Compare commits

265 Commits

Author SHA1 Message Date
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
DanielS
a64b6790c6 feat: Add partner account settings page under /account-kunden
All checks were successful
Staging Build / build (push) Successful in 3m33s
2026-07-04 21:06:46 +02:00
DanielS
060aa97ce5 fix: PDF headers with filename, email download button via protected route
All checks were successful
Staging Build / build (push) Successful in 3m29s
2026-07-04 17:50:02 +02:00
DanielS
80abbfe566 security: replace public PDF URLs with authenticated API route, private bucket
All checks were successful
Staging Build / build (push) Successful in 3m33s
2026-07-04 17:43:33 +02:00
DanielS
28f2285a0e security: fix private storage buckets, api access, and admin RLS bypasses
All checks were successful
Staging Build / build (push) Successful in 3m41s
2026-07-04 17:36:51 +02:00
DanielS
205acb803f docs: update funktionen.md with direct order-to-company mapping
All checks were successful
Staging Build / build (push) Successful in 3m30s
2026-07-04 17:32:52 +02:00
DanielS
5bad959b13 fix: change default internal rewrite and server resolution host to kong
All checks were successful
Staging Build / build (push) Successful in 3m28s
2026-07-04 17:19:09 +02:00
DanielS
f82f08ffd0 fix: resolve internal kong url to configurable public supabase url
All checks were successful
Staging Build / build (push) Successful in 3m30s
2026-07-04 17:15:09 +02:00
DanielS
6a0e882cc9 feat: enforce company authorization on order details and success pages
All checks were successful
Staging Build / build (push) Successful in 3m45s
2026-07-04 16:44:53 +02:00
DanielS
9c50ea8368 fix: resolve internal supabase-kong urls to external domain
All checks were successful
Staging Build / build (push) Successful in 3m24s
2026-07-04 16:38:27 +02:00
DanielS
305af64fd6 feat: assign orders to companies directly and support admin reassignment
All checks were successful
Staging Build / build (push) Successful in 3m36s
2026-07-04 16:08:31 +02:00
DanielS
72852a6b08 docs: add project documentation funktionen.md 2026-07-04 15:43:45 +02:00
DanielS
32c2842dbd feat: enable company-wide order viewing and editing
All checks were successful
Staging Build / build (push) Successful in 3m31s
2026-07-04 15:41:48 +02:00
DanielS
149eae59c9 feat: restrict order creation to users assigned to a company
All checks were successful
Staging Build / build (push) Successful in 3m31s
2026-07-04 15:33:44 +02:00
DanielS
5f65f890ee feat: link end customers directly to companies
All checks were successful
Staging Build / build (push) Successful in 3m37s
2026-07-04 15:17:04 +02:00
DanielS
d3bf4b1db9 feat(admin): set default partner_id to companyId in end-customer dialog
All checks were successful
Staging Build / build (push) Successful in 3m28s
2026-07-03 16:22:59 +02:00
DanielS
553ec7eadd fix(admin-orders): make PDF download/view always available by generating PDF dynamically on the fly if missing
All checks were successful
Staging Build / build (push) Successful in 3m33s
2026-07-03 15:14:34 +02:00
DanielS
4c6fde326b refactor(order): change order prefix from BE to AE for Anfragenummer 2026-07-03 14:58:43 +02:00
DanielS
23be8bcb52 refactor(wording): change 'Angebot' to 'Anfrage' in client views and order emails 2026-07-03 14:55:56 +02:00
DanielS
5c28fbdea5 feat(wizard): support OR logic for requirements (at least one dependency satisfies requirements)
All checks were successful
Staging Build / build (push) Successful in 3m32s
2026-07-03 14:21:15 +02:00
DanielS
90ec351d96 fix(db-tools): resolve REINDEX transaction error by executing directly via pg client, and dynamically support all database tables in integrity dashboard 2026-07-03 14:01:27 +02:00
DanielS
38651037bc fix(users): refactor user admin actions to return status objects instead of throwing to prevent production error obfuscation
All checks were successful
Staging Build / build (push) Successful in 3m29s
2026-07-03 13:51:00 +02:00
DanielS
12d06e46ad feat(email): improve password reset emails, onboarding templates, and handle server action errors without production obfuscation
All checks were successful
Staging Build / build (push) Successful in 3m28s
2026-07-03 13:34:43 +02:00
DanielS
f914e9cdf1 fix(auth): restore middleware, fix setup redirect loop, and add next.config rewrites proxying Supabase requests
All checks were successful
Staging Build / build (push) Successful in 3m27s
2026-07-03 10:57:22 +02:00
DanielS
99f859cde3 style(admin): make product table list scrollable with sticky header
All checks were successful
Staging Build / build (push) Successful in 3m27s
2026-07-03 10:50:31 +02:00
DanielS
4f69fd7e44 feat(role): add 'verwaltung' role with gated access to settings and tools
All checks were successful
Staging Build / build (push) Successful in 3m26s
2026-07-03 10:10:57 +02:00
DanielS
ca07dd0079 feat(setup): make isSetupNeeded check more robust using both auth listUsers and db count 2026-07-03 10:05:18 +02:00
DanielS
233b9f135d feat(setup): remove default admin info@hephex.de automation 2026-07-03 09:59:07 +02:00
DanielS
6dc1b1c00c feat(setup): add initial setup wizard for admin account and SMTP configuration 2026-07-03 09:55:24 +02:00
DanielS
f5f577ea73 feat(customer): add bank account details support 2026-07-03 09:46:47 +02:00
DanielS
250dd82847 style(home): update Zulip and Wiki card icons to fit their concepts 2026-07-03 09:39:14 +02:00
DanielS
b8420cc1ee style(home): change license order CTA to inquiry and refine text
All checks were successful
Staging Build / build (push) Successful in 3m22s
2026-07-03 09:02:33 +02:00
DanielS
7a61002ef5 feat(order): add net/tax/gross breakdown for one-time and monthly totals
All checks were successful
Staging Build / build (push) Successful in 3m28s
2026-07-03 08:50:28 +02:00
DanielS
147dab5607 fix: fix overlapping PDF labels and show net tax gross prices in wizard
All checks were successful
Staging Build / build (push) Successful in 3m25s
2026-07-03 01:13:16 +02:00
DanielS
e67695e36e fix: prevent resolving internal supabase urls to localhost inside docker container
All checks were successful
Staging Build / build (push) Successful in 3m37s
2026-07-03 01:04:49 +02:00
DanielS
728363fa71 feat: hide excluded products and auto-deselect conflicts in wizard
All checks were successful
Staging Build / build (push) Successful in 3m25s
2026-07-03 00:52:22 +02:00
DanielS
4b3ba63e2c fix: resolve Supabase storage pdf URLs on client and server
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-03 00:49:11 +02:00
DanielS
78a16607f9 feat: allow quote editing for all statuses except completed
All checks were successful
Staging Build / build (push) Successful in 3m35s
2026-07-03 00:30:25 +02:00
DanielS
2bfadb49c8 feat: open wizard directly at software step (step 3) when editing 2026-07-03 00:29:22 +02:00
DanielS
2dcc20eb3a style: force dark mode globally and remove theme switcher 2026-07-03 00:26:37 +02:00
DanielS
a9e120e681 Merge branch 'staging' of https://gitea.hephex.de/admin/webshop into staging
All checks were successful
Staging Build / build (push) Successful in 3m27s
2026-07-03 00:22:51 +02:00
DanielS
081baf15ee style: improve accessibility contrast and set default theme to dark 2026-07-03 00:22:10 +02:00
DanielS
e3cdf6683a feat: implement quote editing for admins and creators 2026-07-03 00:22:10 +02:00
DanielS
d72f0b10db Kontraste verbessert und Standard-Theme auf Dunkel gesetzt
All checks were successful
Staging Build / build (push) Successful in 3m40s
2026-07-03 00:15:54 +02:00
DanielS
a4414cc0e6 Angebote bearbeiten fuer Admin und Ersteller implementiert
All checks were successful
Staging Build / build (push) Successful in 3m51s
2026-07-03 00:03:48 +02:00
DanielS
023c789baf feat: add customer search and scrollable list to orderwizard
All checks were successful
Staging Build / build (push) Successful in 3m37s
2026-07-01 09:37:30 +02:00
DanielS
a3498fee66 Merge branch 'staging' of https://gitea.hephex.de/Daniel/webshop into staging
All checks were successful
Staging Build / build (push) Successful in 3m39s
2026-07-01 08:41:21 +02:00
DanielS
f3baada896 limit recent orders visibility to last 3
All checks were successful
Staging Build / build (push) Successful in 3m26s
2026-06-30 22:36:40 +02:00
DanielS
8f447d52e5 Add lockout logic and email notification utils
All checks were successful
Staging Build / build (push) Successful in 3m34s
2026-06-30 22:28:30 +02:00
DanielS
4927216794 limit recent orders visibility to last 5
All checks were successful
Staging Build / build (push) Successful in 3m29s
2026-06-30 22:09:12 +02:00
DanielS
541a9651ec Fix recent orders component UI and styling
All checks were successful
Staging Build / build (push) Successful in 3m40s
2026-06-30 22:00:38 +02:00
DanielS
c4fe221350 Update external links to real URLs for Zulip, Wiki, Support
All checks were successful
Staging Build / build (push) Successful in 3m36s
2026-06-30 21:26:37 +02:00
DanielS
2f3eb8a3f7 style: update workspace landing page sections and titles 2026-06-30 17:26:33 +02:00
DanielS
d56aab3f8a fix: import LifeBuoy icon in HomeClient
All checks were successful
Staging Build / build (push) Successful in 3m37s
2026-06-30 17:03:40 +02:00
DanielS
4cf1ac0eb7 fix: resolve invitation link expiration by adding verification landing page
All checks were successful
Staging Build / build (push) Successful in 3m35s
2026-06-30 16:21:13 +02:00
DanielS
10f8683d7f style(admin): improve company dropdown layout in user list component
All checks were successful
Staging Build / build (push) Successful in 3m29s
2026-06-30 15:45:00 +02:00
DanielS
19e9733144 feat(admin): add API endpoint to download order confirmation PDF securely as attachment 2026-06-30 15:36:42 +02:00
DanielS
fc51d53aa3 style(admin): align company dropdown in user list to the right to avoid horizontal scrolling
All checks were successful
Staging Build / build (push) Successful in 3m30s
2026-06-30 15:32:08 +02:00
DanielS
4b07e68aa0 style(admin): improve user dialog company dropdown layout
All checks were successful
Staging Build / build (push) Successful in 3m50s
2026-06-30 15:24:02 +02:00
DanielS
19b4ac2729 refactor: rename middleware.ts to proxy.ts per Next.js 16 spec 2026-06-30 15:23:57 +02:00
DanielS
5b7ccfd8d9 fix(dsgvo): correctly anonymize last name and street for end customers 2026-06-30 15:23:44 +02:00
DanielS
fbb75567cf refactor(admin): rename partner to user in company assignment pages 2026-06-30 15:23:39 +02:00
DanielS
8f4f05a3cd refactor: rename orders to inquiries and update PDF and email text 2026-06-30 15:23:33 +02:00
DanielS
bdc9e6541c feat(admin): filter dashboard revenue KPI by current month and completed status 2026-06-30 15:23:28 +02:00
DanielS
e25344a185 docs: update imprint and privacy policy for CASPOS 2026-06-30 15:23:21 +02:00
DanielS
ccdfa7402b refactor(order): rename Bestellbestaetigung to Angebotsbestaetigung
All checks were successful
Staging Build / build (push) Successful in 3m22s
2026-06-26 09:06:45 +02:00
DanielS
fbab22506b feat(admin): edit order status with dropdown and notify partner by email
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-26 09:04:06 +02:00
DanielS
c9ae185fdf Fix Supabase URL for staging environment and update admin recent orders polling
All checks were successful
Staging Build / build (push) Successful in 3m24s
2026-06-25 23:46:03 +02:00
DanielS
da7bd91b6f fix: query orders using admin client in admin routes to bypass RLS and show all orders to admin
All checks were successful
Staging Build / build (push) Successful in 3m24s
2026-06-25 23:30:29 +02:00
DanielS
45fb87d589 feat: add admin dashboard revenue chart and fix empty customer order bug
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-25 23:23:53 +02:00
DanielS
71c6ad3721 fix: make last license date input only applicable to one-time purchases and ensure multiplier only affects one_time products
All checks were successful
Staging Build / build (push) Successful in 2m22s
2026-06-25 23:07:14 +02:00
DanielS
1a2ace937c feat: add update price modifier for existing customers based on last license date
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 23:04:48 +02:00
DanielS
16fb713665 feat: rename invoice (RE-) to order confirmation (BE-) throughout the application
All checks were successful
Staging Build / build (push) Successful in 2m42s
2026-06-25 22:43:17 +02:00
DanielS
52f2a78753 fix: resolve client-side supabase auth session visibility on page.tsx by querying server-side and passing to HomeClient client component
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-06-25 22:34:14 +02:00
DanielS
e93cf7805c feat: modernize landing page design, localize login/forgot password forms, and add public privacy/imprint pages
All checks were successful
Staging Build / build (push) Successful in 2m42s
2026-06-25 22:25:11 +02:00
DanielS
08382ff4a1 sec: Add form-action to CSP and remove duplicate HSTS header based on ZAP report
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-06-25 17:20:27 +02:00
DanielS
f44cf1a5f6 sec: Add HTTP security headers and disable X-Powered-By header based on ZAP report
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 15:49:40 +02:00
DanielS
d3df9998f0 feat: Add database backup export and import as ZIP in admin settings
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-25 15:18:41 +02:00
DanielS
47928fad3f feat: Add product dependencies (requirements and exclusions) to database, admin UI and wizard validation
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-25 14:38:30 +02:00
DanielS
5a292a8ddc feat: Add action and dialog to copy/transfer modules between products
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-25 14:03:26 +02:00
DanielS
9246fb7d77 fix: Filter products in admin list based on show_in_kauf and show_in_abo flags instead of billing_interval
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 13:07:18 +02:00
DanielS
9e189f331a feat: Add quantity support for product modules, multiply prices in order snapshot and pdf
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 13:04:37 +02:00
DanielS
fe05e13d3f feat: add Kaufprodukte and Mietprodukte filter checkboxes in products view
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-06-25 12:51:17 +02:00
DanielS
fd7a53b954 fix: resolve dialog scrolling overlap by transition to flex layout and native overflow-y-auto
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 09:50:43 +02:00
DanielS
563a483c8d fix: constrain ScrollArea maxHeight to force dialog scrolling
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-06-25 09:24:41 +02:00
DanielS
4fa5f71d16 feat: change products view sorting to category grouping with subheaders
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-06-25 09:19:28 +02:00
DanielS
ab9f8f93be feat: add sort by category button to products list view
All checks were successful
Staging Build / build (push) Successful in 2m20s
2026-06-25 09:15:39 +02:00
DanielS
5608e9fcc6 fix: make admin product edit form scrollable to prevent modules cutoff
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 09:13:48 +02:00
DanielS
25703d400b fix: adjust button text colors for dark mode default buttons
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-06-25 09:10:38 +02:00
DanielS
f9fb90848b feat: restructure checkout steps, add Kauf/Abo selection and product display flags
All checks were successful
Staging Build / build (push) Successful in 2m41s
2026-06-25 09:03:53 +02:00
DanielS
eafd890297 feat: update landing page icons for retail, gastronomy, and hybrid modules
All checks were successful
Staging Build / build (push) Successful in 2m40s
2026-06-25 02:57:24 +02:00
DanielS
af44f4b3be feat: Add Orders link to navbar dropdown for partner role
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-25 02:53:27 +02:00
DanielS
b2656345e8 feat: Send email confirmation with PDF attachment upon successful order
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-06-25 02:47:03 +02:00
DanielS
7cad819df8 fix: Invalidate user list cache on company creation and refresh page on popup creation
All checks were successful
Staging Build / build (push) Successful in 2m44s
2026-06-25 02:37:45 +02:00
DanielS
b537ca50cf feat: Add email field to end_customers table and UI screens
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 02:29:33 +02:00
DanielS
b9e6559bff style: Update warning title for unassigned users 2026-06-25 02:27:26 +02:00
DanielS
2f96552fe8 revert: Remove group by partner option from end customers view
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-06-25 02:23:50 +02:00
DanielS
6a2fc16f75 feat: Add group by partner option to company end customers list
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 02:18:53 +02:00
DanielS
0ff4df2a12 feat: Add end customer view for companies with CRUD and partner assignment
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 02:18:00 +02:00
DanielS
ef9422908d feat: list end customers in companies admin and improve dark/light mode contrast across all admin components
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 02:11:07 +02:00
DanielS
bf27c62f62 feat: add button and modal to display users (partners) of a company in companies management page
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-06-25 01:58:34 +02:00
DanielS
5a2edef0df feat: add search field to companies list page
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:57:40 +02:00
DanielS
a1ad75c2db feat: add delivery address migration, update companies server actions and rewrite companies page to support creation and edit of full address
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-06-25 01:52:05 +02:00
DanielS
85df922498 feat: make company selection mandatory for partner role and add inline company creation in UserDialog
All checks were successful
Staging Build / build (push) Successful in 2m21s
2026-06-25 01:43:53 +02:00
DanielS
67a2c2a67b feat: add hideLocked checkbox to filter out gesperrt users by default
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:41:51 +02:00
DanielS
07c2758b73 feat: add first_name and last_name to users and implement inline editing in user-list
All checks were successful
Staging Build / build (push) Successful in 2m40s
2026-06-25 01:38:07 +02:00
DanielS
30a9de0e6d style: clarify user deletion blocking message and offer to lock instead
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 01:33:57 +02:00
DanielS
24929494a6 feat: check for existing orders before user deletion and offer to lock instead
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:33:38 +02:00
DanielS
86d71b2441 fix: improve deleteUser error logging and handling to prevent silent DB failures
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-25 01:30:10 +02:00
DanielS
744302e8ab fix: resolve popover cutoff inside table by setting overflow-visible and adding debug logging for companies
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:29:01 +02:00
DanielS
396f135e87 fix: resolve infinite loading on user deletion by adding try-finally and deleting from public.users first
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-25 01:25:49 +02:00
DanielS
03d711c2e1 style: rename CASPOS to CASPOS Shop in welcome email
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-25 01:19:33 +02:00
DanielS
2de8913dac style: replace HEPHEX branding with CASPOS in welcome email
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:18:26 +02:00
DanielS
82e877249a style: improve welcome email layout and content
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:17:54 +02:00
DanielS
872e973a36 fix: add missing use client directive to user-dialog.tsx
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-06-25 01:11:14 +02:00
DanielS
4cd6d86f33 feat: add company selection to user dialog, require email password link on user creation, password validation with confirmation, and company grouping with search
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-25 01:09:19 +02:00
DanielS
69c8e8e5c1 fix: use token_hash with /auth/confirm route for password reset link
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 00:54:10 +02:00
DanielS
5652ce1098 fix: replace localhost in reset link with staging domain
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-06-25 00:30:20 +02:00
DanielS
cebf677758 fix: clean users.ts rewrite, add company_id to createUser, fix resetPassword link
All checks were successful
Staging Build / build (push) Successful in 2m38s
2026-06-25 00:20:18 +02:00
DanielS
410289a519 Fix typings and company name access
All checks were successful
Staging Build / build (push) Successful in 2m38s
2026-06-25 00:07:08 +02:00
DanielS
49607f484f Add email send to password reset
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-24 23:56:52 +02:00
DanielS
2120698192 Clean admin settings page, remove duplicate imports and partner code
All checks were successful
Staging Build / build (push) Successful in 2m41s
2026-06-24 23:39:33 +02:00
DanielS
a9a5dd4e6f Cleanup admin settings page, remove duplicate imports and partner code
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-24 23:37:40 +02:00
DanielS
520599986c Add user lock/unlock functionality and remove partner management
Some checks failed
Staging Build / build (push) Failing after 26s
2026-06-24 23:29:16 +02:00
DanielS
864bb3a0c1 Fix companies actions, add assignCompanyToUser, cleanup duplicates
All checks were successful
Staging Build / build (push) Successful in 2m41s
2026-06-24 23:11:02 +02:00
DanielS
82c89b70bf Add company CRUD, admin sidebar order, partner/company pages
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-24 23:05:15 +02:00
DanielS
ac0b0f607d Add Partner/Firmen links, migration for companies and partner relationship
All checks were successful
Staging Build / build (push) Successful in 2m40s
2026-06-24 22:51:02 +02:00
DanielS
4e86ad7a9d Rename companies to partners and update UI
All checks were successful
Staging Build / build (push) Successful in 2m44s
2026-06-24 22:29:51 +02:00
DanielS
7a14aa0f75 feat: add company management and user-company assignment in admin panel
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-24 17:12:40 +02:00
DanielS
6b66cc5c41 feat: add lock/unlock button to partner table in Einstellungen
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-06-24 17:05:16 +02:00
DanielS
cd04309e3e feat: add user role management (admin/partner/gesperrt) with login block
All checks were successful
Staging Build / build (push) Successful in 2m44s
2026-06-24 14:26:25 +02:00
DanielS
26de6820e3 feat: add Einstellungen, SMTP and DB-Tools links to admin sidebar
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-24 14:04:24 +02:00
DanielS
9fbab07e1a fix: resolve header logout state synchronization issue
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-24 13:31:30 +02:00
DanielS
fe1d15090a feat: hide all demo badges when demo mode is disabled
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-24 13:19:00 +02:00
DanielS
3c60889e2a feat: implement admin validation middleware, DB integrity scan, automatic schema repair, and reindexing dashboard 2026-06-24 13:06:23 +02:00
DanielS
6e5cee0180 refactor: rename proxy.ts to middleware.ts and update function name to middleware 2026-06-24 13:02:28 +02:00
DanielS
f2db40fa36 fix: resolve client-side connection timeout by stripping internal port 8000 on Supabase URL rewriting
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-24 11:00:44 +02:00
DanielS
0968f1a753 ci: add docker prune step to clean up Gitea runner cache after push
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-24 10:57:25 +02:00
DanielS
ac5bd766a4 ci: add docker image prune -f to staging workflow deployment script 2026-06-24 10:56:00 +02:00
DanielS
5c8c198fc5 fix: production login error masking, resolve navbar hydration mismatch, and gracefully handle database render errors
All checks were successful
Staging Build / build (push) Successful in 2m15s
2026-06-24 10:54:00 +02:00
DanielS
e1f47ddcd0 feat: add role selection to user creation and UI updates
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 01:58:01 +02:00
DanielS
934879e5cf fix: redirect to home page on sign out 2026-06-24 01:40:17 +02:00
DanielS
4a17ea378e feat: make orders list clearer and put navbar globally except for admin area
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 01:37:01 +02:00
DanielS
e829006b77 feat: update contact link on homepage to point to caspos.de/kontakt/
Some checks failed
Staging Build / build (push) Failing after 1m56s
2026-06-24 01:26:44 +02:00
DanielS
76e46c08a4 feat: update privacy policy links to point to caspos.de/datenschutz/
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-24 01:25:41 +02:00
DanielS
01c46be9ef add zulip dummy for quick access
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-24 01:23:30 +02:00
DanielS
f09cec4e25 fix: ignore network errors during concurrent session check to prevent false-positive logouts
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-24 01:14:33 +02:00
DanielS
b34ed79d2b feat: implement next redirect flow, remove inactivity auto-logout, and add concurrent session termination notification
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 01:09:17 +02:00
DanielS
47664c0aae fix: resolve mixed content errors by synchronizing client supabase URL protocol with window location protocol
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-24 00:57:22 +02:00
DanielS
5662b9f770 feat: allow admin to order licenses and query all partner end customers
All checks were successful
Staging Build / build (push) Successful in 2m15s
2026-06-24 00:32:20 +02:00
DanielS
a56eb93ffc feat: handle redirects for admin in /order page and implement next query param on login
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-24 00:29:46 +02:00
DanielS
615b0719f1 feat: redirect user dynamically based on role instead of /protected after login
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 00:23:51 +02:00
DanielS
62e3c7804a feat: fix logout button, inactivity tracker, and concurrent session termination
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:15:59 +02:00
DanielS
9878cdb2a2 feat: add CASPOS Store brand and Demo badge to admin sidebar footer
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:12:45 +02:00
DanielS
f19fda5494 feat: enforce admin role checks in layouts and navbar navigation
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:05:16 +02:00
DanielS
7b22889e23 feat: add partner list and customer list explorer to admin settings
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-24 00:02:27 +02:00
DanielS
bdbf840529 feat: lock down registration and enforce partner-only logins
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 23:59:22 +02:00
DanielS
b71b84da1b migration: create public.customers table with RLS
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-23 23:52:57 +02:00
DanielS
1b4e998cf9 feat: add admin settings page with toggle switch for demo banner
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-23 23:43:22 +02:00
DanielS
a99adb88d6 feat: update navbar links, settings labels, dark mode text color, and banner toggle hook 2026-06-23 23:43:16 +02:00
DanielS
dd2c3859e9 feat: add custom navbar with inline supabase clients and custom dropdown
All checks were successful
Staging Build / build (push) Successful in 2m22s
2026-06-23 23:27:23 +02:00
DanielS
0729dd21be Implement dynamic SMTP setting loader, admin product dependency matrix UI, recursive deselect cascade, and backend catalog validation
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 04:42:50 +02:00
DanielS
de1d228fb2 feat: port all auth forms to Next.js Server Actions to avoid exposing Supabase API to the browser
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 04:39:00 +02:00
DanielS
3228c76675 fix: use maybeSingle for settings query to avoid failing when the table is empty
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-23 04:36:16 +02:00
DanielS
b039da3ff8 fix: force dynamic rendering on admin users page to load data at request-time
All checks were successful
Staging Build / build (push) Successful in 2m20s
2026-06-23 04:29:58 +02:00
DanielS
07eb18c61c debug: print detailed error message when loading admin users fails
All checks were successful
Staging Build / build (push) Successful in 2m25s
2026-06-23 04:11:14 +02:00
DanielS
5ba9869400 feat: create users and settings tables, replace getSession with getUser
All checks were successful
Staging Build / build (push) Successful in 2m29s
2026-06-23 04:05:39 +02:00
DanielS
9542fdfe4a migration: add order_number and order_hash columns to orders
All checks were successful
Staging Build / build (push) Successful in 2m27s
2026-06-23 04:02:36 +02:00
DanielS
88f18246ce feat: split pricing into one-time and monthly totals, fix invoice pdf rendering
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 04:01:03 +02:00
DanielS
6b76e82220 fix: resolve customer creation fk violation and implement instant updates in admin dialogs
All checks were successful
Staging Build / build (push) Successful in 2m26s
2026-06-23 03:27:46 +02:00
DanielS
898a5253e5 fix: remove default seeded categories from migrations
All checks were successful
Staging Build / build (push) Successful in 2m26s
2026-06-23 03:22:30 +02:00
DanielS
756ec5c002 feat: add automatic database migration and seeding on docker startup
All checks were successful
Staging Build / build (push) Successful in 2m47s
2026-06-23 03:14:40 +02:00
DanielS
235efd5a47 fix: use static auth cookie name to sync session across browser and server contexts
All checks were successful
Staging Build / build (push) Successful in 1m56s
2026-06-23 02:58:55 +02:00
DanielS
c0c5240f8d fix: resolve internal docker hostnames to client window location hostname in browser context
All checks were successful
Staging Build / build (push) Successful in 1m49s
2026-06-23 02:42:50 +02:00
DanielS
ca69db2395 fix: prioritize SUPABASE_URL and SUPABASE_ANON_KEY server-side environment variables
All checks were successful
Staging Build / build (push) Successful in 1m47s
2026-06-23 02:37:48 +02:00
DanielS
e789a38d19 fix: add environment variable fallback for Supabase client configuration
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 02:36:31 +02:00
DanielS
2ec5993e63 Add Supabase ARG/ENV before build and in runtime stage
All checks were successful
Staging Build / build (push) Successful in 1m59s
2026-06-23 02:16:45 +02:00
DanielS
6db2610c8d Update Docker build to use internal supabase‑kong URL"
All checks were successful
Staging Build / build (push) Successful in 1m53s
2026-06-23 02:10:11 +02:00
DanielS
0b2809b13e Add Supabase ARG/ENV before build in Dockerfile (final)
All checks were successful
Staging Build / build (push) Successful in 1m41s
2026-06-23 01:32:46 +02:00
DanielS
9ffa04e551 Insert Supabase ARG/ENV before Next.js build in Dockerfile
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 01:16:37 +02:00
DanielS
505629f798 Add build args to Docker build step in staging workflow
All checks were successful
Staging Build / build (push) Successful in 1m46s
2026-06-23 01:09:58 +02:00
DanielS
08418e22d3 Add Supabase keys to staging build step
All checks were successful
Staging Build / build (push) Successful in 1m44s
2026-06-23 01:03:55 +02:00
DanielS
0f55e17095 Enable Next.js standalone mode and simplify Dockerfile copy block
All checks were successful
Staging Build / build (push) Successful in 1m43s
2026-06-23 00:32:08 +02:00
DanielS
fa1edc9c12 Add cookies handling to smtp-settings routes
Some checks failed
Staging Build / build (push) Failing after 1m55s
2026-06-23 00:27:55 +02:00
DanielS
bab2930b97 Add cookies handling to send-test-email route
Some checks failed
Staging Build / build (push) Failing after 27s
2026-06-23 00:26:08 +02:00
DanielS
e97ff51dc8 Fix client directive on admin settings page and install nodemailer
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-23 00:23:47 +02:00
DanielS
4402d4ec64 Add SMTP mail utility, admin settings page, and API routes for SMTP configuration and test email
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-23 00:17:41 +02:00
DanielS
4b9c289ec4 test build server
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-06-22 23:54:12 +02:00
DanielS
072ceaf5f0 fix build yaml staging adress of the host for deployment
All checks were successful
Staging Build / build (push) Successful in 3m28s
2026-06-22 23:40:47 +02:00
DanielS
e31721432b Update optional TS copy lines with wildcard in Dockerfile
Some checks failed
Staging Build / build (push) Failing after 2m34s
2026-06-22 23:34:29 +02:00
DanielS
e1a555d4f9 Fix duplicate next.config copy in Dockerfile
Some checks failed
Staging Build / build (push) Failing after 1m30s
2026-06-22 23:29:29 +02:00
DanielS
8bf59b20f6 Make public copy flexible with wildcard in Dockerfile 2026-06-22 23:29:06 +02:00
DanielS
3bc7c6ad3b Make Dockerfile tolerant to missing config files
Some checks failed
Staging Build / build (push) Failing after 1m32s
2026-06-22 23:24:58 +02:00
DanielS
65e133a391 fix staging build yaml
Some checks failed
Staging Build / build (push) Failing after 1m45s
2026-06-22 23:18:41 +02:00
DanielS
7ab0c28f6f fix staging build yaml docker build image
Some checks failed
Staging Build / build (push) Failing after 29s
2026-06-22 23:14:22 +02:00
DanielS
c187c1dc60 fix dockerfile position
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-22 23:10:58 +02:00
DanielS
9ddf5faf66 change from GITEA to REGISTRY for build server secrets
Some checks failed
Staging Build / build (push) Failing after 41s
2026-06-22 23:05:13 +02:00
DanielS
f11322e98d ci: fix docker login command and update deployment paths
Some checks failed
Staging Build / build (push) Failing after 31s
2026-06-22 22:45:20 +02:00
DanielS
126de81834 add container for build server
Some checks failed
Staging Build / build (push) Failing after 1m22s
2026-06-22 22:41:04 +02:00
DanielS
7bb6807d37 Fix global error page, update CI workflow, add Dockerfile
Some checks failed
Staging Build / build (push) Failing after 34s
2026-06-22 22:30:55 +02:00
DanielS
3eb72481ce Add key props to recent orders list rendering
Some checks failed
Staging Build / build (push) Failing after 26s
2026-06-22 22:25:21 +02:00
DanielS
2403408b6c Fix React key props in order and my-orders pages 2026-06-22 22:24:21 +02:00
DanielS
f91499861e fix: korrigiere workflow syntax
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-22 21:41:42 +02:00
DanielS
84ced5a170 remove unused build config
Some checks failed
Staging Build / build (push) Failing after 2s
2026-06-22 21:38:57 +02:00
DanielS
d7a45576b2 build server test
Some checks failed
Staging Build / build (push) Failing after 1m1s
2026-06-22 21:32:53 +02:00
DanielS
dedf1945ef correct build server config
Some checks failed
Staging Build / build (push) Failing after 24s
2026-06-22 21:27:06 +02:00
e6913c03f5 Merge pull request 'develop build server' (#1) from develop into staging
Some checks failed
Staging Build / build (push) Failing after 48s
Reviewed-on: #1
2026-06-22 21:24:09 +02:00
156 changed files with 15980 additions and 1422 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_snapshot` (JSONB) und `customer_snapshot` (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 **B2B-Angebot**.
- Weist die einmalige Gesamtsumme aus.
- **Typ `subscription` (Abonnement)**:
- Generiert eine **Angebotsbestä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,84 @@
# 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_multiple` (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)
### `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_snapshot` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
- `order_snapshot` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
- `pdf_url` (TEXT)
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
- `created_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,36 @@
---
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

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.

4
.brain/index.md Normal file
View File

@@ -0,0 +1,4 @@
# Second Brain Wiki
- [Supabase Schema](file:///c:/source/webshop/.brain/supabase-schema.md)
- [ADR Log](file:///c:/source/webshop/.brain/adr-log.md)

48
.brain/supabase-schema.md Normal file
View File

@@ -0,0 +1,48 @@
# 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)

View File

@@ -7,6 +7,11 @@ on:
jobs:
build:
runs-on: webserver
container:
image: catthehacker/ubuntu:act-latest
defaults:
run:
working-directory: shop
steps:
- name: Code auschecken
uses: actions/checkout@v4
@@ -15,4 +20,46 @@ jobs:
run: npm install
- name: Staging-Build generieren
run: npm run build -- --configuration=staging
env:
NODE_ENV: production
# KORREKTUR: Wir brennen die interne Docker-Netzwerk-URL fest ein!
NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }}
run: npm run build
- name: Docker login to Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.hephex.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# KORREKTUR: Wir übergeben die INTERNE Docker-URL auch an das Docker-Image!
- name: Build Docker image
run: |
docker build \
--build-arg NEXT_PUBLIC_SUPABASE_URL="http://supabase-kong:8000" \
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="${{ secrets.STAGING_SUPABASE_ANON_KEY }}" \
-t gitea.hephex.de/daniel/webshop:staging -f Dockerfile .
working-directory: .
# KORREKTUR: Auch der Push wird vom Hauptordner aus gestartet
- name: Push Docker image
run: docker push gitea.hephex.de/daniel/webshop:staging
working-directory: .
- name: Clean up runner Docker cache
run: |
docker image prune -f
docker builder prune -f
working-directory: .
# KORREKTUR: IP-Adresse exakt auf Ihren Proxmox-Staging-Container eingestellt
- name: Deploy to Proxmox LXC via SSH
uses: appleboy/ssh-action@v0.1.5
with:
host: 192.168.178.132
username: ${{ secrets.PROXMOX_USER }}
key: ${{ secrets.PROXMOX_SSH_KEY }}
script: |
cd /opt/webshop/supabase/supabase-project
docker compose pull webshop
docker compose up -d webshop
docker image prune -f

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ example_data/
shop/Leitfaden
.gitignore
db-access.txt
2026-06-25-ZAP-Report-.json

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Use a multistage build for a Next.js app
# ---------- Builder stage ----------
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Install dependencies (use package lock when present)
COPY shop/package*.json ./
RUN npm ci --omit=dev
# Install nodemailer for email sending
RUN npm install nodemailer
# Copy the source code
COPY shop/ .
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
# Build the production output
RUN npm run build
# ---------- Runtime stage ----------
FROM node:20-alpine AS runner
WORKDIR /app
# Statische Assets kopieren (wichtig für die Chunks/404-Fehler von vorhin)
COPY --from=builder /app/public* ./public
COPY --from=builder /app/.next/static ./.next/static
# Den fertigen Standalone-Server kopieren
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/supabase ./supabase
COPY --from=builder /app/migrate.js ./migrate.js
# Pass Supabase variables to runtime image
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV IS_DOCKER=true
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["sh", "-c", "node migrate.js && node server.js"]

6
scripts/init_supabase.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -e
echo "Supabase default user creation disabled setup wizard will handle user initialization."
exec "$@"

View File

@@ -0,0 +1,27 @@
'use client'
import { useEffect } from 'react'
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
// Ensure the page does not depend on any context or Supabase client.
useEffect(() => {
console.error('Global error caught:', error)
}, [error])
return (
<div className="min-h-screen bg-[#020617] text-white flex items-center justify-center p-8">
<div className="max-w-lg space-y-4 text-center">
<h1 className="text-4xl font-extrabold">Ein unerwarteter Fehler ist aufgetreten</h1>
<p className="text-slate-300">
Bitte versuchen Sie die Seite zu aktualisieren oder kontaktieren Sie den Support.
</p>
<button
onClick={reset}
className="mt-4 px-6 py-2 bg-primary text-white rounded hover:bg-primary/90"
>
Seite neu laden
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,346 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { updateUserProfile, updateCompanyDetails } from '@/lib/actions/users';
import { updatePassword } from '@/lib/actions/auth';
import { Save, KeyRound, Loader2 } from 'lucide-react';
interface AccountSettingsFormProps {
userId: string;
profile: any;
company: any;
}
export function AccountSettingsForm({ userId, profile, company }: AccountSettingsFormProps) {
// Profil State
const [firstName, setFirstName] = useState(profile?.first_name || '');
const [lastName, setLastName] = useState(profile?.last_name || '');
const [profileMessage, setProfileMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [isUpdatingProfile, setIsUpdatingProfile] = useState(false);
// Company State
const [street, setStreet] = useState(company?.street || '');
const [zip, setZip] = useState(company?.zip || '');
const [city, setCity] = useState(company?.city || '');
const [companyEmail, setCompanyEmail] = useState(company?.email || '');
const [companyMessage, setCompanyMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [isUpdatingCompany, setIsUpdatingCompany] = useState(false);
// Passwort State
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordMessage, setPasswordMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
// Profil speichern
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
setProfileMessage(null);
setIsUpdatingProfile(true);
try {
const res = await updateUserProfile(userId, {
first_name: firstName || null,
last_name: lastName || null,
});
if (res.success) {
setProfileMessage({ type: 'success', text: 'Persönliche Daten erfolgreich aktualisiert.' });
} else {
setProfileMessage({ type: 'error', text: res.error || 'Fehler beim Aktualisieren des Profils.' });
}
} catch (err: any) {
setProfileMessage({ type: 'error', text: err.message || 'Ein unerwarteter Fehler ist aufgetreten.' });
} finally {
setIsUpdatingProfile(false);
}
};
// Company speichern
const handleUpdateCompany = async (e: React.FormEvent) => {
e.preventDefault();
if (!company?.id) return;
setCompanyMessage(null);
setIsUpdatingCompany(true);
try {
const res = await updateCompanyDetails(company.id, {
street: street || null,
zip: zip || null,
city: city || null,
email: companyEmail || null,
});
if (res.success) {
setCompanyMessage({ type: 'success', text: 'Unternehmensdaten erfolgreich aktualisiert.' });
} else {
setCompanyMessage({ type: 'error', text: res.error || 'Fehler beim Aktualisieren der Unternehmensdaten.' });
}
} catch (err: any) {
setCompanyMessage({ type: 'error', text: err.message || 'Ein unerwarteter Fehler ist aufgetreten.' });
} finally {
setIsUpdatingCompany(false);
}
};
// Passwort speichern
const handleUpdatePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordMessage(null);
if (newPassword.length < 6) {
setPasswordMessage({ type: 'error', text: 'Das Passwort muss mindestens 6 Zeichen lang sein.' });
return;
}
if (newPassword !== confirmPassword) {
setPasswordMessage({ type: 'error', text: 'Die Passwörter stimmen nicht überein.' });
return;
}
setIsUpdatingPassword(true);
try {
const res = await updatePassword(newPassword);
if (res.success) {
setPasswordMessage({ type: 'success', text: 'Passwort erfolgreich geändert.' });
setNewPassword('');
setConfirmPassword('');
} else {
setPasswordMessage({ type: 'error', text: res.error || 'Fehler beim Ändern des Passworts.' });
}
} catch (err: any) {
setPasswordMessage({ type: 'error', text: err.message || 'Ein unerwarteter Fehler ist aufgetreten.' });
} finally {
setIsUpdatingPassword(false);
}
};
return (
<div className="space-y-8">
{/* 1. Persönliche Daten */}
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-xl">Persönliche Daten</CardTitle>
<CardDescription className="text-slate-400">
Aktualisieren Sie Ihren Vor- und Nachnamen.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Vorname</Label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="Max"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nachname</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="Mustermann"
/>
</div>
</div>
{profileMessage && (
<div
className={`p-3 rounded-lg text-sm border ${
profileMessage.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-400'
: 'bg-red-500/10 border-red-500/20 text-red-400'
}`}
>
{profileMessage.text}
</div>
)}
<div className="flex justify-end">
<Button type="submit" disabled={isUpdatingProfile} className="gap-2">
{isUpdatingProfile ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Speichern...
</>
) : (
<>
<Save className="w-4 h-4" /> Profil speichern
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
{/* 2. Unternehmensdaten */}
{company && (
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-xl">Unternehmensdaten</CardTitle>
<CardDescription className="text-slate-400">
Verwalten Sie die Adressdaten Ihres Partner-Unternehmens.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdateCompany} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="companyName">Firmenname (nicht änderbar)</Label>
<Input
id="companyName"
value={company.name}
disabled
className="bg-slate-950/50 border-white/5 text-slate-400 cursor-not-allowed"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="street">Straße & Hausnummer</Label>
<Input
id="street"
value={street}
onChange={(e) => setStreet(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="Musterstraße 12"
/>
</div>
<div className="space-y-2">
<Label htmlFor="zip">Postleitzahl</Label>
<Input
id="zip"
value={zip}
onChange={(e) => setZip(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="12345"
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
value={city}
onChange={(e) => setCity(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="Musterstadt"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="companyEmail">E-Mail für Rechnungen / Anfragen</Label>
<Input
id="companyEmail"
type="email"
value={companyEmail}
onChange={(e) => setCompanyEmail(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="partner@firma.de"
/>
</div>
</div>
{companyMessage && (
<div
className={`p-3 rounded-lg text-sm border ${
companyMessage.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-400'
: 'bg-red-500/10 border-red-500/20 text-red-400'
}`}
>
{companyMessage.text}
</div>
)}
<div className="flex justify-end">
<Button type="submit" disabled={isUpdatingCompany} className="gap-2">
{isUpdatingCompany ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Speichern...
</>
) : (
<>
<Save className="w-4 h-4" /> Firmendaten speichern
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* 3. Sicherheit / Passwort ändern */}
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-xl">Passwort ändern</CardTitle>
<CardDescription className="text-slate-400">
Geben Sie ein neues Passwort für Ihren Zugang ein.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdatePassword} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="newPassword">Neues Passwort</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="••••••••"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="bg-slate-950 border-white/10 focus:border-primary text-white"
placeholder="••••••••"
/>
</div>
</div>
{passwordMessage && (
<div
className={`p-3 rounded-lg text-sm border ${
passwordMessage.type === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-400'
: 'bg-red-500/10 border-red-500/20 text-red-400'
}`}
>
{passwordMessage.text}
</div>
)}
<div className="flex justify-end">
<Button type="submit" disabled={isUpdatingPassword} className="gap-2">
{isUpdatingPassword ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Ändern...
</>
) : (
<>
<KeyRound className="w-4 h-4" /> Passwort aktualisieren
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, User as UserIcon, Building2, ShieldAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { AccountSettingsForm } from './AccountSettingsForm';
export default async function AccountKundenPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/auth/login?next=/account-kunden');
}
// 1. Hole User aus custom users Tabelle
const { data: dbUser } = await supabase
.from('users')
.select('role, company_id')
.eq('id', user.id)
.single();
// 2. Hole Profil-Daten
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
// 3. Hole Company-Daten falls vorhanden
let company = null;
if (dbUser?.company_id) {
const { data: companyData } = await supabase
.from('companies')
.select('*')
.eq('id', dbUser.company_id)
.single();
company = companyData;
}
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
</Button>
</Link>
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<UserIcon className="w-8 h-8 text-primary" />
Account Einstellungen
</h1>
<p className="text-slate-400 text-sm mt-1">
Verwalten Sie Ihre persönlichen Profildaten, Unternehmensdaten und Sicherheitseinstellungen.
</p>
</div>
</div>
{/* Dashboard Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Linke Infospalte */}
<div className="space-y-6">
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<UserIcon className="w-5 h-5 text-primary" />
Benutzer-Info
</CardTitle>
<CardDescription className="text-slate-400 text-xs">
Ihr aktueller Systemstatus
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<span className="text-slate-500 block text-xs uppercase tracking-wider">E-Mail-Adresse</span>
<span className="text-white font-medium">{user.email}</span>
</div>
<div>
<span className="text-slate-500 block text-xs uppercase tracking-wider">Rolle</span>
<span className="text-amber-400 font-bold uppercase text-xs tracking-wider bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
{dbUser?.role || 'partner'}
</span>
</div>
{company && (
<div>
<span className="text-slate-500 block text-xs uppercase tracking-wider">Unternehmen</span>
<span className="text-white font-medium flex items-center gap-1.5 mt-1">
<Building2 className="w-4 h-4 text-slate-400" />
{company.name}
</span>
</div>
)}
</CardContent>
</Card>
{!dbUser?.company_id && (
<Card className="border-red-500/20 bg-red-500/5">
<CardContent className="p-4 flex gap-3 text-red-400 text-sm">
<ShieldAlert className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-bold">Kein Unternehmen zugewiesen</p>
<p className="text-xs text-red-400/80 mt-1">
Wenden Sie sich an einen Administrator, um Ihrem Account ein Partner-Unternehmen zuzuweisen.
</p>
</div>
</CardContent>
</Card>
)}
</div>
{/* Rechte Formularspalte */}
<div className="md:col-span-2">
<AccountSettingsForm
userId={user.id}
profile={profile}
company={company}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
'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';
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) {
await sendMail({
to: email,
subject: `Bestätigung Ihrer CASPOS-Anfrage ${order.order_number}`,
text: `Vielen Dank für Ihre Anfrage ${order.order_number}.`,
html: `<p>Vielen Dank für Ihre Anfrage <strong>${order.order_number}</strong>.</p>`
});
}
} 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
);
orderItemsList.push(...itemSnapshot.items);
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

@@ -0,0 +1,455 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
Building2,
Plus,
Trash2,
Loader2,
Pencil,
ArrowLeft,
Search,
MapPin,
User,
Users,
Briefcase,
AlertCircle
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
getCompanyDetails,
getEndCustomersByCompanyWithPartners,
getCompanyPartners,
adminCreateEndCustomer,
adminUpdateEndCustomer,
adminDeleteEndCustomer
} from '@/lib/actions/companies'
export default function CompanyCustomersPage() {
const params = useParams()
const router = useRouter()
const companyId = params.id as string
const [company, setCompany] = useState<any>(null)
const [customers, setCustomers] = useState<any[]>([])
const [partners, setPartners] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [loadingAction, setLoadingAction] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// Dialog State
const [isOpen, setIsOpen] = useState(false)
const [editingCustomer, setEditingCustomer] = useState<any>(null)
// Form State
const [companyName, setCompanyName] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [street, setStreet] = useState('')
const [zip, setZip] = useState('')
const [city, setCity] = useState('')
const [vatId, setVatId] = useState('')
const [partnerId, setPartnerId] = useState('')
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const loadData = async () => {
setLoading(true)
try {
const [compDetails, custData, partData] = await Promise.all([
getCompanyDetails(companyId),
getEndCustomersByCompanyWithPartners(companyId),
getCompanyPartners(companyId)
])
setCompany(compDetails)
setCustomers(custData)
setPartners(partData)
} catch (err) {
console.error('Fehler beim Laden der Daten:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (companyId) {
loadData()
}
}, [companyId])
const handleOpenDialog = (customer: any = null) => {
setEditingCustomer(customer)
setCompanyName(customer ? customer.company_name || '' : '')
setFirstName(customer ? customer.first_name || '' : '')
setLastName(customer ? customer.last_name || '' : '')
setStreet(customer ? customer.street || '' : '')
setZip(customer ? customer.zip || '' : '')
setCity(customer ? customer.city || '' : '')
setVatId(customer ? customer.vat_id || '' : '')
setPartnerId(companyId)
setEmail(customer ? customer.email || '' : '')
setError(null)
setIsOpen(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoadingAction(true)
setError(null)
try {
const payload = {
partner_id: companyId,
company_name: companyName.trim(),
first_name: firstName.trim() || undefined,
last_name: lastName.trim() || undefined,
street: street.trim() || undefined,
zip: zip.trim() || undefined,
city: city.trim() || undefined,
vat_id: vatId.trim() || undefined,
email: email.trim() || undefined,
}
if (editingCustomer) {
await adminUpdateEndCustomer(editingCustomer.id, payload)
} else {
await adminCreateEndCustomer(payload)
}
setIsOpen(false)
loadData()
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern des Endkunden.')
} finally {
setLoadingAction(false)
}
}
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Endkunden "${name}" wirklich löschen?`)) return
setLoadingAction(true)
try {
await adminDeleteEndCustomer(id)
loadData()
} catch (err) {
console.error('Fehler beim Löschen des Endkunden:', err)
} finally {
setLoadingAction(false)
}
}
const filteredCustomers = customers.filter((c) => {
const q = searchQuery.toLowerCase()
const nameMatch = c.company_name?.toLowerCase().includes(q)
const personMatch = `${c.first_name || ''} ${c.last_name || ''}`.toLowerCase().includes(q)
const addressMatch = `${c.street || ''} ${c.zip || ''} ${c.city || ''}`.toLowerCase().includes(q)
const partnerMatch = c.partner_name?.toLowerCase().includes(q)
const emailMatch = c.email?.toLowerCase().includes(q)
return nameMatch || personMatch || addressMatch || partnerMatch || emailMatch
})
return (
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-8">
{/* Back Link */}
<div>
<Link
href="/admin/companies"
className="inline-flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Zurück zur Firmenverwaltung
</Link>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Briefcase className="w-8 h-8 text-indigo-500" />
Endkunden von {company ? company.name : '...'}
</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
Hier verwalten Sie alle Endkunden, die Partnern dieser Firma zugeordnet sind.
</p>
</div>
<Button
onClick={() => handleOpenDialog()}
disabled={loading || partners.length === 0}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold"
title={partners.length === 0 ? 'Es müssen zuerst Benutzer für diese Firma existieren' : ''}
>
<Plus className="w-4 h-4 mr-2" /> Endkunde anlegen
</Button>
</div>
{/* Warning if no partners exist */}
{!loading && partners.length === 0 && (
<div className="p-4 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/30 rounded-xl flex gap-3 text-amber-800 dark:text-amber-300 text-sm">
<AlertCircle className="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div>
<p className="font-semibold">Keine Benutzer zugewiesen</p>
<p className="mt-1">
Diese Firma hat aktuell keine zugewiesenen Partner (Benutzer mit der Rolle &quot;partner&quot;).
Sie müssen zuerst in der <Link href="/admin/users" className="underline font-semibold hover:text-amber-900 dark:hover:text-amber-200">Benutzerverwaltung</Link> Partner erstellen und dieser Firma zuweisen, um Endkunden anzulegen.
</p>
</div>
</div>
)}
{/* Table Section */}
<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">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
placeholder="Endkunden suchen (Name, Ansprechpartner, Ort)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all"
/>
</div>
{loading ? (
<div className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
<span className="ml-3 text-slate-400 text-sm">Lade Endkunden...</span>
</div>
) : filteredCustomers.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-xl text-slate-500">
<Users className="w-10 h-10 mx-auto mb-3 opacity-30" />
Keine Endkunden gefunden.
</div>
) : (
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-200 dark:border-white/10 text-slate-400 text-xs font-semibold uppercase">
<th className="pb-3">Endkunde (Firma / Kontakt)</th>
<th className="pb-3">Lieferadresse</th>
<th className="pb-3">E-Mail</th>
<th className="pb-3">USt-ID</th>
<th className="pb-3">Zugeordneter Partner</th>
<th className="pb-3 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
{filteredCustomers.map((customer) => (
<tr key={customer.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
<td className="py-4">
<div className="font-semibold text-slate-900 dark:text-white">
{customer.company_name}
</div>
{(customer.first_name || customer.last_name) && (
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 mt-0.5">
<User className="w-3 h-3 text-slate-400 shrink-0" />
<span>{customer.first_name || ''} {customer.last_name || ''}</span>
</div>
)}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
{customer.street ? (
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span>
{customer.street}, {customer.zip} {customer.city}
</span>
</div>
) : (
<span className="text-slate-400 dark:text-slate-600"></span>
)}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
{customer.email || <span className="text-slate-400 dark:text-slate-600"></span>}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
{customer.vat_id || <span className="text-slate-400 dark:text-slate-600"></span>}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-300">
{customer.partner_name || 'Unbekannt'}
</span>
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenDialog(customer)}
className="h-8 w-8 text-indigo-500 hover:text-indigo-400 hover:bg-indigo-500/10"
title="Endkunde bearbeiten"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={loadingAction}
onClick={() => handleDelete(customer.id, customer.company_name)}
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
title="Endkunde löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Dialog: Endkunde anlegen / bearbeiten */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<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>
{editingCustomer ? 'Endkunden bearbeiten' : 'Neuen Endkunden anlegen'}
</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Geben Sie die Firmendaten und optional Kontaktdaten und Adresse des Endkunden an.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="cust-company">Firmenname *</Label>
<Input
id="cust-company"
type="text"
required
placeholder="Endkunde GmbH"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cust-firstname">Vorname</Label>
<Input
id="cust-firstname"
type="text"
placeholder="Max"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cust-lastname">Nachname</Label>
<Input
id="cust-lastname"
type="text"
placeholder="Mustermann"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cust-street">Straße & Hausnummer</Label>
<Input
id="cust-street"
type="text"
placeholder="Beispielstraße 12"
value={street}
onChange={(e) => setStreet(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2 col-span-1">
<Label htmlFor="cust-zip">PLZ</Label>
<Input
id="cust-zip"
type="text"
placeholder="12345"
value={zip}
onChange={(e) => setZip(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="cust-city">Ort</Label>
<Input
id="cust-city"
type="text"
placeholder="Musterort"
value={city}
onChange={(e) => setCity(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cust-vat">USt-ID (VAT-ID)</Label>
<Input
id="cust-vat"
type="text"
placeholder="DE123456789"
value={vatId}
onChange={(e) => setVatId(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cust-partner">Zugeordnetes Unternehmen</Label>
<Input
id="cust-partner"
type="text"
disabled
value={company ? company.name : ''}
className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 cursor-not-allowed"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cust-email">E-Mail</Label>
<Input
id="cust-email"
type="email"
placeholder="kunde@beispiel.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={loadingAction}>
Abbrechen
</Button>
<Button type="submit" disabled={loadingAction} className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold">
{loadingAction ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{editingCustomer ? 'Speichern' : 'Anlegen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,335 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getCompanies, createCompany, updateCompany, deleteCompany } from '@/lib/actions/companies'
export default function CompaniesPage() {
const [companies, setCompanies] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [isOpen, setIsOpen] = useState(false)
const [editingCompany, setEditingCompany] = useState<any>(null)
const [name, setName] = useState('')
const [street, setStreet] = useState('')
const [zip, setZip] = useState('')
const [city, setCity] = useState('')
const [email, setEmail] = useState('')
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const loadCompanies = async () => {
setLoading(true)
try {
const data = await getCompanies()
setCompanies(data)
} catch (err) {
console.error('Fehler beim Laden der Partner:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadCompanies()
}, [])
const handleOpenDialog = (company: any = null) => {
setEditingCompany(company)
setName(company ? company.name || '' : '')
setStreet(company ? company.street || '' : '')
setZip(company ? company.zip || '' : '')
setCity(company ? company.city || '' : '')
setEmail(company ? company.email || '' : '')
setCreateError(null)
setIsOpen(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setCreating(true)
setCreateError(null)
try {
const payload = {
name: name.trim(),
street: street.trim(),
zip: zip.trim(),
city: city.trim(),
email: email.trim(),
}
if (editingCompany) {
await updateCompany(editingCompany.id, payload)
} else {
await createCompany(payload)
}
setIsOpen(false)
loadCompanies()
} catch (err: any) {
setCreateError(err.message || 'Fehler beim Speichern.')
} finally {
setCreating(false)
}
}
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())
const addressMatch = `${c.street || ''} ${c.zip || ''} ${c.city || ''}`.toLowerCase().includes(searchQuery.toLowerCase())
const emailMatch = c.email?.toLowerCase().includes(searchQuery.toLowerCase())
return nameMatch || addressMatch || emailMatch
})
return (
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Building2 className="w-8 h-8 text-blue-500" />
Firmenverwaltung
</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
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>
{/* 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">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
placeholder="Firmen suchen (Name, Adresse, E-Mail)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
/>
</div>
{loading ? (
<div className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
<span className="ml-3 text-slate-400 text-sm">Lade Firmen...</span>
</div>
) : filteredCompanies.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-xl text-slate-500">
<Building2 className="w-10 h-10 mx-auto mb-3 opacity-30" />
Keine Firmen gefunden.
</div>
) : (
<table className="w-full text-left">
<thead>
<tr className="border-b border-slate-200 dark:border-white/10 text-slate-400 text-xs font-semibold uppercase">
<th className="pb-3">Firmenname</th>
<th className="pb-3">Lieferadresse</th>
<th className="pb-3">E-Mail</th>
<th className="pb-3">Erstellt am</th>
<th className="pb-3 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
{filteredCompanies.map((company) => (
<tr key={company.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
<td className="py-4 font-semibold text-slate-900 dark:text-white">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-blue-500 shrink-0" />
{company.name}
</div>
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span>
{company.street ? `${company.street}, ${company.zip} ${company.city}` : ''}
</span>
</div>
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
{company.email ? (
<div className="flex items-center gap-2">
<Mail className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span>{company.email}</span>
</div>
) : ''}
</td>
<td className="py-4 text-sm text-slate-500 dark:text-slate-400">
{company.created_at ? new Date(company.created_at).toLocaleDateString('de-DE') : ''}
</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
asChild
className="h-8 w-8 text-indigo-500 hover:text-indigo-400 hover:bg-indigo-500/10"
title="Endkunden dieser Firma verwalten"
>
<Link href={`/admin/companies/${company.id}/customers`}>
<Users className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenDialog(company)}
className="h-8 w-8 text-blue-500 hover:text-blue-400 hover:bg-blue-500/10"
title="Firma bearbeiten"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={deletingId === company.id}
onClick={() => handleDelete(company.id, company.name)}
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
title="Firma löschen"
>
{deletingId === company.id
? <Loader2 className="h-4 w-4 animate-spin" />
: <Trash2 className="h-4 w-4" />}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Dialog: Firma anlegen / bearbeiten */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<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>
{editingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'}
</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Geben Sie die Firmendetails und die vollständige Lieferadresse ein. Alle Felder sind Pflichtfelder.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="company-name">Firmenname</Label>
<Input
id="company-name"
type="text"
required
placeholder="Muster GmbH"
value={name}
onChange={(e) => setName(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="company-street">Straße & Hausnummer</Label>
<Input
id="company-street"
type="text"
required
placeholder="Musterstraße 123"
value={street}
onChange={(e) => setStreet(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2 col-span-1">
<Label htmlFor="company-zip">PLZ</Label>
<Input
id="company-zip"
type="text"
required
placeholder="12345"
value={zip}
onChange={(e) => setZip(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="company-city">Ort</Label>
<Input
id="company-city"
type="text"
required
placeholder="Musterstadt"
value={city}
onChange={(e) => setCity(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="company-email">E-Mail für Lieferadresse</Label>
<Input
id="company-email"
type="email"
required
placeholder="lieferung@musterfirma.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
/>
</div>
{createError && (
<p className="text-sm text-red-600 dark:text-red-400">{createError}</p>
)}
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={creating}>
Abbrechen
</Button>
<Button type="submit" disabled={creating} className="bg-blue-600 hover:bg-blue-700 text-white font-semibold">
{creating ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{editingCompany ? 'Speichern' : 'Anlegen'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,237 @@
/* Admin Settings with Database Import / Export functionality */
'use client';
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function AdminSettings() {
const [demoActive, setDemoActive] = useState(true);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [statusMsg, setStatusMsg] = useState('');
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
async function checkAccess() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
} else {
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
setLoading(false);
}
}
checkAccess();
}, [router]);
const toggleDemo = (checked: boolean) => {
setDemoActive(checked);
localStorage.setItem('demo_banner_disabled', (!checked).toString());
window.dispatchEvent(new Event('storage_demo_changed'));
};
const handleExport = async () => {
setExporting(true);
setStatusMsg('Export läuft...');
setStatusType('info');
try {
const res = await fetch('/api/admin/db-backup');
if (!res.ok) throw new Error('Export fehlgeschlagen');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
setStatusMsg('Export erfolgreich abgeschlossen.');
setStatusType('success');
} catch (err: any) {
console.error(err);
setStatusMsg(`Fehler beim Export: ${err.message}`);
setStatusType('error');
} finally {
setExporting(false);
}
};
const handleImport = async () => {
if (!selectedFile) return;
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
return;
}
setImporting(true);
setStatusMsg('Import läuft...');
setStatusType('info');
try {
const formData = new FormData();
formData.append('file', selectedFile);
const res = await fetch('/api/admin/db-backup', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
setStatusMsg('Datenbank erfolgreich importiert!');
setStatusType('success');
setSelectedFile(null);
} catch (err: any) {
console.error(err);
setStatusMsg(`Fehler beim Import: ${err.message}`);
setStatusType('error');
} finally {
setImporting(false);
}
};
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
Verwalten Sie globale Shopeinstellungen.
</p>
</div>
{/* Allgemeine Einstellungen */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-4">
<h2 className="text-lg font-bold flex items-center gap-2">
Allgemeine Einstellungen
</h2>
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-lg border border-slate-100 dark:border-white/5">
<div>
<p className="font-medium text-sm text-slate-900 dark:text-white">Demo-Warnmeldungen</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Schaltet gelbe Banner ein oder aus.</p>
</div>
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
</div>
</div>
{/* Datenimport & -export */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-6">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<Database className="w-5 h-5 text-primary" />
Datenimport & -export
</h2>
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
Sichern Sie Ihre gesamte Instanz oder stellen Sie Daten wieder her.
</p>
</div>
{statusMsg && (
<div className={`p-4 rounded-lg text-sm border ${
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
{statusMsg}
</div>
)}
<div className="grid md:grid-cols-2 gap-6">
{/* Export Card */}
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
<div className="space-y-2">
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
<Download className="w-4 h-4 text-primary" />
Daten exportieren
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
</p>
</div>
<Button
onClick={handleExport}
disabled={exporting || importing}
className="w-full bg-primary hover:bg-primary/90 text-white"
>
{exporting ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
) : (
<><Download className="w-4 h-4 mr-2" /> ZIP-Backup herunterladen</>
)}
</Button>
</div>
{/* Import Card */}
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
<div className="space-y-2">
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
<Upload className="w-4 h-4 text-amber-500" />
Daten importieren
</h3>
<div className="flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 p-2.5 rounded-lg text-amber-600 dark:text-amber-400 text-[11px] leading-snug">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
<strong>Warnung:</strong> Der Import überschreibt alle Daten dieser Instanz unwiderruflich!
</span>
</div>
</div>
<div className="space-y-3">
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
<input
type="file"
accept=".zip"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={exporting || importing}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="text-center text-xs text-slate-500 dark:text-slate-400">
{selectedFile ? (
<span className="text-primary font-semibold">{selectedFile.name}</span>
) : (
"ZIP-Datei auswählen oder hierher ziehen"
)}
</div>
</div>
<Button
onClick={handleImport}
disabled={!selectedFile || exporting || importing}
variant="secondary"
className="w-full border border-slate-200 dark:border-white/10"
>
{importing ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Import läuft...</>
) : (
<><Upload className="w-4 h-4 mr-2" /> ZIP-Backup einspielen</>
)}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,37 @@
import Link from 'next/link'
import { LayoutDashboard, Package, Settings, Users, LogOut, LayoutGrid, ShoppingCart } 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 function AdminLayout({
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
const userRole = userData?.role
if (!userData || (userRole !== 'admin' && userRole !== 'verwaltung')) {
redirect('/')
}
const isVerwaltung = userRole === 'verwaltung'
return (
<div className="flex min-h-screen bg-[#020617] text-white">
<div className="flex min-h-screen bg-slate-50 dark:bg-[#020617] text-slate-900 dark:text-white">
{/* Sidebar */}
<aside className="w-64 border-r border-white/5 bg-black/20 backdrop-blur-xl flex flex-col">
<aside className="w-64 border-r border-slate-200 dark:border-white/5 bg-white dark:bg-black/20 backdrop-blur-xl flex flex-col">
<div className="p-6">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
@@ -20,37 +42,68 @@ export default function AdminLayout({
</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-400 hover:text-white 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-400 hover:text-white 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-400 hover:text-white 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-400 hover:text-white 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-400 hover:text-white hover:bg-white/5 transition-all">
</AdminNavLink>
<AdminNavLink href="/admin/users">
<Users className="w-5 h-5" />
Benutzer
</Link>
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Settings className="w-5 h-5" />
Einstellungen
</Link>
</AdminNavLink>
<AdminNavLink href="/admin/companies">
<Building2 className="w-5 h-5" />
Firmen
</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>
<AdminNavLink href="/admin/einstellungen">
<Settings className="w-5 h-5" />
Allgemein
</AdminNavLink>
<AdminNavLink href="/admin/settings">
<Wrench className="w-5 h-5" />
SMTP
</AdminNavLink>
<AdminNavLink href="/admin/tools">
<Database className="w-5 h-5" />
DB-Tools
</AdminNavLink>
</>
)}
</nav>
<div className="p-4 border-t border-white/5">
<button className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<LogOut className="w-5 h-5" />
Abmelden
</button>
<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">
<span className="font-bold text-sm tracking-tighter text-slate-800 dark:text-white">CASPOS Store</span>
<DemoWrapper>
<span className="text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</DemoWrapper>
</div>
</div>
</aside>

View File

@@ -1,20 +1,14 @@
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { FileText, Download, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { createAdminClient } from '@/lib/supabase/admin'
import { OrdersTable } from '@/components/admin/orders-table'
import { Suspense } from 'react'
export default async function AdminOrdersPage() {
return (
<div className="space-y-6">
<div className="p-8 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight text-white">Bestellungen & Rechnungen</h1>
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
<h1 className="text-3xl font-bold tracking-tight text-gradient">Bestellungen</h1>
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte Annfragesbestätigungen zu.</p>
</div>
</div>
@@ -26,83 +20,16 @@ export default async function AdminOrdersPage() {
}
async function OrdersData() {
const supabase = await createClient()
const adminDb = createAdminClient()
const { data: orders, error } = await supabase
const { data: orders, error } = await adminDb
.from('orders')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return <div>Fehler beim Laden der Bestellungen: {error.message}</div>
return <div className="text-red-500">Fehler beim Laden der Bestellungen: {error.message}</div>
}
return (
<Card className="glass-dark border-white/10">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-transparent">
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
<TableHead className="text-slate-200">Datum</TableHead>
<TableHead className="text-slate-200">Kunde</TableHead>
<TableHead className="text-slate-200">Betrag</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-right text-slate-200">Rechnung</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
Noch keine Bestellungen vorhanden.
</TableCell>
</TableRow>
) : (
orders?.map((order) => (
<TableRow key={order.id} className="border-white/5 hover:bg-white/5">
<TableCell className="font-mono text-xs text-primary font-semibold">
{order.order_number ?? order.id.slice(0, 8) + '...'}
</TableCell>
<TableCell className="text-slate-300">
{new Date(order.created_at).toLocaleDateString('de-DE')}
</TableCell>
<TableCell>
<div className="font-medium text-white">{order.customer_data?.company_name || 'Privatkunde'}</div>
<div className="text-xs text-slate-400">{order.customer_data?.first_name} {order.customer_data?.last_name}</div>
</TableCell>
<TableCell className="font-semibold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</TableCell>
<TableCell>
<Badge variant={order.status === 'completed' ? 'default' : 'secondary'} className="capitalize">
{order.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{order.pdf_url ? (
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20">
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-4 h-4 mr-2" /> Ansehen
</a>
</Button>
<Button variant="secondary" size="sm" asChild className="h-8">
<a href={order.pdf_url} download>
<Download className="w-4 h-4" />
</a>
</Button>
</div>
) : (
<span className="text-xs text-muted-foreground italic">Kein PDF</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
return <OrdersTable initialOrders={orders || []} />
}

View File

@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
import { AdminRecentOrders } from '@/components/admin/recent-orders'
@@ -6,18 +7,29 @@ import { AdminRecentOrders } from '@/components/admin/recent-orders'
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
async function getKpis() {
const supabase = await createClient()
const adminDb = createAdminClient()
const [
{ data: orders },
{ count: userCount },
{ count: productCount },
] = await Promise.all([
supabase.from('orders').select('total_price, status'),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
adminDb.from('orders').select('total_price, status, created_at'),
adminDb.from('profiles').select('*', { count: 'exact', head: true }),
supabase.from('products').select('*', { count: 'exact', head: true }),
])
const totalRevenue = (orders ?? []).reduce((sum, o) => sum + Number(o.total_price), 0)
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()
const completedOrdersThisMonth = (orders ?? []).filter(o => {
if (o.status !== 'completed') return false
const orderDate = new Date(o.created_at)
return orderDate.getFullYear() === currentYear && orderDate.getMonth() === currentMonth
})
const totalRevenue = completedOrdersThisMonth.reduce((sum, o) => sum + Number(o.total_price), 0)
const orderCount = (orders ?? []).length
return { totalRevenue, orderCount, userCount: userCount ?? 0, productCount: productCount ?? 0 }
@@ -34,14 +46,14 @@ export default async function AdminDashboard() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Umsatz gesamt</CardTitle>
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
<TrendingUp className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
</div>
<p className="text-xs text-slate-400">Aus allen Bestellungen</p>
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
</CardContent>
</Card>

View File

@@ -39,7 +39,8 @@ async function ProductDataWrapper() {
async function CreateProductAction() {
const categories = await getCategories()
const products = await getProducts()
return (
<CreateProductDialog categories={categories} />
<CreateProductDialog categories={categories} products={products} />
)
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Loader2 } from 'lucide-react';
interface Settings {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
}
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const router = useRouter();
// Load current settings and verify access
useEffect(() => {
async function checkAccessAndLoad() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
return;
}
fetch('/api/admin/smtp-settings')
.then((res) => res.json())
.then((data) => {
if (data.settings) setSettings(data.settings as Settings);
else setMessage('Failed to load settings');
})
.catch(() => setMessage('Failed to load settings'))
.finally(() => setLoading(false));
}
checkAccessAndLoad();
}, [router]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setSettings((prev) =>
prev && {
...prev,
[name]: type === 'checkbox' ? checked : name === 'port' ? Number(value) : value,
}
);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setMessage('');
const res = await fetch('/api/admin/smtp-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
const data = await res.json();
if (res.ok) setMessage('Settings saved');
else setMessage(data.error || 'Error saving settings');
};
const handleTestEmail = async () => {
setMessage('');
const testPayload = {
to: settings?.user || '', // send to the configured user address
subject: 'Test Email from Webshop Admin',
text: 'This is a test email to verify SMTP configuration.',
};
const res = await fetch('/api/admin/send-test-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testPayload),
});
const data = await res.json();
if (res.ok) setMessage('Test email sent successfully');
else setMessage(data.error || 'Failed to send test email');
};
if (loading) return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
return (
<div className="max-w-2xl mx-auto p-8 bg-black/30 backdrop-blur-xl rounded-lg text-white">
<h1 className="text-2xl font-bold mb-6">SMTPEinstellungen</h1>
{message && <p className="mb-4 text-yellow-300">{message}</p>}
<form onSubmit={handleSave} className="space-y-4">
<label className="flex flex-col">
Host
<input
name="host"
type="text"
required
value={settings?.host ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<label className="flex flex-col">
Port
<input
name="port"
type="number"
required
value={settings?.port ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<label className="inline-flex items-center space-x-2">
<input
name="secure"
type="checkbox"
checked={settings?.secure ?? false}
onChange={handleChange}
className="rounded"
/>
<span>SSL / TLS (secure)</span>
</label>
<label className="flex flex-col">
Benutzer (SMTPUser)
<input
name="user"
type="email"
required
value={settings?.user ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<label className="flex flex-col">
Passwort
<input
name="pass"
type="password"
required
value={settings?.pass ?? ''}
onChange={handleChange}
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
/>
</label>
<div className="flex space-x-4 mt-4">
<button
type="submit"
className="px-4 py-2 bg-primary rounded hover:bg-primary/80 transition"
>
Speichern
</button>
<button
type="button"
onClick={handleTestEmail}
className="px-4 py-2 bg-green-600 rounded hover:bg-green-500 transition"
>
TestMail senden
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import { useEffect, useState } from "react";
import {
getDatabaseIntegrity,
repairDatabaseSchema,
optimizeDatabaseIndices
} from "@/lib/actions/admin";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, RefreshCw, Wrench, Database, AlertTriangle, CheckCircle2, XCircle, Terminal, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
interface TableStatus {
exists: boolean;
count: number;
}
interface IntegrityData {
tables: Record<string, TableStatus>;
errors: {
foreign_keys: number;
};
}
export default function AdminToolsPage() {
const [integrity, setIntegrity] = useState<IntegrityData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [activeAction, setActiveAction] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString();
setLogs((prev) => [...prev, `[${timestamp}] ${message}`]);
};
const loadStatus = async (quiet = false) => {
if (!quiet) setIsLoading(true);
addLog("Starte Integritätsscan der Datenbank...");
const res = await getDatabaseIntegrity();
if (res.success && res.data) {
setIntegrity(res.data as IntegrityData);
addLog("Integritätsscan erfolgreich abgeschlossen.");
} else {
addLog(`FEHLER beim Scannen: ${res.error}`);
}
if (!quiet) setIsLoading(false);
};
const handleRepair = async () => {
setIsLoading(true);
setActiveAction("repair");
addLog("Starte automatische Schemareparatur...");
const res = await repairDatabaseSchema();
if (res.success && res.data) {
const repaired = (res.data as any).repaired || [];
if (repaired.length > 0) {
addLog(`Erfolgreich reparierte Tabellen: ${repaired.join(", ")}`);
} else {
addLog("Keine fehlenden Tabellen gefunden. Schema intakt.");
}
await loadStatus(true);
} else {
addLog(`FEHLER bei Schemareparatur: ${res.error}`);
}
setIsLoading(false);
setActiveAction(null);
};
const handleOptimize = async () => {
setIsLoading(true);
setActiveAction("optimize");
addLog("Starte Reindexierung des Schemas public...");
const res = await optimizeDatabaseIndices();
if (res.success) {
addLog("Reindexierung erfolgreich abgeschlossen. Suchindizes neu aufgebaut.");
} else {
addLog(`FEHLER bei Reindexierung: ${res.error}`);
}
setIsLoading(false);
setActiveAction(null);
};
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');
return;
}
setLoading(false);
loadStatus();
}
checkAccess();
}, [router]);
if (loading) {
return <div className="min-h-screen bg-[#020617] text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12 relative overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full -z-10 opacity-30" />
{/* Loading Overlay */}
{isLoading && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4">
<Loader2 className="w-12 h-12 text-primary animate-spin" />
<p className="text-slate-200 text-lg font-medium">
{activeAction === "repair"
? "Schema wird repariert..."
: activeAction === "optimize"
? "Datenbank wird indexiert..."
: "Verbindung zur Datenbank wird aufgebaut..."}
</p>
</div>
)}
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
</Button>
</Link>
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Database className="w-8 h-8 text-primary" />
Admin Datenbank-Cockpit
</h1>
<p className="text-slate-400 text-sm mt-1">
Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes.
</p>
</div>
</div>
{/* Action Controls */}
<div className="grid md:grid-cols-3 gap-6">
<Card className="glass-dark border-white/10 flex flex-col justify-between">
<CardHeader>
<CardTitle className="text-white text-lg flex items-center gap-2">
<RefreshCw className="w-5 h-5 text-blue-400" />
Integritätsscan
</CardTitle>
<CardDescription className="text-slate-400">
Prüft die physische Existenz der Tabellen und zählt deren Zeilenanzahl.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Button
onClick={() => loadStatus()}
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
Status aktualisieren
</Button>
</CardContent>
</Card>
<Card className="glass-dark border-white/10 flex flex-col justify-between">
<CardHeader>
<CardTitle className="text-white text-lg flex items-center gap-2">
<Wrench className="w-5 h-5 text-emerald-400" />
Schema reparieren
</CardTitle>
<CardDescription className="text-slate-400">
Erstellt fehlende Kerntabellen automatisch neu und konfiguriert die RLS-Rechte.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Button
onClick={handleRepair}
disabled={isLoading}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
>
Schema reparieren
</Button>
</CardContent>
</Card>
<Card className="glass-dark border-white/10 flex flex-col justify-between">
<CardHeader>
<CardTitle className="text-white text-lg flex items-center gap-2">
<Database className="w-5 h-5 text-amber-400" />
Index optimieren
</CardTitle>
<CardDescription className="text-slate-400">
Baut korrupte Suchindizes im Schema public mittels REINDEX neu auf.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Button
onClick={handleOptimize}
disabled={isLoading}
className="w-full bg-amber-600 hover:bg-amber-700 text-white"
>
Index optimieren
</Button>
</CardContent>
</Card>
</div>
{/* Tabellenstatus */}
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-white text-xl">Tabellen-Status & Diagnose</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid sm:grid-cols-2 md:grid-cols-4 gap-4">
{integrity?.tables ? (
Object.entries(integrity.tables).map(([name, status]) => (
<div
key={name}
className="p-4 rounded-xl border bg-white/5 border-white/10 flex flex-col justify-between gap-2"
>
<span className="font-semibold text-slate-300 text-sm capitalize">
{name.replace("_", " ")}
</span>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Zeilen: {status.count}</span>
{status.exists ? (
<Badge className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 gap-1">
<CheckCircle2 className="w-3.5 h-3.5" /> OK
</Badge>
) : (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
<XCircle className="w-3.5 h-3.5" /> Fehlt
</Badge>
)}
</div>
</div>
))
) : (
<div className="col-span-4 py-8 text-center text-slate-500 text-sm">
Lade Diagnosedaten...
</div>
)}
</div>
{/* Foreign Key Errors */}
{integrity && integrity.errors.foreign_keys > 0 && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex gap-3 items-center">
<AlertTriangle className="w-6 h-6 text-red-400 shrink-0" />
<div>
<p className="font-bold text-red-200">Fremdschlüssel-Fehler gefunden!</p>
<p className="text-red-300/80 text-sm">
Es wurden {integrity.errors.foreign_keys} verwaiste Lizenzen ohne zugeordneten Endkunden in der Tabelle `licenses` detektiert.
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Console / Output logs */}
<Card className="glass-dark border-white/10">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white text-lg flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
Diagnose-Konsole
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setLogs([])}
className="text-slate-400 hover:text-white text-xs"
>
Konsole leeren
</Button>
</CardHeader>
<CardContent className="pt-0">
<div className="font-mono text-xs bg-black/50 p-4 rounded-xl border border-white/5 h-64 overflow-y-auto space-y-1.5 scrollbar-thin">
{logs.length === 0 ? (
<span className="text-slate-600 italic">Konsole bereit. Führen Sie einen Scan aus.</span>
) : (
logs.map((log, index) => (
<div key={index} className={log.includes("FEHLER") ? "text-red-400" : log.includes("Erfolgreich") ? "text-emerald-400" : "text-slate-300"}>
{log}
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,10 +1,15 @@
import { getUsers } from '@/lib/actions/users'
import { getCompanies } from '@/lib/actions/companies'
import { UserList } from '@/components/admin/user-list'
import { UserDialog } from '@/components/admin/user-dialog'
import { Users } from 'lucide-react'
import { Suspense } from 'react'
export const dynamic = 'force-dynamic'
export default async function AdminUsersPage() {
const companies = await getCompanies()
return (
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
@@ -18,7 +23,7 @@ export default async function AdminUsersPage() {
</p>
</div>
<div className="flex items-center space-x-2">
<UserDialog />
<UserDialog companies={companies} />
</div>
</div>
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
@@ -30,12 +35,16 @@ export default async function AdminUsersPage() {
async function UserDataWrapper() {
try {
const users = await getUsers()
return <UserList initialUsers={users} />
} catch (error) {
const [users, companies] = await Promise.all([getUsers(), getCompanies()])
return <UserList initialUsers={users} companies={companies} />
} catch (error: any) {
console.error('[UserDataWrapper] Error loading users:', error)
return (
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center">
Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center space-y-2">
<p>Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.</p>
<p className="text-xs font-mono text-destructive/80">
Details: {error instanceof Error ? error.message : JSON.stringify(error)}
</p>
</div>
)
}

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,182 @@
import { NextResponse } from 'next/server';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import AdmZip from 'adm-zip';
import { Client } from 'pg';
function getPgClient() {
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:54322/postgres';
return new Client({
connectionString,
connectionTimeoutMillis: 10000,
});
}
async function verifyAdmin() {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabase = createServerClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Ignored
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
}
);
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
return { errorResponse: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) };
}
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
if (!user || user.role !== 'admin') {
return { errorResponse: NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) };
}
return { success: true };
}
export async function GET() {
const { errorResponse } = await verifyAdmin();
if (errorResponse) return errorResponse;
const client = getPgClient();
await client.connect();
try {
const tables = [
'categories',
'products',
'product_modules',
'companies',
'end_customers',
'orders',
'profiles',
'users',
'settings'
];
const backupData: Record<string, any[]> = {};
for (const table of tables) {
const res = await client.query(`SELECT * FROM public.${table}`);
backupData[table] = res.rows;
}
const zip = new AdmZip();
const dataStr = JSON.stringify(backupData, null, 2);
zip.addFile('db_backup.json', Buffer.from(dataStr, 'utf8'));
const buffer = zip.toBuffer();
return new Response(buffer, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename=db_backup.zip',
},
});
} catch (err: any) {
console.error('Backup export failed:', err);
return NextResponse.json({ error: err.message }, { status: 500 });
} finally {
await client.end();
}
}
export async function POST(request: Request) {
const { errorResponse } = await verifyAdmin();
if (errorResponse) return errorResponse;
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const zip = new AdmZip(Buffer.from(arrayBuffer));
const zipEntries = zip.getEntries();
const backupEntry = zipEntries.find(entry => entry.entryName === 'db_backup.json');
if (!backupEntry) {
return NextResponse.json({ error: 'Invalid backup file structure' }, { status: 400 });
}
const dataStr = backupEntry.getData().toString('utf8');
const backupData = JSON.parse(dataStr);
const client = getPgClient();
await client.connect();
try {
await client.query('BEGIN');
// Disable constraints
await client.query("SET session_replication_role = 'replica'");
const tables = [
'categories',
'products',
'product_modules',
'companies',
'end_customers',
'orders',
'profiles',
'users',
'settings'
];
// Delete existing rows in all tables
for (const table of tables) {
await client.query(`DELETE FROM public.${table}`);
}
// Insert backup data
for (const table of tables) {
const rows = backupData[table];
if (!rows || rows.length === 0) continue;
const columns = Object.keys(rows[0]);
const columnNamesStr = columns.map(c => `"${c}"`).join(', ');
for (const row of rows) {
const values = columns.map(col => row[col]);
const placeholders = columns.map((_, idx) => `$${idx + 1}`).join(', ');
await client.query(
`INSERT INTO public.${table} (${columnNamesStr}) VALUES (${placeholders})`,
values
);
}
}
// Re-enable constraints
await client.query("SET session_replication_role = 'origin'");
await client.query('COMMIT');
return NextResponse.json({ message: 'Database imported successfully.' });
} catch (err: any) {
await client.query('ROLLBACK');
console.error('Backup import failed:', err);
return NextResponse.json({ error: err.message }, { status: 500 });
} finally {
await client.end();
}
} catch (err: any) {
console.error('Backup import request processing failed:', err);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { InvoicePDF } from "@/components/invoice-pdf";
import { renderToBuffer } from "@react-pdf/renderer";
import React from "react";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const inline = request.nextUrl.searchParams.get("inline") === "true";
// 1. Auth check
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { data: dbUser } = await supabase
.from("users")
.select("role, company_id")
.eq("id", user.id)
.single();
if (!dbUser) {
return new NextResponse("Unauthorized", { status: 401 });
}
const adminDb = createAdminClient();
// 2. Get the order from database
const { data: order, error } = await adminDb
.from("orders")
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number, company_id, user_id")
.eq("id", id)
.single();
if (error || !order) {
return new NextResponse("Order not found", { status: 404 });
}
// 3. Authorization: admin, owner, or company member
const isAdmin = dbUser.role === "admin";
const isOwner = order.user_id === user.id;
const isCompanyMember = dbUser.company_id && order.company_id === dbUser.company_id;
if (!isAdmin && !isOwner && !isCompanyMember) {
return new NextResponse("Forbidden", { status: 403 });
}
const storageFileName = `ab_${id}.pdf`;
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
// 4. Try to download from storage if pdf_url is set
if (order.pdf_url) {
try {
const { data: fileData, error: downloadError } = await adminDb
.storage
.from("invoices")
.download(storageFileName);
if (!downloadError && fileData) {
fileBuffer = await fileData.arrayBuffer();
}
} catch (storageErr) {
console.warn("Could not retrieve file from storage, will regenerate:", storageErr);
}
}
// 5. If file not found or pdf_url not set, generate dynamically
if (!fileBuffer) {
try {
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
dateStr: formattedDate,
customer: order.customer_data,
orderData: order.order_data,
totalPrice: Number(order.total_price),
})
);
// Upload/Upsert to storage bucket
const { error: uploadError } = await adminDb.storage
.from("invoices")
.upload(storageFileName, Buffer.from(buffer), {
contentType: "application/pdf",
upsert: true,
});
if (uploadError) {
console.error("Failed to upload regenerated PDF:", uploadError);
} else {
await adminDb.from("orders").update({ pdf_url: storageFileName }).eq("id", id);
}
fileBuffer = buffer;
} catch (genErr: any) {
console.error("PDF generation failed inside API:", genErr);
return new NextResponse("Error generating PDF: " + genErr.message, { status: 500 });
}
}
// 6. Return as PDF response
const downloadFileName = `Anfragebestaetigung_${order.order_number || id.slice(0, 8)}.pdf`;
const headers = new Headers();
headers.append("Content-Type", "application/pdf");
headers.append("Cache-Control", "private, no-cache");
if (inline) {
headers.append("Content-Disposition", `inline; filename="${downloadFileName}"`);
} else {
headers.append("Content-Disposition", `attachment; filename="${downloadFileName}"`);
}
return new NextResponse(fileBuffer as any, {
status: 200,
headers,
});
} catch (err: any) {
console.error("Download endpoint failed:", err);
return new NextResponse(err.message || "Internal Server Error", { status: 500 });
}
}

View File

@@ -1,11 +1,12 @@
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { NextResponse } from 'next/server'
/**
* GET /api/admin/recent-orders
* Gibt die letzten 10 Bestellungen zurück.
* Gibt die letzten 10 Bestellungen und Umsatzdaten der letzten 6 Monate zurück.
* Wird vom Admin-Dashboard alle 30 Sekunden gepollt.
* Zugriff nur für authentifizierte User (Supabase Session).
* Zugriff nur für authentifizierte User mit Admin-Rolle (bypasses RLS).
*/
export async function GET() {
const supabase = await createClient()
@@ -16,7 +17,21 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: orders, error } = await supabase
// Admin-Rolle prüfen
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (!userData || userData.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const adminDb = createAdminClient()
// 1. Letzte 10 Bestellungen laden
const { data: orders, error } = await adminDb
.from('orders')
.select('id, order_number, created_at, total_price, status, customer_data')
.order('created_at', { ascending: false })
@@ -26,5 +41,47 @@ export async function GET() {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ orders })
// 2. Umsatzdaten der letzten 6 Monate für das Chart ermitteln
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
sixMonthsAgo.setDate(1)
sixMonthsAgo.setHours(0, 0, 0, 0)
const { data: chartOrders, error: chartError } = await adminDb
.from('orders')
.select('total_price, created_at')
.neq('status', 'cancelled')
.gte('created_at', sixMonthsAgo.toISOString())
let chartData: { name: string; value: number }[] = []
if (!chartError && chartOrders) {
const monthlyData: Record<string, number> = {}
const now = new Date()
// Die letzten 6 Monate initialisieren
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const key = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' })
monthlyData[key] = 0
}
// Werte aufsummieren
chartOrders.forEach(o => {
const date = new Date(o.created_at)
const key = date.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' })
if (key in monthlyData) {
monthlyData[key] += Number(o.total_price)
}
})
// In Array umwandeln
chartData = Object.entries(monthlyData).map(([name, value]) => ({
name,
value: Math.round(value * 100) / 100
}))
} else if (chartError) {
console.error('Chart data fetch error:', chartError)
}
return NextResponse.json({ orders, chartData })
}

View File

@@ -0,0 +1,52 @@
// API route to send a test email protected by Supabase admin check
import { NextResponse } from 'next/server';
import { sendMail } from '@/utils/mail';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function POST(request: Request) {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabase = createServerClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
}
);
// Verify admin rights expecting a user with role "admin" in the "users" table
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { to, subject, text, html } = await request.json();
try {
const info = await sendMail({ to, subject, text, html });
return NextResponse.json({ message: 'Email sent', info });
} catch (err) {
console.error('Mail error', err);
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
}
}

View File

@@ -0,0 +1,96 @@
// API route for getting and updating SMTP settings (protected by admin check)
import { NextResponse } from 'next/server';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function GET() {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabase = createServerClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
}
);
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const { data, error } = await supabase.from('settings').select('*').maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({
settings: data || { host: '', port: 587, secure: false, user: '', pass: '' }
});
}
export async function POST(request: Request) {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabase = createServerClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
}
);
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
const payload = await request.json(); // expect {host, port, secure, user, pass}
const { error } = await supabase
.from('settings')
.upsert({ id: 'smtp', ...payload });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ message: 'SMTP settings saved' });
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { InvoicePDF } from "@/components/invoice-pdf";
import { renderToBuffer } from "@react-pdf/renderer";
import React from "react";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const inline = request.nextUrl.searchParams.get("inline") === "true";
// 1. Auth check — any authenticated user
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { data: dbUser } = await supabase
.from("users")
.select("role, company_id")
.eq("id", user.id)
.single();
if (!dbUser) {
return new NextResponse("Unauthorized", { status: 401 });
}
const adminDb = createAdminClient();
// 2. Get the order from database
const { data: order, error } = await adminDb
.from("orders")
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number, company_id, user_id")
.eq("id", id)
.single();
if (error || !order) {
return new NextResponse("Order not found", { status: 404 });
}
// 3. Authorization: admin, owner, or company member
const isAdmin = dbUser.role === "admin";
const isOwner = order.user_id === user.id;
const isCompanyMember = dbUser.company_id && order.company_id === dbUser.company_id;
if (!isAdmin && !isOwner && !isCompanyMember) {
return new NextResponse("Forbidden", { status: 403 });
}
const storageFileName = `ab_${id}.pdf`;
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
// 4. Try to download from storage if pdf_url is set
if (order.pdf_url) {
try {
const { data: fileData, error: downloadError } = await adminDb
.storage
.from("invoices")
.download(storageFileName);
if (!downloadError && fileData) {
fileBuffer = await fileData.arrayBuffer();
}
} catch (storageErr) {
console.warn("Could not retrieve file from storage, will regenerate:", storageErr);
}
}
// 5. If file not found or pdf_url not set, generate dynamically
if (!fileBuffer) {
try {
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
dateStr: formattedDate,
customer: order.customer_data,
orderData: order.order_data,
totalPrice: Number(order.total_price),
})
);
// Upload/Upsert to storage bucket
const { error: uploadError } = await adminDb.storage
.from("invoices")
.upload(storageFileName, Buffer.from(buffer), {
contentType: "application/pdf",
upsert: true,
});
if (uploadError) {
console.error("Failed to upload regenerated PDF:", uploadError);
} else {
await adminDb.from("orders").update({ pdf_url: storageFileName }).eq("id", id);
}
fileBuffer = buffer;
} catch (genErr: any) {
console.error("PDF generation failed inside API:", genErr);
return new NextResponse("Error generating PDF: " + genErr.message, { status: 500 });
}
}
// 6. Return as PDF response
const downloadFileName = `Anfragebestaetigung_${order.order_number || id.slice(0, 8)}.pdf`;
const headers = new Headers();
headers.append("Content-Type", "application/pdf");
headers.append("Cache-Control", "private, no-cache");
if (inline) {
headers.append("Content-Disposition", `inline; filename="${downloadFileName}"`);
} else {
headers.append("Content-Disposition", `attachment; filename="${downloadFileName}"`);
}
return new NextResponse(fileBuffer as any, {
status: 200,
headers,
});
} catch (err: any) {
console.error("Download endpoint failed:", err);
return new NextResponse(err.message || "Internal Server Error", { status: 500 });
}
}

View File

@@ -0,0 +1,165 @@
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';
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();
// 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
);
orderItemsList.push(...itemSnapshot.items);
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;
// Trigger PDF generation & storage upload
try {
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);
order.pdf_url = fileName;
}
} catch (pdfErr) {
console.error('PDF error for order ' + order.id, pdfErr);
}
// Trigger E-Mail notifications
if (user.email) {
try {
await sendMail({
to: user.email,
subject: `Bestätigung Ihrer CASPOS-Anfrage ${order.order_number}`,
text: `Vielen Dank für Ihre Anfrage ${order.order_number}.`,
html: `<p>Vielen Dank für Ihre Anfrage <strong>${order.order_number}</strong>.</p>`
});
} 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

@@ -1,6 +1,12 @@
import { LoginForm } from "@/components/login-form";
import { isSetupNeeded } from "@/lib/actions/setup";
import { redirect } from "next/navigation";
export default async function Page() {
if (await isSetupNeeded()) {
redirect("/setup");
}
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">

View File

@@ -1,11 +1,5 @@
import { SignUpForm } from "@/components/sign-up-form";
import { redirect } from "next/navigation";
export default function Page() {
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<SignUpForm />
</div>
</div>
);
export default function SignUpPage() {
redirect("/auth/login");
}

View File

@@ -0,0 +1,76 @@
'use client';
import { use, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ShieldCheck, Loader2 } from "lucide-react";
interface PageProps {
searchParams: Promise<{
token_hash?: string;
type?: string;
next?: string;
}>;
}
export default function VerifyLinkPage({ searchParams }: PageProps) {
const params = use(searchParams);
const [isSubmitting, setIsSubmitting] = useState(false);
const tokenHash = params.token_hash || "";
const type = params.type || "";
const next = params.next || "/";
const handleSubmit = () => {
setIsSubmitting(true);
};
return (
<div className="relative flex min-h-svh w-full items-center justify-center p-6 md:p-10 overflow-hidden bg-slate-950">
{/* Background gradients for rich aesthetics */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#0f172a_1px,transparent_1px),linear-gradient(to_bottom,#0f172a_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-60" />
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[500px] h-[500px] bg-blue-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[400px] bg-indigo-500/10 rounded-full blur-[100px] pointer-events-none" />
<div className="relative w-full max-w-md z-10">
<Card className="border-slate-800/80 bg-slate-900/60 backdrop-blur-xl shadow-2xl transition-all duration-300 hover:border-slate-700/80">
<CardHeader className="flex flex-col items-center justify-center text-center space-y-4 pt-8">
<div className="p-3.5 bg-blue-500/10 rounded-full text-blue-400 border border-blue-500/20 shadow-[0_0_20px_rgba(59,130,246,0.15)] animate-pulse">
<ShieldCheck className="w-8 h-8" />
</div>
<div className="space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight text-slate-100">
Sicherheit prüfen
</CardTitle>
<p className="text-sm text-slate-400 max-w-xs mx-auto">
Bitte bestätigen Sie den Vorgang mit einem Klick auf den Button, um fortzufahren.
</p>
</div>
</CardHeader>
<CardContent className="pb-8">
<form action="/auth/confirm" method="GET" onSubmit={handleSubmit} className="w-full">
<input type="hidden" name="token_hash" value={tokenHash} />
<input type="hidden" name="type" value={type} />
<input type="hidden" name="next" value={next} />
<Button
type="submit"
disabled={isSubmitting || !tokenHash}
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-medium shadow-[0_0_20px_rgba(59,130,246,0.25)] transition-all duration-200 h-11 active:scale-[0.98] disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird weitergeleitet...
</>
) : (
"Bestätigen & Fortfahren"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import Link from 'next/link'
import { ArrowLeft, Lock } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function Datenschutz() {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 py-16 px-4">
<div className="container max-w-3xl mx-auto space-y-8">
<Link href="/">
<Button variant="ghost" size="sm" className="gap-2 text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4" /> Zurück zur Startseite
</Button>
</Link>
<div className="space-y-4">
<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">
Datenschutzerklärung
</h1>
<p className="text-slate-400">Informationen über die Verarbeitung Ihrer personenbezogenen Daten.</p>
</div>
<hr className="border-slate-900" />
<div className="space-y-6 text-slate-300">
<section className="space-y-2">
<h2 className="text-xl font-bold text-white">1. Datenschutz auf einen Blick</h2>
<div className="space-y-4">
<div>
<h3 className="text-md font-semibold text-slate-200">Allgemeiner Hinweis</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung. Wir weisen darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.
</p>
</div>
<div>
<h3 className="text-md font-semibold text-slate-200">Wer ist verantwortlich für die Datenerfassung auf dieser Website?</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber CASPOS GmbH. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
</p>
</div>
<div>
<h3 className="text-md font-semibold text-slate-200">Welche Rechte haben Sie bezüglich Ihrer Daten?</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Sie haben jederzeit das Recht unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die Berichtigung, Sperrung oder Löschung dieser Daten zu verlangen. Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit unter der im Impressum angegebenen Adresse an uns wenden.
</p>
</div>
</div>
</section>
<section className="space-y-2">
<h2 className="text-xl font-bold text-white">2. Erhebung, Verarbeitung und Nutzung personenbezogener Daten</h2>
<p className="text-sm text-slate-400 leading-relaxed">
Sie können unsere Webseiten grundsätzlich besuchen, ohne dass wir personenbezogene Daten von Ihnen erheben. Personenbezogene Daten werden nur erhoben, wenn Sie uns diese von sich aus zur Durchführung eines Vertrages, oder im Rahmen der Kontaktaufnahme mitteilen. Diese Daten werden ohne Ihre ausdrückliche Einwilligung jeweils allein zur Vertragsabwicklung und Bearbeitung Ihrer Anfragen genutzt.
</p>
</section>
<section className="space-y-2">
<h2 className="text-xl font-bold text-white">3. Analyse-Tools und Tools von Drittanbietern</h2>
<p className="text-sm text-slate-400 leading-relaxed">
Beim Besuch unserer Website werden keine Analyse-Tools eingesetzt.
</p>
</section>
<section className="space-y-2">
<h2 className="text-xl font-bold text-white">4. Cookies</h2>
<p className="text-sm text-slate-400 leading-relaxed">
CASPOS GmbH verwendet keine so genannte Cookies.
</p>
</section>
<section className="space-y-2">
<h2 className="text-xl font-bold text-white">5. Server-Log-Dateien</h2>
<p className="text-sm text-slate-400 leading-relaxed">
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
</p>
<ul className="list-disc pl-5 text-sm text-slate-400 space-y-1">
<li>Browsertyp und Browserversion</li>
<li>verwendetes Betriebssystem</li>
<li>Referrer URL</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse</li>
</ul>
<p className="text-sm text-slate-400 leading-relaxed mt-2">
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.
</p>
<p className="text-sm text-slate-400 leading-relaxed">
Grundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. f DSGVO, der die Verarbeitung von Daten zur Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen gestattet.
</p>
</section>
<section className="space-y-2">
<h2 className="text-xl font-bold text-white">6. Weitergabe personenbezogener Daten</h2>
<p className="text-sm text-slate-400 leading-relaxed">
Personenbezogenen Daten werden von uns im Rahmen der Vertragsabwicklung an das mit der Lieferung beauftragte Transportunternehmen weitergegeben, soweit dies zur Lieferung der Ware erforderlich ist.
</p>
<p className="text-sm text-slate-400 leading-relaxed">
Bei Vorlage einer richterlichen Anordnung darf CASPOS GmbH Ihre persönlichen Daten an die Strafverfolgungsbehörden weitergeben, ohne Sie davon zu informieren. Ebenso behält sich CASPOS GmbH vor, Ihre persönlichen Daten für gerichtliche Auseinandersetzungen einzusetzen, falls dies notwendig ist.
</p>
</section>
<section className="space-y-2">
<h2 className="text-xl font-bold text-white">7. Kontaktformular</h2>
<p className="text-sm text-slate-400 leading-relaxed">
Wenn Sie uns per Kontaktformular Anfragen zukommen lassen, werden Ihre Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert.
</p>
<p className="text-sm text-slate-400 leading-relaxed">
Die Verarbeitung der in das Kontaktformular eingegebenen Daten erfolgt somit ausschließlich auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können diese Einwilligung jederzeit widerrufen. Dazu reicht eine formlose Mitteilung per E-Mail an uns. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitungsvorgänge bleibt vom Widerruf unberührt.
</p>
<p className="text-sm text-slate-400 leading-relaxed">
Die von Ihnen im Kontaktformular eingegebenen Daten verbleiben bei uns, bis Sie uns zur Löschung auffordern, Ihre Einwilligung zur Speicherung widerrufen oder der Zweck für die Datenspeicherung entfällt (z.B. nach abgeschlossener Bearbeitung Ihrer Anfrage). Zwingende gesetzliche Bestimmungen insbesondere Aufbewahrungsfristen bleiben unberührt.
</p>
</section>
</div>
</div>
</div>
)
}

View File

@@ -4,48 +4,82 @@
@layer base {
:root {
--background: 220 33% 98%;
--foreground: 220 40% 2%;
/* Light Mode: Maximierter Kontrast (Reines Weißer Hintergrund + Tiefschwarzer Text) */
--background: 0 0% 100%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 220 40% 2%;
--card-foreground: 224 71% 4%;
--popover: 0 0% 100%;
--popover-foreground: 220 40% 2%;
--primary: 220 90% 56%;
--popover-foreground: 224 71% 4%;
/* Primary: Cyan/Blau lesbarer gemacht */
--primary: 199 100% 35%;
--primary-foreground: 0 0% 100%;
/* Muted & Accent: Deutlich dunklerer Text für Barrierefreiheit */
--secondary: 220 14% 90%;
--secondary-foreground: 220 40% 2%;
--muted: 220 14% 90%;
--muted-foreground: 220 14% 40%;
--secondary-foreground: 224 71% 4%;
--muted: 220 14% 92%;
--muted-foreground: 220 20% 28%;
/* Vorher 40% Helligkeit, jetzt 28% (viel dunkler) */
--accent: 220 14% 90%;
--accent-foreground: 220 40% 2%;
--destructive: 0 84% 60%;
--accent-foreground: 224 71% 4%;
--destructive: 0 84% 50%;
--destructive-foreground: 0 0% 100%;
--border: 220 14% 85%;
--input: 220 14% 85%;
--ring: 220 90% 56%;
--border: 220 14% 75%;
/* Grenzen sichtbarer gemacht */
--input: 220 14% 75%;
--ring: 199 100% 35%;
--radius: 0.75rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--popover: 224 71% 4%;
--popover-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--muted: 222.2 47.4% 11.2%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 222.2 47.4% 11.2%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--border: 222.2 47.4% 11.2%;
--input: 222.2 47.4% 11.2%;
--ring: 212.7 26.8% 83.9%;
/* Dark Mode: Reines Tiefschwarz erhöht den Kontrast zu weißem Text drastisch */
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
/* Muted Text: Helligkeit massiv erhöht, damit Beschreibungen lesbar sind */
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 75%;
/* Vorher 56.9% Helligkeit, jetzt 75% (viel heller) */
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 72% 51%;
/* Helleres Rot für dunklen Hintergrund */
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 25%;
/* Grenzen im Dunkeln besser sichtbar */
--input: 240 3.7% 25%;
--ring: 240 4.9% 83.9%;
}
}
/* Automatischer High-Contrast-Modus für Betriebssystem-Vorgaben */
@media (prefers-contrast: more) {
:root {
--foreground: 0 0% 0%;
--muted-foreground: 0 0% 0%;
--border: 0 0% 0%;
}
.dark {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--muted-foreground: 0 0% 100%;
--border: 0 0% 100%;
}
}
@@ -53,6 +87,7 @@
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "rlig" 1, "calt" 1;
@@ -60,13 +95,17 @@
}
@layer utilities {
/* Glassmorphism Kontraste geschärft (höhere Deckkraft) */
.glass {
@apply bg-white/10 backdrop-blur-md border border-white/20;
@apply bg-white/20 backdrop-blur-md border border-white/40;
}
.glass-dark {
@apply bg-black/20 backdrop-blur-md border border-white/10;
@apply bg-black/40 backdrop-blur-md border border-white/20;
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
@apply bg-clip-text text-transparent bg-gradient-to-r from-sky-500 to-blue-600 dark:from-primary dark:to-blue-400;
}
}

111
shop/app/impressum/page.tsx Normal file
View File

@@ -0,0 +1,111 @@
import Link from 'next/link'
import { ArrowLeft, Shield } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function Impressum() {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 py-16 px-4">
<div className="container max-w-3xl mx-auto space-y-8">
<Link href="/">
<Button variant="ghost" size="sm" className="gap-2 text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4" /> Zurück zur Startseite
</Button>
</Link>
<div className="space-y-4">
<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">
Impressum
</h1>
<p className="text-slate-400">Angaben gemäß § 5 TMG.</p>
</div>
<hr className="border-slate-900" />
<div className="space-y-6 text-slate-300">
<section className="space-y-2">
<h2 className="text-lg font-bold text-white">CASPOS Computerabrechnungssysteme GmbH</h2>
<p className="text-sm text-slate-400">
Alte Bundesstraße 16<br />
D-76846 Hauenstein
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-bold text-white">Vertreten durch die Geschäftsführer:</h2>
<p className="text-sm text-slate-400">Helmut Dietz, Tamara Korn, Julian Winter</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-bold text-white">Kontakt:</h2>
<p className="text-sm text-slate-400">
Telefon: +49 (0) 6392 8598 440<br />
E-Mail: info@caspos.de
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-bold text-white">Registereintrag:</h2>
<p className="text-sm text-slate-400">
Handelsregister: HRB 32806, Amtsgericht Zweibrücken
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-bold text-white">Umsatzsteuer-ID & Steuernummer:</h2>
<p className="text-sm text-slate-400">
Umsatzsteueridentifikationsnummer: DE 158286059<br />
Steuernummer: 35/671/01361
</p>
</section>
<hr className="border-slate-900" />
<section className="space-y-2 text-slate-400 text-xs">
<p className="font-semibold text-slate-300">
© 2022 CASPOS Computerabrechnungssysteme GmbH Alle Rechte vorbehalten
</p>
<p className="leading-relaxed">
Sämtliches Material auf dieser Website wie Texte, Bilder, Illustrationen, Audio- und Videoclips, um nur ein paar Beispiele zu nennen (allgemein das Material genannt), sind urheberrechtlich geschützt und dürfen für keinen anderen Zweck verwendet werden. Abdruck, kopieren, verteilen, Fremdnutzung, Posting und andere unerlaubte Nutzung des Materials ist hiermit ausdrücklich verboten.
</p>
</section>
<hr className="border-slate-900" />
<div className="space-y-6">
<h2 className="text-xl font-bold text-white">Haftungsausschluss</h2>
<section className="space-y-2">
<h3 className="text-md font-semibold text-slate-200">1. Inhalt der Onlineanfrage</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Der Autor übernimmt keinerlei Gewähr für die Aktualität, Korrektheit, Vollständigkeit oder Qualität der bereitgestellten Informationen. Haftungsansprüche gegen den Autor, welche sich auf Schäden materieller oder ideeller Art beziehen, die durch die Nutzung oder Nichtnutzung der dargebotenen Informationen bzw. durch die Nutzung fehlerhafter und unvollständiger Informationen verursacht wurden, sind grundsätzlich ausgeschlossen, sofern seitens des Autors kein nachweislich vorsätzliches oder grob fahrlässiges Verschulden vorliegt.
</p>
<p className="text-sm text-slate-400 leading-relaxed">
Alle Anfragen sind freibleibend und unverbindlich. Der Autor behält es sich ausdrücklich vor, Teile der Seiten oder die gesamte Anfrage ohne gesonderte Ankündigung zu verändern, zu ergänzen, zu löschen oder die Veröffentlichung zeitweise oder endgültig einzustellen.
</p>
</section>
<section className="space-y-2">
<h3 className="text-md font-semibold text-slate-200">2. Verweise und Links</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Bei direkten oder indirekten Verweisen auf fremde Webseiten (Hyperlinks), die außerhalb des Verantwortungsbereiches des Autors liegen, würde eine Haftungsverpflichtung ausschließlich in dem Fall in Kraft treten, in dem der Autor von den Inhalten Kenntnis hat und es ihm technisch möglich und zumutbar wäre, die Nutzung im Falle rechtswidriger Inhalte zu verhindern. Der Autor erklärt hiermit ausdrücklich, dass zum Zeitpunkt der Linksetzung keine illegalen Inhalte auf den zu verlinkenden Seiten erkennbar waren.
</p>
<p className="text-sm text-slate-400 leading-relaxed">
Auf die aktuelle und zukünftige Gestaltung, die Inhalte oder die Urheberschaft der gelinkten/verknüpften Seiten hat der Autor keinerlei Einfluss. Deshalb distanziert er sich hiermit ausdrücklich von allen Inhalten aller gelinkten /verknüpften Seiten, die nach der Linksetzung verändert wurden. Diese Feststellung gilt für alle innerhalb des eigenen Internetanfrage gesetzten Links und Verweise sowie für Fremdeinträge in vom Autor eingerichteten Gästebüchern, Diskussionsforen und Mailinglisten. Für illegale, fehlerhafte oder unvollständige Inhalte und insbesondere für Schäden, die aus der Nutzung oder Nichtnutzung solcherart dargebotener Informationen entstehen, haftet allein der Anbieter der Seite, auf welche verwiesen wurde, nicht derjenige, der über Links auf die jeweilige Veröffentlichung lediglich verweist.
</p>
</section>
<section className="space-y-2">
<h3 className="text-md font-semibold text-slate-200">4. Rechtswirksamkeit dieses Haftungsausschlusses</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Dieser Haftungsausschluss ist als Teil der Onlineanfrage zu betrachten, von dem aus auf diese Seite verwiesen wurde. Sofern Teile oder einzelne Formulierungen dieses Textes der geltenden Rechtslage nicht, nicht mehr oder nicht vollständig entsprechen sollten, bleiben die übrigen Teile des Dokumentes in ihrem Inhalt und ihrer Gültigkeit davon unberührt.
</p>
</section>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { InactivityTracker } from "@/components/inactivity-tracker";
import { HeaderWrapper } from "@/components/HeaderWrapper";
import { Navbar } from "@/components/Navbar";
import "./globals.css";
const defaultUrl = process.env.VERCEL_URL
@@ -10,7 +13,7 @@ const defaultUrl = process.env.VERCEL_URL
export const metadata: Metadata = {
metadataBase: new URL(defaultUrl),
title: "CASPOS-Shop | Die Kasse",
description: "Konfigurieren und bestellen Sie Ihre maßgeschneiderte Business-Software in Minuten.",
description: "Konfigurieren Sie Ihre maßgeschneiderte Business-Software in Minuten und fragen Sie sie an.",
};
const geistSans = Geist({
@@ -29,11 +32,15 @@ export default function RootLayout({
<body className={`${geistSans.className} antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
defaultTheme="dark"
forcedTheme="dark"
enableSystem={false}
disableTransitionOnChange
>
{children}
<InactivityTracker />
<HeaderWrapper navbar={<Navbar />}>
{children}
</HeaderWrapper>
</ThemeProvider>
</body>
</html>

5
shop/app/login/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function LoginPage() {
redirect("/auth/login");
}

View File

@@ -21,7 +21,8 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
const [anonymizing, setAnonymizing] = useState(false)
const [showGdprConfirm, setShowGdprConfirm] = useState(false)
const [form, setForm] = useState({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '',
bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '',
})
useEffect(() => {
@@ -37,6 +38,11 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
street: c.street ?? '',
zip: c.zip ?? '',
city: c.city ?? '',
email: c.email ?? '',
bank_iban: c.bank_iban ?? '',
bank_bic: c.bank_bic ?? '',
bank_name: c.bank_name ?? '',
bank_owner: c.bank_owner ?? '',
})
setLoading(false)
})
@@ -121,9 +127,14 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
{ label: 'USt-IdNr.', key: 'vat_id', span: false },
{ label: 'Vorname', key: 'first_name', span: false },
{ label: 'Nachname', key: 'last_name', span: false },
{ label: 'E-Mail', key: 'email', span: true },
{ label: 'Straße & Hausnummer', key: 'street', span: true },
{ label: 'PLZ', key: 'zip', span: false },
{ label: 'Ort', key: 'city', span: false },
{ label: 'Kontoinhaber', key: 'bank_owner', span: false },
{ label: 'IBAN', key: 'bank_iban', span: false },
{ label: 'BIC', key: 'bank_bic', span: false },
{ label: 'Bankname', key: 'bank_name', span: false },
] as const).map(({ label, key, span }) => (
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-slate-300 text-sm">{label}</Label>

View File

@@ -14,7 +14,8 @@ export default function NewCustomerPage() {
const router = useRouter()
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '',
bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '',
})
const handleSave = async () => {
@@ -58,9 +59,14 @@ export default function NewCustomerPage() {
{ 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: 'E-Mail', key: 'email', span: true, placeholder: 'kunde@beispiel.de' },
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: 'Musterstraße 1' },
{ label: 'PLZ', key: 'zip', span: false, placeholder: '12345' },
{ label: 'Ort', key: 'city', span: false, placeholder: 'Musterstadt' },
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
{ label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' },
] as const).map(({ label, key, span, placeholder }) => (
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-slate-300 text-sm">{label}</Label>

View File

@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic';
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { getEndCustomers } from '@/lib/actions/end-customers'
import type { EndCustomer } from '@/lib/types'
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
@@ -13,10 +14,20 @@ export default async function MyCustomersPage() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
const customers = await getEndCustomers()
let customers: EndCustomer[] = []
let fetchError: string | null = null
try {
customers = await getEndCustomers()
} catch (err: any) {
console.error("Error loading end customers:", err)
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Endkunden."
}
// Anzahl Bestellungen pro Endkunde
const { data: orderCounts } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null);
const { data: orderCounts, error: orderError } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null);
if (orderError) {
console.error("Error loading order counts:", orderError)
}
const countMap: Record<string, number> = {}
;(orderCounts ?? []).forEach(o => {
@@ -53,6 +64,12 @@ export default async function MyCustomersPage() {
</Link>
</div>
{fetchError && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-200 text-sm">
{fetchError}
</div>
)}
{/* Keine Kunden */}
{customers.length === 0 && (
<Card className="glass-dark border-white/10">

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() {
@@ -25,11 +29,22 @@ export default async function MyOrdersPage() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
const { data: orders, error } = await supabase
.from('orders')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
// Get user's company_id and fetch all company orders
const { data: dbUser } = await supabase
.from('users')
.select('company_id')
.eq('id', user.id)
.single()
let query = supabase.from('orders').select('*')
if (dbUser?.company_id) {
query = query.eq('company_id', dbUser.company_id)
} else {
query = query.eq('user_id', user.id)
}
const { data: orders, error } = await query.order('created_at', { ascending: false })
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
@@ -44,10 +59,10 @@ export default async function MyOrdersPage() {
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<ShoppingBag className="w-8 h-8 text-primary" />
Meine Bestellungen
Meine Anfragen
</h1>
<p className="text-slate-400 text-sm mt-1">
Alle Ihre Bestellungen auf einen Blick inkl. aktuellem Status.
Alle Ihre Anfragen auf einen Blick inkl. aktuellem Status.
</p>
</div>
</div>
@@ -55,16 +70,16 @@ export default async function MyOrdersPage() {
{/* Fehler */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
Fehler beim Laden der Bestellungen: {error.message}
Fehler beim Laden der Anfragen: {error.message}
</div>
)}
{/* Keine Bestellungen */}
{/* Keine Anfragen */}
{!error && (!orders || orders.length === 0) && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
<Package className="w-12 h-12 text-slate-600" />
<p className="text-slate-400 text-lg">Sie haben noch keine Bestellungen aufgegeben.</p>
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
<Link href="/order">
<Button>Jetzt konfigurieren</Button>
</Link>
@@ -72,7 +87,7 @@ export default async function MyOrdersPage() {
</Card>
)}
{/* Bestellliste */}
{/* Anfragenliste */}
<div className="space-y-4">
{(orders ?? []).map((order) => {
const o = order as Order
@@ -83,7 +98,7 @@ export default async function MyOrdersPage() {
{/* Kopfzeile */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<p className="text-xs text-slate-500 uppercase tracking-wider">Bestellnummer</p>
<p className="text-xs text-slate-500 uppercase tracking-wider">Anfragenummer</p>
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
</div>
<div className="space-y-1 text-right">
@@ -113,7 +128,7 @@ export default async function MyOrdersPage() {
<div className="flex flex-wrap gap-2">
{items.map((item, i) => (
<span
key={i}
key={item.product_id}
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
>
{item.product_name}
@@ -132,15 +147,22 @@ export default async function MyOrdersPage() {
Details ansehen
</Button>
</Link>
{o.status !== 'completed' && (
<Link href={`/order?id=${o.id}`}>
<Button variant="outline" size="sm" className="border-amber-500/20 hover:bg-amber-500/10 hover:border-amber-500/40 text-amber-400 text-xs">
Bearbeiten
</Button>
</Link>
)}
{o.pdf_url && (
<>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3 h-3 mr-1" /> Rechnung ansehen
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3 h-3 mr-1" /> Anfrage ansehen
</a>
</Button>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
<a href={o.pdf_url} download>
<a href={`/api/orders/${o.id}/download`} download>
<Download className="w-3 h-3 mr-1" /> PDF
</a>
</Button>

View File

@@ -1,11 +1,18 @@
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { getProducts, getCategories } from '@/lib/actions/products'
import { getEndCustomers } from '@/lib/actions/end-customers'
import { OrderWizard } from '@/components/order-wizard'
import { getCompanies } from '@/lib/actions/companies'
import { redirect } from 'next/navigation'
import { Suspense } from 'react'
export default async function OrderPage() {
interface PageProps {
searchParams: Promise<{ id?: string }>
}
export default async function OrderPage({ searchParams }: PageProps) {
const params = await searchParams
return (
<div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30">
<div className="container mx-auto py-10">
@@ -19,31 +26,118 @@ export default async function OrderPage() {
</div>
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
<OrderDataWrapper />
<OrderDataWrapper orderId={params.id} />
</Suspense>
</div>
</div>
)
}
async function OrderDataWrapper() {
async function OrderDataWrapper({ orderId }: { orderId?: string }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/auth/login')
redirect(`/auth/login?next=/order${orderId ? `?id=${orderId}` : ''}`)
}
const products = await getProducts()
const categories = await getCategories()
const endCustomers = await getEndCustomers()
// Fetch profile if exists
const { data: profile } = await supabase
.from('profiles')
.select('*')
// Check user role and company
const { data: userData } = await supabase
.from('users')
.select('role, company_id')
.eq('id', user.id)
.single()
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
const isAdmin = userData?.role === 'admin'
const hasCompany = !!userData?.company_id
if (!isAdmin && !hasCompany) {
return (
<div className="max-w-md mx-auto mt-12 p-6 bg-red-500/10 border border-red-500/20 rounded-2xl text-center space-y-4">
<h2 className="text-xl font-bold text-red-400">Kein Unternehmen zugewiesen</h2>
<p className="text-slate-400 text-sm">
Sie müssen einem Unternehmen zugewiesen sein, um Anfragen erstellen zu können.
Bitte wenden Sie sich an den Administrator.
</p>
</div>
)
}
let initialOrder = null
if (orderId) {
const adminClient = createAdminClient()
const { data: orderData, error } = await adminClient
.from('orders')
.select('*')
.eq('id', orderId)
.single()
if (error || !orderData) {
redirect('/order')
}
const isAdmin = userData?.role === 'admin'
const isOwner = orderData.user_id === user.id
const isCompanyMember = userData?.company_id && orderData.company_id === userData.company_id
if (!isAdmin && !isOwner && !isCompanyMember) {
redirect('/order')
}
if (orderData.status === 'completed' && !isAdmin) {
redirect('/order')
}
initialOrder = orderData
}
const products = await getProducts().catch(() => [])
const categories = await getCategories().catch(() => [])
// Admin sees all end customers across partners
let endCustomers = []
if (userData?.role === 'admin') {
try {
const adminClient = createAdminClient()
const { data } = await adminClient
.from('end_customers')
.select('*')
.order('company_name', { ascending: true })
endCustomers = data || []
} catch (e) {
console.error('Fehler beim Laden aller Endkunden für Admin:', e)
}
} else {
endCustomers = await getEndCustomers().catch(() => [])
}
// Fetch profile safely using maybeSingle
let profile = null
try {
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.maybeSingle()
profile = data
} catch (e) {
console.error('Fehler beim Laden des Profils:', e)
}
// Fetch companies list for admin
let companies: any[] = []
if (userData?.role === 'admin') {
companies = await getCompanies().catch(() => [])
}
return (
<OrderWizard
products={products}
categories={categories}
initialProfile={profile}
initialEndCustomers={endCustomers}
initialOrder={initialOrder}
isAdmin={userData?.role === 'admin'}
companies={companies}
/>
)
}

View File

@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight } from 'lucide-react'
import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight, Calendar } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
@@ -22,23 +22,51 @@ export default async function OrderSuccessPage({
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
// Bestellung laden und prüfen ob sie dem eingeloggten User gehört
// 1. Hole Benutzerrolle und Company
const { data: dbUser } = await supabase
.from('users')
.select('role, company_id')
.eq('id', user.id)
.single()
// 2. Hole Bestellung
const { data: order, error } = await supabase
.from('orders')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error || !order) redirect('/')
// Berechtigung prüfen
const isAdmin = dbUser?.role === 'admin'
const isOwner = order.user_id === user.id
const isCompanyMember = dbUser?.company_id && order.company_id === dbUser.company_id
if (!isAdmin && !isOwner && !isCompanyMember) {
redirect('/')
}
const o = order as Order
const items = o.order_data?.items ?? []
const taxRate = o.order_data?.tax_rate ?? 19
const oneTimeItems = items.filter((item) => item.billing_interval === 'one_time')
const monthlyItems = items.filter((item) => item.billing_interval === 'monthly')
const oneTimeNet = oneTimeItems.reduce((sum, item) => sum + (item.item_total || 0), 0)
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100
const monthlyNet = monthlyItems.reduce((sum, item) => sum + (item.item_total || 0), 0)
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
return (
@@ -50,27 +78,27 @@ export default async function OrderSuccessPage({
<CheckCircle2 className="w-12 h-12 text-green-400" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Bestellung eingegangen!
Anfrage eingegangen!
</h1>
<p className="text-slate-400 text-lg">
Ihre Bestellung wurde erfolgreich gespeichert.
Ihre Anfrage wurde erfolgreich gespeichert.
</p>
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-full px-5 py-2 font-mono text-lg font-bold text-primary">
#{o.order_number}
</div>
</div>
{/* Bestell-Details */}
{/* Anfrage-Details */}
<Card className="glass-dark border-white/10">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white">Bestellübersicht</CardTitle>
<CardTitle className="text-white">Anfrageübersicht</CardTitle>
<Badge
className={
o.status === 'active'
? 'bg-green-500/20 text-green-400 border-green-500/30'
: o.status === 'cancelled'
? 'bg-red-500/20 text-red-400 border-red-500/30'
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
? 'bg-red-500/20 text-red-400 border-red-500/30'
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
}
>
{statusLabel[o.status] ?? o.status}
@@ -79,8 +107,8 @@ export default async function OrderSuccessPage({
<CardContent className="space-y-4">
{/* Produkte */}
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="space-y-1">
{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">
@@ -90,8 +118,8 @@ export default async function OrderSuccessPage({
</span>
</span>
</div>
{item.selected_modules.map((mod, j) => (
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400">
{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>
@@ -103,12 +131,76 @@ export default async function OrderSuccessPage({
<Separator className="bg-white/10" />
{/* Gesamtbetrag */}
<div className="flex justify-between text-lg font-bold">
<span className="text-slate-300">Gesamtbetrag</span>
<span className="text-gradient">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
</span>
</div>
{oneTimeNet > 0 && (
<div className="space-y-1">
{monthlyNet > 0 && <p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmaliger Betrag</p>}
<div className="flex justify-between text-sm text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. {taxRate}% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto Gesamtbetrag:</span>
<span className={monthlyNet > 0 ? "text-white" : "text-gradient"}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
</span>
</div>
</div>
)}
{oneTimeNet > 0 && monthlyNet > 0 && (
<Separator className="bg-white/5 my-2" />
)}
{monthlyNet > 0 && (
<div className="space-y-1">
{oneTimeNet > 0 && <p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlicher Betrag</p>}
<div className="flex justify-between text-sm text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / Monat</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. {taxRate}% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / Monat</span>
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto Gesamtbetrag:</span>
<span className="text-gradient">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / Monat
</span>
</div>
</div>
)}
{o.order_data?.price_multiplier !== null && o.order_data?.price_multiplier !== undefined && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-3 rounded-lg border border-green-500/20 space-y-1">
<p>Update-Rabatt angewendet:</p>
<p className="text-xs font-normal text-slate-300">
{o.order_data.price_multiplier === 0
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
: o.order_data.price_multiplier === 0.15
? "Update (13-14 Monate): 15% vom Listenpreis"
: o.order_data.price_multiplier === 0.30
? "Update (15-25 Monate): 30% vom Listenpreis"
: o.order_data.price_multiplier === 0.45
? "Update (26-37 Monate): 45% vom Listenpreis"
: o.order_data.price_multiplier === 0.60
? "Update (38-49 Monate): 60% vom Listenpreis"
: o.order_data.price_multiplier === 0.90
? "Neukauf (>= 50 Monate): 10% Rabatt"
: `Gebühr: ${o.order_data.price_multiplier * 100}%`}
</p>
{o.order_data.last_license_date && (
<p className="text-xs font-normal text-slate-400 flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-primary" />
Letzte Lizenz vom: {new Date(o.order_data.last_license_date).toLocaleDateString('de-DE')}
</p>
)}
</div>
)}
<Separator className="bg-white/10" />
@@ -117,18 +209,26 @@ export default async function OrderSuccessPage({
<p className="font-semibold text-white">{o.customer_data?.company_name}</p>
<p>{o.customer_data?.first_name} {o.customer_data?.last_name}</p>
<p>{o.customer_data?.address}, {o.customer_data?.zip_code} {o.customer_data?.city}</p>
{o.customer_data?.bank_iban && (
<div className="text-xs text-slate-500 pt-2 border-t border-white/5 mt-2 space-y-0.5">
<p><span className="font-semibold text-slate-400">Kontoinhaber:</span> {o.customer_data.bank_owner}</p>
<p><span className="font-semibold text-slate-400">IBAN:</span> {o.customer_data.bank_iban}</p>
{o.customer_data.bank_bic && <p><span className="font-semibold text-slate-400">BIC:</span> {o.customer_data.bank_bic}</p>}
{o.customer_data.bank_name && <p><span className="font-semibold text-slate-400">Bank:</span> {o.customer_data.bank_name}</p>}
</div>
)}
</div>
{/* PDF-Download */}
{o.pdf_url && (
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-4 h-4 mr-2" /> Rechnung ansehen
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-4 h-4 mr-2" /> Anfragebestätigung ansehen
</a>
</Button>
<Button variant="secondary" size="sm" asChild>
<a href={o.pdf_url} download>
<a href={`/api/orders/${o.id}/download`} download>
<Download className="w-4 h-4 mr-2" /> Herunterladen
</a>
</Button>

View File

@@ -1,108 +1,23 @@
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
import { createClient } from '@/lib/supabase/server'
import { HomeClient } from '@/components/HomeClient'
import { isSetupNeeded } from '@/lib/actions/setup'
import { redirect } from 'next/navigation'
export default function Home() {
return (
<div className="flex flex-col min-h-screen bg-[#020617] text-white">
<div className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-400 font-medium tracking-wide z-50">
Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
</div>
<header className="px-4 lg:px-6 h-16 flex items-center border-b border-white/5 backdrop-blur-md sticky top-0 z-50">
<Link className="flex items-center justify-center gap-2" href="#">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<Rocket className="h-5 w-5 text-white" />
</div>
<span className="font-bold text-xl tracking-tighter">CASPOS Store</span>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</Link>
<nav className="ml-auto flex gap-4 sm:gap-6 items-center">
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
Bestellen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-customers">
Meine Kunden
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-orders">
Meine Bestellungen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
Admin
</Link>
<Button variant="outline" className="border-white/10 hover:bg-white/5">
Login
</Button>
</nav>
</header>
<main className="flex-1">
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-primary/20 blur-[120px] rounded-full -z-10 opacity-30" />
<div className="container mx-auto">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-full bg-amber-500/10 px-3 py-1 text-sm text-amber-400 font-medium border border-amber-500/20 mb-4">
Demo-Umgebung für Testbestellungen
</div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
CASPOS <span className="text-gradient">Software-Bestellung</span>
</h1>
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
Modulare Lösungen, einfaches Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
</p>
</div>
<div className="space-x-4 mt-8">
<Link href="/order">
<Button size="lg" className="h-12 px-8 text-lg font-semibold shadow-lg shadow-primary/20 transition-all hover:scale-105">
Jetzt bestellen <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="/contact">
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
Contact Us
</Button>
</Link>
</div>
</div>
</div>
</section>
export const dynamic = "force-dynamic"
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]">
<div className="container mx-auto px-4 md:px-6">
<div className="grid gap-12 lg:grid-cols-3">
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Cpu className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Handel</h3>
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. Wir können es.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Shield className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Gastronomie</h3>
<p className="text-slate-400">Egal ob mobiles Ordern, einer Küchen-Anzeige oder einen Pick-Up-Bord. Wir können es.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Rocket className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
</div>
</div>
</div>
</section>
</main>
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-white/5 bg-[#020617]">
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Impressum</Link>
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
</nav>
</footer>
</div>
)
export default async function Home() {
if (await isSetupNeeded()) {
redirect('/setup')
}
let user = null
try {
const supabase = await createClient()
const { data } = await supabase.auth.getUser()
user = data.user
} catch (e) {
console.error("Failed to fetch user in page.tsx:", e)
}
return <HomeClient initialUser={user} />
}

View File

@@ -1,7 +1,6 @@
import { DeployButton } from "@/components/deploy-button";
import { EnvVarWarning } from "@/components/env-var-warning";
import { AuthButton } from "@/components/auth-button";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { hasEnvVars } from "@/lib/utils";
import Link from "next/link";
import { Suspense } from "react";
@@ -47,7 +46,6 @@ export default function ProtectedLayout({
Supabase
</a>
</p>
<ThemeSwitcher />
</footer>
</div>
</main>

15
shop/app/setup/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { isSetupNeeded } from '@/lib/actions/setup'
import { redirect } from 'next/navigation'
import { SetupWizard } from '@/components/SetupWizard'
export const dynamic = 'force-dynamic'
export default async function SetupPage() {
const needed = await isSetupNeeded()
if (!needed) {
redirect('/')
}
return <SetupWizard />
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
interface DemoWrapperProps {
children: React.ReactNode;
}
export function DemoWrapper({ children }: DemoWrapperProps) {
const [showDemo, setShowDemo] = useState<boolean>(true);
const [isMounted, setIsMounted] = useState<boolean>(false);
useEffect(() => {
setIsMounted(true);
const checkDemo = () => {
const disabled = localStorage.getItem('demo_banner_disabled') === 'true';
setShowDemo(!disabled);
};
checkDemo();
window.addEventListener('storage_demo_changed', checkDemo);
return () => window.removeEventListener('storage_demo_changed', checkDemo);
}, []);
if (!isMounted) {
return <>{children}</>;
}
if (!showDemo) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,39 @@
"use client";
import { usePathname } from "next/navigation";
import { DemoWrapper } from "./DemoWrapper";
interface HeaderWrapperProps {
navbar: React.ReactNode;
children: React.ReactNode;
}
export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
const pathname = usePathname();
const isAdmin = pathname?.startsWith("/admin");
const isSetup = pathname === "/setup";
if (isAdmin || isSetup) {
return <>{children}</>;
}
return (
<div className="flex flex-col min-h-screen bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white">
{/* Demo Banner */}
<DemoWrapper>
<div
id="demo-banner"
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50"
>
Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
</div>
</DemoWrapper>
{navbar}
<main className="flex-1">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,362 @@
"use client"
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { type User } from '@supabase/supabase-js'
import {
ArrowRight,
CheckCircle2,
MessageSquare,
BookOpen,
Layers,
LifeBuoy,
Users,
ClipboardList,
Zap,
ArrowUpRight,
ShieldCheck,
Sparkles,
Activity
} from 'lucide-react'
interface HomeClientProps {
initialUser: User | null
}
export function HomeClient({ initialUser }: HomeClientProps) {
const [user, setUser] = useState<User | null>(initialUser)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
const supabase = createClient()
// Listen to client-side auth state changes to stay in sync
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
setUser(session?.user ?? null)
})
return () => {
subscription.unsubscribe()
}
}, [])
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15
}
}
}
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
type: "spring" as const,
stiffness: 100,
damping: 15
}
}
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
{/* Background Decorative Animated Blobs */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
<motion.div
animate={{
scale: [1, 1.15, 0.95, 1],
rotate: [0, 90, 180, 270, 360],
x: [0, 40, -30, 0],
y: [0, -20, 50, 0]
}}
transition={{
duration: 20,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
/>
<motion.div
animate={{
scale: [1, 0.9, 1.1, 1],
rotate: [0, -90, -180, -270, -360],
x: [0, -30, 40, 0],
y: [0, 50, -30, 0]
}}
transition={{
duration: 25,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
/>
</div>
{/* Hero Section */}
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
className="container max-w-6xl mx-auto flex flex-col items-center text-center space-y-8"
>
{/* Badge */}
<motion.div
variants={itemVariants}
className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-xs sm:text-sm text-blue-400 font-semibold tracking-wide backdrop-blur-md shadow-[0_0_15px_rgba(59,130,246,0.1)]"
>
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
>
<Zap className="w-3.5 h-3.5 text-blue-400 fill-blue-400/20" />
</motion.div>
CASPOS Lizenz-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"
>
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>
</motion.h1>
<motion.p
variants={itemVariants}
className="mx-auto max-w-[750px] text-slate-400 text-base sm:text-lg md:text-xl font-normal leading-relaxed"
>
Erwerben und verwalten Sie Lizenzen für CASPOS Kassensysteme.
</motion.p>
</div>
{/* Hero CTAs */}
<motion.div
variants={itemVariants}
className="flex flex-col sm:flex-row items-center justify-center gap-4 w-full max-w-md pt-4"
>
<Link href="/order" className="w-full sm:w-auto">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-bold transition-all duration-300 hover:scale-[1.02] bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-[0_0_25px_rgba(37,99,235,0.25)] hover:shadow-[0_0_35px_rgba(37,99,235,0.45)] border-none rounded-xl">
Lizenz anfragen <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer" className="w-full sm:w-auto">
<Button size="lg" variant="outline" className="w-full sm:w-auto h-12 px-8 text-base font-semibold transition-all duration-300 hover:bg-white/5 border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white rounded-xl backdrop-blur-sm">
Fragen? Kontakt
</Button>
</Link>
</motion.div>
{/* Quick Access Actions Bar */}
{mounted && user && (
<motion.div
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">
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"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
<Users className="w-5.5 h-5.5" />
</div>
<div className="text-left">
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Kundenliste</span>
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Kunden & Lizenzen verwalten</span>
</div>
</div>
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
</motion.div>
</Link>
<Link href="/my-orders" className="group">
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
<ClipboardList className="w-5.5 h-5.5" />
</div>
<div className="text-left">
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Bestellungen</span>
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Aufträge & Status einsehen</span>
</div>
</div>
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
</motion.div>
</Link>
</div>
</div>
</motion.div>
)}
</motion.div>
</section>
{/* Grid of modules */}
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50 relative">
<div className="container max-w-6xl mx-auto px-4 relative">
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 border border-indigo-500/20 px-3 py-1 rounded-full">
<Sparkles className="w-3 h-3" />CASPOS Sites
</div>
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
Workspace-Zentrale
</h2>
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
Die zentralen Plattformen für unsere tägliche Zusammenarbeit, Dokumentation und den Support.
</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Card 1 */}
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
<MessageSquare className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Zulip</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Treten Sie unserem Partner-Chat bei! tauschen Sie sich mit anderen CASPOS Partnern aus.
</p>
</div>
</motion.div></a>
{/* Card 2 */}
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
<BookOpen className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Wiki</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Ihr Wissens-Portal für die CASPOS Produktfamilie.
</p>
</div>
</motion.div></a>
{/* Card 3 */}
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
<LifeBuoy className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Support</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Sie haben ein technisches Problem in der CASPOS? Erstellen Sie jetzt ein Ticket!
</p>
</div>
</motion.div></a>
</div>
</div>
</section>
{/* Stepper info */}
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
<div className="container max-w-5xl mx-auto px-4">
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
<Activity className="w-3 h-3" /> Ablauf
</div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
Wie funktioniert die Bereitstellung?
</h2>
</div>
<div className="relative">
{/* Connection line for Desktop */}
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
<div className="grid gap-8 md:grid-cols-3 relative z-10">
{/* Step 1 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
1
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Kunde wählen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Gewünschten Kunden auswählen, der die Lizenz erhalten soll.
</p>
</div>
{/* Step 2 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
2
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Lizenz wählen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Gewünschte Lizenzen für den Kunden auswählen und den Bestellung abschließen.
</p>
</div>
{/* Step 3 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
3
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Live gehen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Nach dem Erhalt der Lizenz Live gehen.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
<nav className="flex gap-6">
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>
</nav>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NavbarClient } from "@/components/NavbarClient";
export async function Navbar() {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
// Falls keine Env-Vars da sind, leeren User an Client übergeben
return <NavbarClient user={null} />;
}
const supabase = createServerClient(
supabaseUrl,
supabaseAnonKey,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Kann in Server Components ignoriert werden, da dort keine Cookies gesetzt werden können
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
},
);
let user = null;
let role = "partner";
try {
const { data } = await supabase.auth.getUser();
user = data.user;
if (user) {
const { data: dbUser } = await supabase
.from("users")
.select("role")
.eq("id", user.id)
.single();
if (dbUser) {
role = dbUser.role;
}
}
} catch (error) {
console.error("Fehler beim Abrufen des Users in Navbar:", error);
}
return <NavbarClient user={user} role={role} />;
}

View File

@@ -0,0 +1,298 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { createBrowserClient } from "@supabase/ssr";
import { type User } from "@supabase/supabase-js";
import { Rocket, Menu, X, User as UserIcon, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { signOut } from "@/lib/actions/auth";
import { resolveSupabaseUrl } from "@/lib/utils";
import { DemoWrapper } from "./DemoWrapper";
interface NavbarClientProps {
user: User | null;
role?: string;
}
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
const router = useRouter();
const [currentUser, setCurrentUser] = useState<User | null>(user);
const [userRole, setUserRole] = useState<string>(role);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [open, setOpen] = useState(false);
const [pathname, setPathname] = useState("/");
useEffect(() => {
setPathname(window.location.pathname);
}, []);
useEffect(() => {
setCurrentUser(user);
setUserRole(role);
}, [user, role]);
// Supabase URL & Anon Key auslesen (für Browser-Client)
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Browser Client inline erstellen
const supabase = createBrowserClient(
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
);
// Auf Auth-Statusänderungen reagieren
useEffect(() => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, session) => {
setCurrentUser(session?.user ?? null);
if (session?.user) {
try {
const { data } = await supabase
.from("users")
.select("role")
.eq("id", session.user.id)
.single();
if (data) {
setUserRole(data.role);
}
} catch {
setUserRole("partner");
}
} else {
setUserRole("partner");
}
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
const handleSignOut = async () => {
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout:", e);
}
try {
await supabase.auth.signOut();
} catch (e) {
console.error("Fehler beim Client-Signout:", e);
}
setCurrentUser(null);
setIsMobileMenuOpen(false);
router.push("/");
router.refresh();
};
return (
<>
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
<Link className="flex items-center justify-center gap-2 group" href="/">
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
<DemoWrapper>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</DemoWrapper>
</Link>
{/* Desktop Navigation */}
<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"
target="_blank"
rel="noopener noreferrer"
>
Zulip
</Link>
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://wiki.caspos.de"
target="_blank"
rel="noopener noreferrer"
>
Wiki
</Link>
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://support.caspos.de"
target="_blank"
rel="noopener noreferrer"
>
Support
</Link>
<div className="h-4 w-px bg-white/10 mx-2" />
{currentUser ? (
<div className="relative">
<Button
variant="outline"
onClick={() => setOpen(!open)}
className="border-white/10 hover:bg-white/5 gap-2 text-white"
>
<UserIcon className="w-4 h-4 text-primary" />
<span className="max-w-[120px] truncate">{currentUser.email}</span>
</Button>
{open && (
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10">
<div className="px-3 py-1.5 text-[11px] truncate border-b text-slate-400 border-slate-100 dark:border-white/5 mb-1">
{currentUser.email}
</div>
{userRole === "admin" ? (
<>
<Link
href="/admin/einstellungen"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
>
Admin
</Link>
</>
) : (
<>
<Link
href="/account-kunden"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
>
Account Einstellungen
</Link>
<Link
href="/my-customers"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
>
Kundenliste
</Link>
<Link
href="/my-orders"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
>
Bestellungen
</Link>
</>
)}
<hr className="my-1 border-slate-100 dark:border-white/5" />
<button
onClick={handleSignOut}
className="w-full text-left px-3 py-2 text-sm font-bold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg"
>
Abmelden
</button>
</div>
)}
</div>
) : (
<Button asChild className="bg-primary hover:bg-primary/95 text-white dark:text-blue-500">
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
</Button>
)}
</nav>
{/* Mobile Hamburger Trigger */}
<button
className="md:hidden p-2 text-slate-300 hover:text-white focus:outline-none"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</header>
{/* Mobile Menu Panel */}
{isMobileMenuOpen && (
<div className="md:hidden fixed inset-x-0 top-16 bg-[#020617] border-b border-white/5 z-40 py-4 px-6 flex flex-col gap-4 animate-in slide-in-from-top duration-200">
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="/order"
onClick={() => setIsMobileMenuOpen(false)}
>
Bestellen
</Link>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="https://caspos.de"
target="_blank"
rel="noopener noreferrer"
onClick={() => setIsMobileMenuOpen(false)}
>
Wiki
</Link>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="https://caspos.de"
target="_blank"
rel="noopener noreferrer"
onClick={() => setIsMobileMenuOpen(false)}
>
Support
</Link>
{currentUser && (
<>
<div className="h-px bg-white/5 my-1" />
<div className="text-xs text-slate-500 px-1">Mein Account ({currentUser.email})</div>
{userRole === "admin" ? (
<>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="/admin/einstellungen"
onClick={() => setIsMobileMenuOpen(false)}
>
Admin
</Link>
</>
) : (
<>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="/my-customers"
onClick={() => setIsMobileMenuOpen(false)}
>
Kundenliste
</Link>
<Link
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
href="/my-orders"
onClick={() => setIsMobileMenuOpen(false)}
>
Bestellungen
</Link>
</>
)}
</>
)}
<div className="h-px bg-white/5 my-1" />
{currentUser ? (
<Button
variant="destructive"
className="w-full flex items-center justify-center gap-2"
onClick={handleSignOut}
>
<LogOut className="w-4 h-4" /> Abmelden
</Button>
) : (
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white dark:text-blue-500" onClick={() => setIsMobileMenuOpen(false)}>
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
</Button>
)}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,405 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createClient } from '@/lib/supabase/client'
import { completeSetup } from '@/lib/actions/setup'
export function SetupWizard() {
const router = useRouter()
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
// Step 2 Form: Admin account
const [adminForm, setAdminForm] = useState({
email: '',
password: '',
confirmPassword: '',
companyName: '',
firstName: '',
lastName: '',
})
// Step 3 Form: SMTP Config
const [smtpForm, setSmtpForm] = useState({
host: '',
port: '587',
secure: false,
user: '',
pass: '',
})
// Validation checks for Admin form
const isAdminFormValid =
adminForm.email.includes('@') &&
adminForm.password.length >= 6 &&
adminForm.password === adminForm.confirmPassword &&
adminForm.companyName.trim().length > 0 &&
adminForm.firstName.trim().length > 0 &&
adminForm.lastName.trim().length > 0
// Validation checks for SMTP form
const isSmtpFormValid =
smtpForm.host.trim().length > 0 &&
!isNaN(Number(smtpForm.port)) &&
smtpForm.user.trim().length > 0
const handleFinishSetup = async () => {
if (!isAdminFormValid || !isSmtpFormValid) return
setLoading(true)
setErrorMsg('')
try {
// 1. Submit details via server action
const res = await completeSetup(
{
email: adminForm.email,
password: adminForm.password,
companyName: adminForm.companyName,
firstName: adminForm.firstName,
lastName: adminForm.lastName,
},
{
host: smtpForm.host,
port: Number(smtpForm.port),
secure: smtpForm.secure,
user: smtpForm.user,
pass: smtpForm.pass,
}
)
if (!res.success) {
setErrorMsg(res.error || 'Fehler beim Setup.')
setLoading(false)
return
}
// 2. Automatischer Login für flüssiges Erlebnis
const supabase = createClient()
const { error: loginError } = await supabase.auth.signInWithPassword({
email: adminForm.email,
password: adminForm.password,
})
if (loginError) {
console.error('Auto login failed:', loginError)
// Redirect anyway since setup is done
}
// 3. Weiterleitung
router.push('/')
router.refresh()
} catch (e: any) {
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
setLoading(false)
}
}
return (
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
{/* Dynamic Background Glow */}
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/10 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-500/10 blur-[120px]" />
<div className="w-full max-w-xl relative z-10">
{/* Step Indicator Header */}
<div className="flex items-center justify-between mb-8 px-2">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight text-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>
</div>
<div className="flex gap-1.5">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
}`}
/>
))}
</div>
</div>
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400 mx-auto shadow-inner">
<Sparkles className="w-8 h-8 animate-pulse" />
</div>
<div className="text-center space-y-2">
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
<p className="text-slate-400 leading-relaxed">
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
</p>
</div>
<div className="p-4 rounded-xl bg-white/5 border border-white/5 text-sm text-slate-400 space-y-2">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Komplett self-hosted & sicher</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Automatische Schema-Updates auf dem neuesten Stand</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>SMTP Mailversand für direkte Anfragebestätigungen</span>
</div>
</div>
<Button
onClick={() => setStep(2)}
className="w-full py-6 text-base font-bold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 shadow-lg shadow-blue-500/15 group"
>
Setup starten
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</motion.div>
)}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-blue-400" />
Admin-Konto anlegen
</h2>
<p className="text-slate-400 text-sm">
Erstellen Sie den ersten Administrator-Benutzer. Dieser erhält vollen Zugriff auf das System.
</p>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Vorname *</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.firstName}
onChange={(e) => setAdminForm({ ...adminForm, firstName: e.target.value })}
placeholder="Vorname"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Nachname *</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.lastName}
onChange={(e) => setAdminForm({ ...adminForm, lastName: e.target.value })}
placeholder="Nachname"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Unternehmen / Partnername *</Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.companyName}
onChange={(e) => setAdminForm({ ...adminForm, companyName: e.target.value })}
placeholder="Name Ihrer Firma"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
type="email"
value={adminForm.email}
onChange={(e) => setAdminForm({ ...adminForm, email: e.target.value })}
placeholder="admin@firma.de"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Passwort * (min. 6 Zeichen)</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
type="password"
value={adminForm.password}
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
placeholder="••••••"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Passwort bestätigen *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
type="password"
value={adminForm.confirmPassword}
onChange={(e) => setAdminForm({ ...adminForm, confirmPassword: e.target.value })}
placeholder="••••••"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
</div>
</div>
<div className="flex gap-3">
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
Zurück
</Button>
<Button
onClick={() => setStep(3)}
disabled={!isAdminFormValid}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zu SMTP
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</motion.div>
)}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Mail className="w-6 h-6 text-blue-400" />
SMTP-Mailserver konfigurieren
</h2>
<p className="text-slate-400 text-sm">
Tragen Sie Ihre Mailserver-Daten ein, um automatisierte E-Mails an Partner und Kunden senden zu können.
</p>
</div>
{errorMsg && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
{errorMsg}
</div>
)}
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Host *</Label>
<Input
value={smtpForm.host}
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
placeholder="smtp.domain.de"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
<Input
value={smtpForm.port}
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
placeholder="587"
className="bg-white/5 border-white/10 text-white"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Benutzername *</Label>
<Input
value={smtpForm.user}
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
placeholder="mail@domain.de"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Passwort</Label>
<Input
type="password"
value={smtpForm.pass}
onChange={(e) => setSmtpForm({ ...smtpForm, pass: e.target.value })}
placeholder="••••••••••••"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="smtp_secure"
checked={smtpForm.secure}
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
Sichere Verbindung (SSL/TLS anstelle STARTTLS)
</Label>
</div>
</div>
<div className="flex gap-3">
<Button
variant="ghost"
onClick={() => setStep(2)}
disabled={loading}
className="text-white hover:bg-white/5"
>
Zurück
</Button>
<Button
onClick={handleFinishSetup}
disabled={!isSmtpFormValid || loading}
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Setup wird abgeschlossen...
</>
) : (
'Einrichtung abschließen'
)}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

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

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
@@ -42,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>
@@ -54,6 +61,7 @@ export function CategoryDialog({
category?: Category
}) {
const [open, setOpen] = useState(false)
const router = useRouter()
const form = useForm<CategoryFormValues>({
resolver: zodResolver(categorySchema) as any,
@@ -63,18 +71,25 @@ 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()
router.refresh()
} catch (error) {
console.error('Error saving category:', error)
}
@@ -96,10 +111,10 @@ export function CategoryDialog({
</Button>
)}
</DialogTrigger>
<DialogContent className="glass-dark border-white/10 text-white shadow-2xl">
<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>{category ? 'Kategorie bearbeiten' : 'Neue Kategorie erstellen'}</DialogTitle>
<DialogDescription className="text-slate-300">
<DialogDescription className="text-slate-500 dark:text-slate-400">
Definieren Sie eine Kategorie für Ihre Produkte.
</DialogDescription>
</DialogHeader>
@@ -113,9 +128,9 @@ export function CategoryDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Name</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Name</FormLabel>
<FormControl>
<Input placeholder="z.B. Software" className="bg-white/5 border-white/10 text-white" {...field} />
<Input placeholder="z.B. Software" 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>
@@ -126,9 +141,9 @@ export function CategoryDialog({
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Beschreibung</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung" className="bg-white/5 border-white/10 text-white" {...field} />
<Input placeholder="Kurze Beschreibung" 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>
@@ -139,16 +154,16 @@ export function CategoryDialog({
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Icon</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Icon</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
<SelectValue placeholder="Icon wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
{icons.map(({ name, Icon }) => (
<SelectItem key={name} value={name} className="text-white hover:bg-white/10">
<SelectItem key={name} value={name}>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{name}
@@ -166,26 +181,26 @@ export function CategoryDialog({
name="sort_order"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Reihenfolge im Bestellvorgang</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Reihenfolge im Bestellvorgang</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="0 = zuerst"
className="bg-white/5 border-white/10 text-white"
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-400">Kleinere Zahl = weiter oben im Bestellformular angezeigt.</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Kleinere Zahl = weiter oben im Bestellformular angezeigt.</p>
</FormItem>
)}
/>
<FormField
<FormField
control={form.control}
name="is_required"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-white/10 bg-white/5 p-4">
<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}
@@ -193,10 +208,10 @@ export function CategoryDialog({
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-white font-semibold cursor-pointer">
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
Pflichtauswahl im Bestellvorgang
</FormLabel>
<p className="text-xs text-slate-400">
<p className="text-xs text-slate-500 dark:text-slate-400">
Wenn aktiv, muss der Kunde aus dieser Kategorie einen Artikel wählen.
Wenn deaktiviert, ist die Kategorie optional.
</p>
@@ -204,10 +219,156 @@ 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">
<Button type="submit" className="w-full bg-primary hover:bg-primary/90">
<Button type="submit" className="w-full bg-primary hover:bg-primary/90 text-white">
{category ? 'Aktualisieren' : 'Erstellen'}
</Button>
</DialogFooter>

View File

@@ -14,12 +14,16 @@ import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package, ArrowUpDown } from
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { deleteCategory } from '@/lib/actions/products'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { CategoryDialog } from './category-dialog'
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
const [categories, setCategories] = useState(initialCategories)
useEffect(() => {
setCategories(initialCategories)
}, [initialCategories])
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diese Kategorie wirklich löschen? Alle zugehörigen Produkte werden nicht mehr dieser Kategorie zugeordnet sein.')) {
await deleteCategory(id)
@@ -37,55 +41,62 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
}
return (
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
<CardHeader>
<CardTitle>Kategorienübersicht</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-slate-500 dark:text-slate-400">
Verwalten Sie die Hauptkategorien für Ihre Produkte.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-white font-bold w-10">Icon</TableHead>
<TableHead className="text-white font-bold">Name</TableHead>
<TableHead className="text-white font-bold">Beschreibung</TableHead>
<TableHead className="text-white font-bold w-28">
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
<TableHead className="text-slate-700 dark:text-white font-bold w-10">Icon</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Beschreibung</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold w-28">
<span className="flex items-center gap-1">
<ArrowUpDown className="w-3 h-3" /> Reihenfolge
</span>
</TableHead>
<TableHead className="text-white font-bold w-24">Typ</TableHead>
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold w-24">Typ</TableHead>
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map((category) => (
<TableRow key={category.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableRow key={category.id} className="border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
<TableCell>
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
{getIcon(category.icon)}
</div>
</TableCell>
<TableCell className="font-medium text-white">{category.name}</TableCell>
<TableCell className="text-slate-300">{category.description}</TableCell>
<TableCell className="font-medium text-slate-900 dark:text-white">{category.name}</TableCell>
<TableCell className="text-slate-700 dark:text-slate-300">{category.description}</TableCell>
<TableCell>
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-primary/20 text-primary text-sm font-bold">
{category.sort_order}
</span>
</TableCell>
<TableCell>
{category.is_required ? (
<Badge className="bg-green-500/20 text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
) : (
<Badge variant="outline" className="border-white/20 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">
<CategoryDialog category={category}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
<Edit2 className="h-4 w-4" />
</Button>
</CategoryDialog>
@@ -103,7 +114,7 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
))}
{categories.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-10 text-slate-400">
<TableCell colSpan={6} className="text-center py-10 text-slate-500 dark:text-slate-400">
Keine Kategorien gefunden.
</TableCell>
</TableRow>

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
@@ -39,12 +40,14 @@ import {
} from "@/components/ui/select"
const moduleSchema = z.object({
id: z.string().optional(),
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
description: z.string().optional(),
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
is_required: z.boolean().default(false),
requirements: z.string().optional().default(''), // Will be converted to array
exclusions: z.string().optional().default(''), // Will be converted to array
has_quantity: z.boolean().default(false),
requirements: z.array(z.string()).default([]),
exclusions: z.array(z.string()).default([]),
})
const productSchema = z.object({
@@ -53,13 +56,30 @@ const productSchema = z.object({
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
show_in_kauf: z.boolean().default(true),
show_in_abo: z.boolean().default(true),
requirements: z.array(z.string()).default([]),
exclusions: z.array(z.string()).default([]),
modules: z.array(moduleSchema).default([]),
})
type ProductFormValues = z.infer<typeof productSchema>
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
export function CreateProductDialog({
children,
categories,
product,
products = []
}: {
children?: React.ReactNode
categories: Category[]
product?: Product
products?: Product[]
}) {
const [open, setOpen] = useState(false)
const router = useRouter()
const otherProducts = products.filter(p => p.id !== product?.id)
const form = useForm<ProductFormValues>({
resolver: zodResolver(productSchema) as any,
@@ -69,13 +89,19 @@ export function CreateProductDialog({ children, categories, product }: { childre
base_price: product?.base_price || 0,
category_id: product?.category_id || '',
billing_interval: product?.billing_interval || 'monthly',
show_in_kauf: product ? (product.show_in_kauf ?? true) : true,
show_in_abo: product ? (product.show_in_abo ?? true) : true,
requirements: product?.requirements || [],
exclusions: product?.exclusions || [],
modules: product?.modules?.map(m => ({
id: m.id,
name: m.name,
description: m.description || '',
price: m.price,
is_required: false,
requirements: m.requirements?.join(', ') || '',
exclusions: m.exclusions?.join(', ') || '',
has_quantity: m.has_quantity ?? false,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
})) || [],
},
})
@@ -90,8 +116,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
const { modules, ...productData } = values
const formattedModules = modules.map(m => ({
...m,
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
id: m.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15)),
}))
if (product) {
@@ -102,6 +127,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
setOpen(false)
if (!product) form.reset()
router.refresh()
} catch (error) {
console.error('Error saving product:', error)
}
@@ -116,29 +142,28 @@ export function CreateProductDialog({ children, categories, product }: { childre
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col glass-dark border-white/10 text-white shadow-2xl">
<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">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
</DialogTitle>
<DialogDescription className="text-slate-300">
<DialogDescription className="text-slate-500 dark:text-slate-400">
Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 flex-1 overflow-hidden flex flex-col">
<ScrollArea className="flex-1 max-h-[60vh] pr-4">
<div className="space-y-6 py-4">
<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-white">Produktname</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
<FormControl>
<Input placeholder="z.B. Basic ERP" className="bg-white/5 border-white/10 text-white" {...field} />
<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>
@@ -149,9 +174,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="base_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Basispreis ()</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Basispreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/5 border-white/10 text-white" {...field} />
<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>
@@ -164,16 +189,16 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Kategorie</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Kategorie wählen" className="text-white" />
<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="glass-dark border-white/10 text-white">
<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} className="text-white">{cat.name}</SelectItem>
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
@@ -187,7 +212,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="billing_interval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Abrechnungsmodell</FormLabel>
<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: '💳' },
@@ -199,8 +224,8 @@ export function CreateProductDialog({ children, categories, product }: { childre
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-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:bg-white/10'
? '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>
@@ -213,21 +238,154 @@ export function CreateProductDialog({ children, categories, product }: { childre
)}
/>
<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>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Beschreibung</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-white/5 border-white/10 text-white placeholder:text-slate-400" {...field} />
<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>
)}
/>
<Separator className="bg-white/10" />
<div className="grid grid-cols-2 gap-4">
{/* Product Requirements */}
<FormField
control={form.control}
name="requirements"
render={({ field: reqField }) => (
<FormItem className="flex flex-col gap-1.5">
<FormLabel className="text-slate-900 dark:text-white font-medium">Benötigte Artikel</FormLabel>
<ScrollArea className="h-32 border border-slate-200 dark:border-white/10 rounded p-3 bg-slate-50 dark:bg-white/5">
{otherProducts.length === 0 ? (
<p className="text-xs text-slate-500 italic">Keine anderen Artikel vorhanden.</p>
) : (
<div className="space-y-2">
{otherProducts.map(other => {
const checked = (reqField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`prod-req-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = reqField.value || []
if (checkedState) {
reqField.onChange([...currentVal, other.id])
} else {
reqField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`prod-req-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
<FormMessage />
</FormItem>
)}
/>
{/* Product Exclusions */}
<FormField
control={form.control}
name="exclusions"
render={({ field: exclField }) => (
<FormItem className="flex flex-col gap-1.5">
<FormLabel className="text-slate-900 dark:text-white font-medium">Schließt aus</FormLabel>
<ScrollArea className="h-32 border border-slate-200 dark:border-white/10 rounded p-3 bg-slate-50 dark:bg-white/5">
{otherProducts.length === 0 ? (
<p className="text-xs text-slate-500 italic">Keine anderen Artikel vorhanden.</p>
) : (
<div className="space-y-2">
{otherProducts.map(other => {
const checked = (exclField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`prod-excl-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = exclField.value || []
if (checkedState) {
exclField.onChange([...currentVal, other.id])
} else {
exclField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`prod-excl-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator className="bg-slate-200 dark:bg-white/10" />
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -240,116 +398,211 @@ export function CreateProductDialog({ children, categories, product }: { childre
variant="outline"
size="sm"
className="border-primary/50 text-primary hover:bg-primary/10"
onClick={() => append({ name: '', price: 0, is_required: false, description: '', requirements: '', exclusions: '' })}
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) => (
<div key={field.id} className="p-4 rounded-lg bg-white/5 border 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>
{fields.map((field, index) => {
const allModules = form.watch('modules') || []
const otherModules = allModules
.map((m, idx) => ({
id: m.id || field.id,
name: m.name || `Modul ${idx + 1}`,
}))
.filter((_, idx) => idx !== index)
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Modulname</FormLabel>
<FormControl>
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Aufpreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/10 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
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}.requirements`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.exclusions`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<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>
<div className="flex items-center space-x-2">
<FormField
control={form.control}
name={`modules.${index}.is_required`}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-white">Erforderlich</FormLabel>
<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">
{otherModules.map(other => {
const checked = (reqField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`req-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = reqField.value || []
if (checkedState) {
reqField.onChange([...currentVal, other.id])
} else {
reqField.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}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
</div>
</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">
{otherModules.map(other => {
const checked = (exclField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`excl-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = exclField.value || []
if (checkedState) {
exclField.onChange([...currentVal, other.id])
} else {
exclField.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}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
</div>
)}
/>
</div>
<div className="flex items-center gap-6">
<FormField
control={form.control}
name={`modules.${index}.is_required`}
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">Erforderlich</FormLabel>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.has_quantity`}
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">Mengeneingabe erlauben</FormLabel>
</FormItem>
)}
/>
</div>
</div>
</div>
))}
)
})}
{fields.length === 0 && (
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">
<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>
</div>
</ScrollArea>
</div>
<DialogFooter className="pt-4 border-t border-white/10">
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button type="submit" className="bg-primary hover:bg-primary/90">Speichern</Button>
<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>
</form>
</Form>

View File

@@ -0,0 +1,51 @@
"use client";
import { signOut } from "@/lib/actions/auth";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
import { createBrowserClient } from "@supabase/ssr";
import { resolveSupabaseUrl } from "@/lib/utils";
export function AdminLogoutButton() {
const router = useRouter();
// Supabase URL & Anon Key auslesen (für Browser-Client)
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Browser Client inline erstellen
const supabase = createBrowserClient(
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
);
const handleLogout = async () => {
try {
await signOut();
} catch (error) {
console.error("Fehler beim Abmelden:", error);
}
try {
await supabase.auth.signOut();
} catch (e) {
console.error("Fehler beim Client-Signout:", e);
}
router.push("/");
router.refresh();
};
return (
<button
onClick={handleLogout}
className="flex w-full 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 text-left"
>
<LogOut className="w-5 h-5" />
Abmelden
</button>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, ExternalLink, Search, Loader2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { updateOrderStatus } from "@/lib/actions/orders";
import { resolveSupabaseUrl } from "@/lib/utils";
interface OrdersTableProps {
initialOrders: any[];
}
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 border-amber-500/30',
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
rejected: 'bg-rose-500/20 text-rose-400 border border-rose-500/30',
};
export function OrdersTable({ initialOrders }: OrdersTableProps) {
const [orders, setOrders] = useState(initialOrders);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [updatingId, setUpdatingId] = useState<string | null>(null);
useEffect(() => {
setOrders(initialOrders);
}, [initialOrders]);
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
setUpdatingId(orderId);
try {
await updateOrderStatus(orderId, newStatus);
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: newStatus } : o));
} catch (error) {
console.error(error);
alert("Fehler beim Ändern des Status.");
} finally {
setUpdatingId(null);
}
};
const filteredOrders = orders.filter((order) => {
const orderNum = (order.order_number || "").toLowerCase();
const company = (order.customer_data?.company_name || "").toLowerCase();
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
const matchesSearch =
orderNum.includes(search.toLowerCase()) ||
company.includes(search.toLowerCase()) ||
contact.includes(search.toLowerCase());
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
return (
<div className="space-y-4">
{/* Filterleiste */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
<div className="relative w-full md:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Bestellung, Firma oder Name suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("all")}
className="text-xs"
>
Alle
</Button>
<Button
variant={statusFilter === "pending" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("pending")}
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
>
Eingegangen
</Button>
<Button
variant={statusFilter === "active" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("active")}
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
>
In Bearbeitung
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("completed")}
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
>
Abgeschlossen
</Button>
<Button
variant={statusFilter === "cancelled" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("cancelled")}
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
>
Storniert
</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>
{/* Tabelle */}
<div className="rounded-xl border border-white/5 bg-black/20 overflow-hidden backdrop-blur-xl">
<Table>
<TableHeader>
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
<TableHead className="text-slate-200">Datum</TableHead>
<TableHead className="text-slate-200">Kunde</TableHead>
<TableHead className="text-slate-200">Auswahl</TableHead>
<TableHead className="text-slate-200">Betrag</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
Keine Bestellungen gefunden.
</TableCell>
</TableRow>
) : (
filteredOrders.map((order) => {
const items = order.order_data?.items || [];
return (
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-mono text-xs text-primary font-semibold">
#{order.order_number || order.id.slice(0, 8)}
</TableCell>
<TableCell className="text-slate-300 text-sm">
{new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
})}
</TableCell>
<TableCell>
<div className="font-semibold text-white text-sm">
{order.customer_data?.company_name || 'Privatkunde'}
</div>
<div className="text-xs text-slate-400">
{order.customer_data?.first_name} {order.customer_data?.last_name}
</div>
</TableCell>
<TableCell className="max-w-[200px]">
<div className="flex flex-wrap gap-1">
{items.map((item: any) => (
<span
key={item.product_id}
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
>
{item.product_name}
{item.selected_modules?.length > 0 && (
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
)}
</span>
))}
</div>
</TableCell>
<TableCell className="font-bold text-white text-sm">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={updatingId === order.id}>
<button className="focus:outline-none focus:ring-0 active:scale-95 disabled:pointer-events-none transition-transform duration-100">
<Badge className={`text-xs px-2 py-0.5 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
{updatingId === order.id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : null}
{statusLabel[order.status] ?? order.status}
<span className="text-[9px] opacity-70"></span>
</Badge>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[140px]">
{Object.entries(statusLabel).map(([key, label]) => (
<DropdownMenuItem
key={key}
disabled={updatingId === order.id}
onClick={() => handleStatusChange(order.id, key as any)}
className={`text-xs cursor-pointer hover:bg-white/10 transition-colors focus:bg-white/10 focus:text-white ${order.status === key ? 'font-bold text-primary' : ''}`}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs">
<a href={`/order?id=${order.id}`}>
Bearbeiten
</a>
</Button>
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
<a href={`/api/admin/orders/${order.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF
</a>
</Button>
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
<a href={`/api/admin/orders/${order.id}/download`}>
<Download className="w-3.5 h-3.5" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -11,14 +11,61 @@ import {
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Edit2, Trash2, Layers } from 'lucide-react'
import { Edit2, Trash2, Layers, ArrowUpDown, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { deleteProduct } from '@/lib/actions/products'
import { useState } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { deleteProduct, transferModules } from '@/lib/actions/products'
import { useState, useEffect, useMemo, Fragment } from 'react'
import { CreateProductDialog } from './create-product-dialog'
import { useRouter } from 'next/navigation'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
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 [showKauf, setShowKauf] = useState(true)
const [showAbo, setShowAbo] = useState(true)
const [copySourceProduct, setCopySourceProduct] = useState<Product | null>(null)
const [copyTargetProductId, setCopyTargetProductId] = useState<string>('')
const [copyMode, setCopyMode] = useState<'overwrite' | 'append'>('overwrite')
const [isCopying, setIsCopying] = useState(false)
const router = useRouter()
const handleCopyModules = async () => {
if (!copySourceProduct || !copyTargetProductId) return
setIsCopying(true)
try {
await transferModules(copySourceProduct.id, copyTargetProductId, copyMode)
setCopySourceProduct(null)
setCopyTargetProductId('')
router.refresh()
} catch (err) {
console.error('Error copying modules:', err)
alert('Fehler beim Kopieren der Module.')
} finally {
setIsCopying(false)
}
}
useEffect(() => {
setProducts(initialProducts)
}, [initialProducts])
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
@@ -27,88 +74,240 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
}
}
return (
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<CardHeader>
<CardTitle>Produktübersicht</CardTitle>
<CardDescription className="text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-white font-bold">Name</TableHead>
<TableHead className="text-white font-bold">Basispreis</TableHead>
<TableHead className="text-white font-bold">Module</TableHead>
<TableHead className="text-white font-bold">Status</TableHead>
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-medium text-white">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{product.name}
{product.category && (
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary py-0 h-4">
{product.category.name}
</Badge>
)}
{product.billing_interval === 'monthly' && (
<Badge className="text-[10px] bg-blue-500/20 text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
)}
{product.billing_interval === 'one_time' && (
<Badge className="text-[10px] bg-slate-500/20 text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
)}
</div>
<p className="text-xs text-slate-300 font-normal">{product.description}</p>
</div>
</TableCell>
<TableCell className="text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Layers className="w-3 h-3 text-primary" />
<span className="text-slate-200">{product.modules?.length || 0} Module</span>
</div>
</TableCell>
<TableCell>
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<CreateProductDialog categories={categories} product={product}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
<Edit2 className="h-4 w-4" />
</Button>
</CreateProductDialog>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(product.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{products.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-slate-400">
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
</TableCell>
</TableRow>
const toggleGroupByCategory = () => {
setIsGroupedByCategory(!isGroupedByCategory)
}
const filteredProducts = useMemo(() => {
return products.filter(p => {
const inKauf = p.show_in_kauf ?? true
const inAbo = p.show_in_abo ?? true
if (showKauf && inKauf) return true
if (showAbo && inAbo) return true
return false
})
}, [products, showKauf, showAbo])
const groupedProducts = useMemo(() => {
const groups: Record<string, { category: Category | null; products: Product[] }> = {}
filteredProducts.forEach(p => {
const catId = p.category_id || 'no-category'
if (!groups[catId]) {
groups[catId] = {
category: p.category || null,
products: []
}
}
groups[catId].products.push(p)
})
return Object.values(groups).sort((a, b) => {
const orderA = a.category?.sort_order ?? 9999
const orderB = b.category?.sort_order ?? 9999
return orderA - orderB
})
}, [filteredProducts])
const renderProductRow = (product: Product) => (
<TableRow key={product.id} className="border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
<TableCell className="font-medium text-slate-900 dark:text-white">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{product.name}
{product.category && (
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary py-0 h-4">
{product.category.name}
</Badge>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{product.billing_interval === 'monthly' && (
<Badge className="text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
)}
{product.billing_interval === 'one_time' && (
<Badge className="text-[10px] bg-slate-500/20 text-slate-600 dark:text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
)}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-normal">{product.description}</p>
</div>
</TableCell>
<TableCell className="text-slate-900 dark:text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Layers className="w-3 h-3 text-primary" />
<span className="text-slate-700 dark:text-slate-200">{product.modules?.length || 0} Module</span>
</div>
</TableCell>
<TableCell>
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
onClick={() => {
setCopySourceProduct(product)
setCopyTargetProductId('')
}}
title="Module kopieren"
>
<Copy className="h-4 w-4" />
</Button>
<CreateProductDialog categories={categories} product={product} products={products}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
<Edit2 className="h-4 w-4" />
</Button>
</CreateProductDialog>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(product.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
return (
<>
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
<CardHeader className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<CardTitle>Produktübersicht</CardTitle>
<CardDescription className="text-slate-500 dark:text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-6">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
<Checkbox
checked={showKauf}
onCheckedChange={(checked) => setShowKauf(!!checked)}
/>
In Kaufauswahl
</label>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
<Checkbox
checked={showAbo}
onCheckedChange={(checked) => setShowAbo(!!checked)}
/>
In Aboauswahl
</label>
<Button
variant="outline"
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' : ''
}`}
>
<ArrowUpDown className="w-4 h-4 mr-2" />
{isGroupedByCategory ? 'Kategoriengruppierung aktiv' : 'Nach Kategorien gruppieren'}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-auto max-h-[600px] border border-slate-200 dark:border-white/10 rounded-lg scrollbar-thin">
<Table>
<TableHeader className="sticky top-0 bg-white dark:bg-slate-900/95 backdrop-blur-sm z-10">
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Status</TableHead>
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isGroupedByCategory ? (
groupedProducts.map((group) => (
<Fragment key={group.category?.id || 'no-category'}>
<TableRow className="bg-slate-100 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/40 border-b border-slate-200 dark:border-white/10">
<TableCell colSpan={5} className="text-primary font-bold py-2.5 text-xs uppercase tracking-wider">
{group.category ? group.category.name : 'Keine Kategorie'}
</TableCell>
</TableRow>
{group.products.map((product) => renderProductRow(product))}
</Fragment>
))
) : (
filteredProducts.map((product) => renderProductRow(product))
)}
{filteredProducts.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
Keine Produkte gefunden. Passen Sie Ihre Filter an.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Dialog open={!!copySourceProduct} onOpenChange={(open) => { if (!open) setCopySourceProduct(null) }}>
<DialogContent className="sm:max-w-[425px] bg-white dark:bg-slate-950 text-slate-900 dark:text-white border-slate-200 dark:border-white/10 shadow-2xl">
<DialogHeader>
<DialogTitle>Module übertragen</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Kopieren Sie alle Module von <strong>{copySourceProduct?.name}</strong> auf ein anderes Produkt.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="target-product">Ziel-Produkt</Label>
<Select value={copyTargetProductId} onValueChange={setCopyTargetProductId}>
<SelectTrigger id="target-product" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
<SelectValue placeholder="Ziel-Produkt auswählen" />
</SelectTrigger>
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
{products
.filter(p => p.id !== copySourceProduct?.id)
.map(p => (
<SelectItem key={p.id} value={p.id} className="focus:bg-slate-100 dark:focus:bg-white/5">
{p.name}
</SelectItem>
))
}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Übertragungsmodus</Label>
<RadioGroup value={copyMode} onValueChange={(val: any) => setCopyMode(val)} className="flex flex-col gap-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="overwrite" id="mode-overwrite" />
<Label htmlFor="mode-overwrite" className="font-normal cursor-pointer text-slate-700 dark:text-slate-300">
Bestehende Module überschreiben
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="append" id="mode-append" />
<Label htmlFor="mode-append" className="font-normal cursor-pointer text-slate-700 dark:text-slate-300">
Module anfügen (bestehende behalten)
</Label>
</div>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCopySourceProduct(null)} disabled={isCopying} className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
Abbrechen
</Button>
<Button onClick={handleCopyModules} disabled={!copyTargetProductId || isCopying} className="bg-primary text-white hover:bg-primary/90">
{isCopying ? 'Wird kopiert...' : 'Module übertragen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import { RevenueChart } from './revenue-chart'
type RecentOrder = {
id: string
@@ -22,27 +23,30 @@ 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
export function AdminRecentOrders() {
const [orders, setOrders] = useState<RecentOrder[]>([])
const [chartData, setChartData] = useState<{ name: string; value: number }[]>([])
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
const [lastChecked, setLastChecked] = useState<Date>(new Date())
const knownIds = useRef<Set<string>>(new Set())
const fetchOrders = async () => {
try {
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' });
if (!res.ok) return
const { orders: fresh } = await res.json()
const { orders: fresh, chartData: freshChart } = await res.json()
// Neue Bestellungen erkennen (Highlight)
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
@@ -53,6 +57,7 @@ export function AdminRecentOrders() {
knownIds.current = freshIds
setOrders(fresh)
setChartData(freshChart || [])
setNewOrderIds(newOnes)
setLastChecked(new Date())
@@ -72,69 +77,61 @@ export function AdminRecentOrders() {
}, [])
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4 glass-dark border-white/5">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7 bg-[#020617]/85 p-4 rounded-xl">
<div className="rounded-xl border border-white/5 bg-[#020617]/85 overflow-hidden backdrop-blur-xl col-span-4">
<CardHeader>
<CardTitle>Übersicht</CardTitle>
<CardTitle className="text-white">Übersicht (letzte 6 Monate)</CardTitle>
</CardHeader>
<CardContent className="pl-2">
<div className="h-[200px] flex items-center justify-center text-slate-400 italic">
[Umsatz-Chart folgt in nächstem Update]
</div>
<RevenueChart data={chartData} />
</CardContent>
</Card>
</div>
<Card className="col-span-3 glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle>Neue Bestellungen</CardTitle>
<div className="flex flex-col gap-4 bg-[#020617]/85 p-4 rounded-xl border border-white/5 col-span-3">
<div className="flex flex-row items-center justify-between pb-3">
<h3 className="font-semibold text-lg text-white">Neue Bestellungen</h3>
<span className="text-[10px] text-slate-500">
Aktualisiert: {lastChecked.toLocaleTimeString('de-DE')}
</span>
</CardHeader>
<CardContent>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{orders.length === 0 ? (
<p className="text-slate-500 text-sm italic text-center py-8">Noch keine Bestellungen</p>
) : (
<div className="space-y-3">
{orders.map(order => {
const isNew = newOrderIds.has(order.id)
const customerName =
order.customer_data?.company_name ||
`${order.customer_data?.first_name ?? ''} ${order.customer_data?.last_name ?? ''}`.trim() ||
'Unbekannt'
return (
<Link key={order.id} href="/admin/orders" className="block">
<div
className={`flex items-center gap-3 p-3 rounded-lg transition-all duration-500 ${
isNew
? 'bg-primary/10 border border-primary/30 shadow-[0_0_12px_rgba(59,130,246,0.2)]'
: 'bg-white/5 hover:bg-white/10 border border-white/5'
orders.slice(0, 3).map(order => {
const isNew = newOrderIds.has(order.id)
const customerName =
order.customer_data?.company_name ||
`${order.customer_data?.first_name ?? ''} ${order.customer_data?.last_name ?? ''}`.trim() ||
'Unbekannt'
return (
<Link key={order.id} href="/admin/orders" className="block">
<div
className={`flex items-center gap-3 p-3 rounded-lg transition-all duration-500 ${isNew
? 'bg-primary/10 border border-primary/30 shadow-[0_0_12px_rgba(59,130,246,0.2)]'
: 'bg-[#020617]/20 hover:bg-[#020617]/30 border border-white/5'
}`}
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0">
{customerName.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{customerName}</p>
<p className="text-xs text-slate-500 font-mono">#{order.order_number}</p>
</div>
<div className="text-right shrink-0 space-y-1">
<p className="font-medium text-white text-sm">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</p>
<Badge className={`text-[10px] ${statusClass[order.status] ?? ''}`}>
{statusLabel[order.status] ?? order.status}
</Badge>
</div>
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0">
{customerName.slice(0, 2).toUpperCase()}
</div>
</Link>
)
})}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{customerName}</p>
<p className="text-xs text-slate-500 font-mono">#{order.order_number}</p>
</div>
<div className="text-right shrink-0 space-y-1">
<p className="font-medium text-white text-sm">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</p>
<Badge className={`text-[10px] ${statusClass[order.status] ?? ''}`}>{statusLabel[order.status] ?? order.status}</Badge>
</div>
</div>
</Link>
)
})
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
type ChartDataPoint = {
name: string
value: number
}
export function RevenueChart({ data }: { data: ChartDataPoint[] }) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
if (!data || data.length === 0) {
return (
<div className="h-[200px] flex items-center justify-center text-slate-500 italic">
Keine Umsatzdaten vorhanden.
</div>
)
}
const width = 500
const height = 200
const paddingLeft = 55
const paddingRight = 15
const paddingTop = 25
const paddingBottom = 30
const chartWidth = width - paddingLeft - paddingRight
const chartHeight = height - paddingTop - paddingBottom
const maxVal = Math.max(...data.map(d => d.value), 100)
const maxValue = Math.ceil(maxVal * 1.15) // 15% headroom
const points = data.map((d, i) => {
const x = paddingLeft + (i / (data.length - 1)) * chartWidth
const y = paddingTop + chartHeight - (d.value / maxValue) * chartHeight
return { x, y, name: d.name, value: d.value }
})
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
const areaPath = points.length > 0
? `${linePath} L ${points[points.length - 1].x} ${paddingTop + chartHeight} L ${points[0].x} ${paddingTop + chartHeight} Z`
: ''
// 4 ticks for grid
const yTicks = [0, 0.33, 0.66, 1].map(ratio => Math.round(maxValue * ratio))
return (
<div className="relative w-full">
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto overflow-visible select-none">
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.0" />
</linearGradient>
</defs>
{/* Grid Lines */}
{yTicks.map((val, idx) => {
const y = paddingTop + chartHeight - (val / maxValue) * chartHeight
return (
<g key={idx} className="opacity-40">
<line
x1={paddingLeft}
y1={y}
x2={width - paddingRight}
y2={y}
stroke="white"
strokeWidth={1}
strokeDasharray="4 4"
className="stroke-white/10"
/>
<text
x={paddingLeft - 8}
y={y + 4}
className="fill-slate-400 text-[9px] text-right font-mono"
textAnchor="end"
>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(val)}
</text>
</g>
)
})}
{/* X Axis Labels */}
{points.map((p, i) => (
<text
key={i}
x={p.x}
y={height - 8}
className="fill-slate-400 text-[10px] font-semibold"
textAnchor="middle"
>
{p.name}
</text>
))}
{/* Area under the line */}
{areaPath && (
<path d={areaPath} fill="url(#areaGrad)" />
)}
{/* Line */}
{linePath && (
<path
d={linePath}
fill="none"
stroke="#3b82f6"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
className="drop-shadow-[0_2px_8px_rgba(59,130,246,0.4)]"
/>
)}
{/* Interactive Dots & Hover areas */}
{points.map((p, i) => (
<g key={i}>
{/* Thick hover circle area */}
<circle
cx={p.x}
cy={p.y}
r={16}
fill="transparent"
className="cursor-pointer"
onMouseEnter={() => setHoveredIdx(i)}
onMouseLeave={() => setHoveredIdx(null)}
/>
{/* Visual Point dot */}
<circle
cx={p.x}
cy={p.y}
r={hoveredIdx === i ? 6 : 4}
className="transition-all duration-200 fill-primary stroke-[#0b0f19] stroke-2"
pointerEvents="none"
/>
{hoveredIdx === i && (
<circle
cx={p.x}
cy={p.y}
r={12}
className="fill-primary/20 animate-ping"
pointerEvents="none"
/>
)}
</g>
))}
</svg>
{/* HTML Tooltip overlay */}
{hoveredIdx !== null && (
<div
className="absolute bg-slate-950/95 border border-white/10 rounded-lg p-2.5 text-xs text-white shadow-xl backdrop-blur-md pointer-events-none transition-all duration-150 font-sans"
style={{
left: `${((points[hoveredIdx].x - paddingLeft) / chartWidth) * 80 + 10}%`,
top: `${(points[hoveredIdx].y / height) * 75 - 12}%`,
transform: 'translateX(-50%) translateY(-100%)',
}}
>
<p className="font-semibold text-slate-400">{points[hoveredIdx].name}</p>
<p className="font-bold text-sm text-primary mt-0.5">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(points[hoveredIdx].value)}
</p>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -25,50 +25,112 @@ import {
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { createUser } from '@/lib/actions/users'
import { Plus } from 'lucide-react'
import { createCompany } from '@/lib/actions/companies'
import { Plus, Building2 } from 'lucide-react'
const userSchema = z.object({
email: z.string().email('Ungültige E-Mail-Adresse'),
password: z.string().min(6, 'Passwort muss mindestens 6 Zeichen lang sein').optional(),
role: z.enum(['admin', 'partner', 'verwaltung']),
company_id: z.string().optional().or(z.literal('')),
first_name: z.string().optional().or(z.literal('')),
last_name: z.string().optional().or(z.literal('')),
}).refine((data) => {
if (data.role === 'partner' && !data.company_id) {
return false
}
return true
}, {
message: 'Für Partner-Benutzer ist eine Firmenauswahl erforderlich.',
path: ['company_id'],
})
type UserFormValues = z.infer<typeof userSchema>
export function UserDialog() {
interface UserDialogProps {
companies: { id: string; name: string }[]
}
export function UserDialog({ companies }: UserDialogProps) {
const [open, setOpen] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const [search, setSearch] = useState('')
const [localCompanies, setLocalCompanies] = useState<{ id: string; name: string }[]>(companies)
const [showNewCompanyInput, setShowNewCompanyInput] = useState(false)
const [newCompanyName, setNewCompanyName] = useState('')
const [isCreatingCompany, setIsCreatingCompany] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const router = useRouter()
useEffect(() => {
setLocalCompanies(companies)
}, [companies])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false)
setSearch('')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema) as any,
defaultValues: {
email: '',
password: '',
role: 'partner',
company_id: '',
first_name: '',
last_name: '',
},
})
async function onSubmit(values: UserFormValues) {
try {
await createUser(values)
setOpen(false)
form.reset()
router.refresh()
const payload = {
email: values.email,
role: values.role,
company_id: values.company_id || undefined,
first_name: values.first_name || undefined,
last_name: values.last_name || undefined,
}
const res = await createUser(payload)
if (res.success) {
setOpen(false)
form.reset()
router.refresh()
} else {
alert(res.error || 'Fehler beim Erstellen des Benutzers.')
}
} catch (error) {
console.error('Error creating user:', error)
alert('Ein unerwarteter Fehler ist aufgetreten.')
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={(val) => {
setOpen(val)
if (!val) {
form.reset()
setDropdownOpen(false)
setSearch('')
setShowNewCompanyInput(false)
setNewCompanyName('')
}
}}>
<DialogTrigger asChild>
<Button className="bg-primary hover:bg-primary/90">
<Plus className="mr-2 h-4 w-4" /> Benutzer neu anlegen
</Button>
</DialogTrigger>
<DialogContent className="glass-dark border-white/10 text-white shadow-2xl">
<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>Neuen Benutzer anlegen</DialogTitle>
<DialogDescription className="text-slate-300">
Geben Sie die E-Mail und optional ein Passwort für den neuen Benutzer ein.
<DialogDescription className="text-slate-500 dark:text-slate-400">
Geben Sie die E-Mail, die Rolle und optional die Firma für den neuen Benutzer ein. Nach der Erstellung erhält der Benutzer eine E-Mail zum Festlegen seines Passworts.
</DialogDescription>
</DialogHeader>
@@ -79,9 +141,57 @@ export function UserDialog() {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">E-Mail</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">E-Mail</FormLabel>
<FormControl>
<Input placeholder="user@example.com" className="bg-white/5 border-white/10 text-white" {...field} />
<Input placeholder="user@example.com" 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 className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="first_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Vorname</FormLabel>
<FormControl>
<Input placeholder="Vorname" 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="last_name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Nachname</FormLabel>
<FormControl>
<Input placeholder="Nachname" 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="role"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Rolle</FormLabel>
<FormControl>
<select
className="flex h-10 w-full rounded-md border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-slate-950/80 px-3 py-2 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
{...field}
>
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
<option value="verwaltung" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Verwaltung</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
@@ -89,19 +199,144 @@ export function UserDialog() {
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Passwort (optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="Passwort leer lassen für Zufall" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
name="company_id"
render={({ field }) => {
const selectedCompany = localCompanies.find((c) => c.id === field.value)
return (
<FormItem className="w-full">
<FormLabel className="text-slate-900 dark:text-white">Firma</FormLabel>
<FormControl>
<div className="flex flex-col gap-2 w-full">
<div className="relative w-full" ref={dropdownRef}>
<div className="flex flex-col gap-1 w-full">
<button
type="button"
onClick={() => {
setDropdownOpen(!dropdownOpen)
setSearch('')
}}
className="w-full text-left text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md px-3 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 truncate"
>
{selectedCompany ? selectedCompany.name : 'Keine Firma'}
</button>
</div>
{dropdownOpen && (
<div className="absolute z-50 mt-1 left-0 w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-lg shadow-xl overflow-hidden">
<div className="p-2 border-b border-slate-200 dark:border-white/10">
<input
type="text"
placeholder="Firma suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded px-2 py-1.5 text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-primary/50"
autoFocus
/>
</div>
<div className="max-h-40 overflow-y-auto flex flex-col">
<button
type="button"
onClick={() => {
field.onChange('')
setDropdownOpen(false)
setSearch('')
}}
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${!field.value ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
}`}
>
Keine Firma
</button>
{localCompanies
.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
field.onChange(c.id)
setDropdownOpen(false)
setSearch('')
}}
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors truncate ${field.value === c.id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
}`}
>
{c.name}
</button>
))}
</div>
</div>
)}
</div>
{showNewCompanyInput ? (
<div className="flex flex-col sm:flex-row gap-2 mt-1 w-full">
<Input
placeholder="Name der neuen Firma"
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs h-8 w-full"
/>
<div className="flex gap-2 justify-end">
<Button
type="button"
size="sm"
onClick={async () => {
const trimmed = newCompanyName.trim()
if (trimmed) {
setIsCreatingCompany(true)
try {
const created = await createCompany({ name: trimmed })
if (created) {
setLocalCompanies((prev) => [...prev, created])
field.onChange(created.id)
setShowNewCompanyInput(false)
setNewCompanyName('')
router.refresh()
}
} catch (err) {
console.error('Failed to create company:', err)
} finally {
setIsCreatingCompany(false)
}
}
}}
disabled={isCreatingCompany}
className="bg-primary text-xs h-8"
>
{isCreatingCompany ? 'Erstellt...' : 'Speichern'}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setShowNewCompanyInput(false)
setNewCompanyName('')
}}
className="text-xs h-8 text-slate-500 dark:text-slate-400"
>
Abbrechen
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowNewCompanyInput(true)}
className="text-xs text-primary hover:underline mt-1 self-start font-medium"
>
+ Neue Firma anlegen
</button>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<DialogFooter>
<Button type="submit" className="w-full bg-primary hover:bg-primary/90">
<Button type="submit" className="w-full bg-primary hover:bg-primary/90 text-white">
Benutzer erstellen
</Button>
</DialogFooter>

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useRef, useEffect, Fragment } from 'react'
import { useRouter } from 'next/navigation'
import {
Table,
@@ -11,24 +11,125 @@ import {
TableRow,
} from '@/components/ui/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { UserX, ShieldAlert, Key, Trash2, Mail } from 'lucide-react'
import { Key, Trash2, Mail, Loader2, Building2, Ban, Unlock, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserStatus, resetPassword } from '@/lib/actions/users'
import { deleteUser, resetPassword, updateUserRole, updateUserProfile } from '@/lib/actions/users'
import { assignCompanyToUser } from '@/lib/actions/companies'
import { Badge } from '@/components/ui/badge'
export function UserList({ initialUsers }: { initialUsers: any[] }) {
type Role = 'admin' | 'partner' | 'verwaltung' | 'gesperrt'
const ROLE_LABELS: Record<Role, string> = {
admin: 'Admin',
partner: 'Partner',
verwaltung: 'Verwaltung',
gesperrt: 'Gesperrt',
}
const ROLE_BADGE_CLASSES: Record<Role, string> = {
admin: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
partner: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
verwaltung: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
gesperrt: 'bg-red-500/20 text-red-400 border border-red-500/30',
}
interface UserListProps {
initialUsers: any[]
companies: { id: string; name: string }[]
}
export function UserList({ initialUsers, companies }: UserListProps) {
const router = useRouter()
const [loadingId, setLoadingId] = useState<string | null>(null)
const [companyDropdownOpen, setCompanyDropdownOpen] = useState<string | null>(null)
const [companySearch, setCompanySearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [groupByCompany, setGroupByCompany] = useState(false)
const [hideLocked, setHideLocked] = useState(true)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setCompanyDropdownOpen(null)
setCompanySearch('')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
await deleteUser(id)
router.refresh()
setLoadingId(id)
try {
const res = await deleteUser(id)
if (res.success) {
router.refresh()
} else {
console.error('Fehler beim Löschen des Benutzers:', res.error)
if (res.error?.includes('Belege')) {
if (confirm('Löschen nicht möglich! Auf diesen User existieren bereits Belege. Möchten Sie diesen Benutzer stattdessen sperren?')) {
await handleRoleChange(id, 'gesperrt')
}
} else {
alert('Fehler beim Löschen: ' + res.error)
}
}
} catch (e: any) {
console.error('Fehler beim Löschen des Benutzers:', e)
alert('Fehler beim Löschen: ' + (e.message || JSON.stringify(e)))
} finally {
setLoadingId(null)
}
}
}
const handleToggleBan = async (id: string, isBanned: boolean) => {
await updateUserStatus(id, !isBanned)
router.refresh()
const handleRoleChange = async (id: string, newRole: Role) => {
setLoadingId(id + '-role')
try {
const res = await updateUserRole(id, newRole)
if (res.success) {
router.refresh()
} else {
alert(res.error || 'Fehler beim Ändern der Rolle.')
}
} catch (e: any) {
console.error('Fehler beim Ändern der Rolle:', e)
alert('Fehler beim Ändern der Rolle: ' + (e.message || JSON.stringify(e)))
} finally {
setLoadingId(null)
}
}
const handleToggleLock = async (id: string, currentRole: Role) => {
const newRole = currentRole === 'gesperrt' ? 'partner' : 'gesperrt'
setLoadingId(id + '-lock')
try {
const res = await updateUserRole(id, newRole)
if (res.success) {
router.refresh()
} else {
alert(res.error || 'Fehler beim Sperren/Entsperren.')
}
} catch (e: any) {
console.error('Fehler beim Sperren/Entsperren:', e)
alert('Fehler beim Sperren/Entsperren: ' + (e.message || JSON.stringify(e)))
} finally {
setLoadingId(null)
}
}
const handleCompanyChange = async (userId: string, companyId: string) => {
setLoadingId(userId + '-company')
try {
await assignCompanyToUser(userId, companyId === '' ? null : companyId)
router.refresh()
} catch (e) {
console.error('Fehler beim Zuweisen der Firma:', e)
} finally {
setLoadingId(null)
}
}
const handleResetPassword = async (email: string) => {
@@ -40,75 +141,292 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
}
}
const filteredUsers = initialUsers.filter((user) => {
if (hideLocked && user.role === 'gesperrt') return false
const emailMatch = user.email?.toLowerCase().includes(searchQuery.toLowerCase())
const companyMatch = user.company_name?.toLowerCase().includes(searchQuery.toLowerCase())
const roleMatch = user.role?.toLowerCase().includes(searchQuery.toLowerCase())
return emailMatch || companyMatch || roleMatch
})
const displayUsers = groupByCompany
? [...filteredUsers].sort((a, b) => {
const nameA = a.company_name || 'Keine Firma'
const nameB = b.company_name || 'Keine Firma'
if (!a.company_name && b.company_name) return 1
if (a.company_name && !b.company_name) return -1
return nameA.localeCompare(nameB)
})
: filteredUsers
console.log('COMPANIES IN USERLIST:', companies)
let lastCompanyName: string | null = null
return (
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-visible text-slate-900 dark:text-white">
<CardHeader>
<CardTitle>Benutzerverwaltung</CardTitle>
<CardDescription className="text-slate-400">
Hier können Sie Benutzer neu anlegen, sperren oder Passwörter zurücksetzen.
<CardDescription className="text-slate-500 dark:text-slate-400">
Rollen, Firmenzuweisungen, Passwörter und Benutzerkonten verwalten.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
placeholder="Benutzer suchen (E-Mail, Firma, Rolle)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-slate-50 dark:focus:ring-offset-slate-950 transition-all"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md px-3 py-2 cursor-pointer hover:bg-slate-100 dark:hover:bg-white/10 transition-colors">
<input
type="checkbox"
id="hideLocked"
checked={hideLocked}
onChange={(e) => setHideLocked(e.target.checked)}
className="h-4 w-4 rounded border-slate-200 dark:border-white/10 bg-white dark:bg-slate-950 text-primary focus:ring-primary focus:ring-offset-slate-50 dark:focus:ring-offset-slate-950 accent-primary cursor-pointer"
/>
<label htmlFor="hideLocked" className="text-xs text-slate-700 dark:text-slate-300 font-medium cursor-pointer select-none">
Gesperrte ausblenden
</label>
</div>
<div className="flex items-center gap-2 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md px-3 py-2 cursor-pointer hover:bg-slate-100 dark:hover:bg-white/10 transition-colors">
<input
type="checkbox"
id="groupByCompany"
checked={groupByCompany}
onChange={(e) => setGroupByCompany(e.target.checked)}
className="h-4 w-4 rounded border-slate-200 dark:border-white/10 bg-white dark:bg-slate-950 text-primary focus:ring-primary focus:ring-offset-slate-50 dark:focus:ring-offset-slate-950 accent-primary cursor-pointer"
/>
<label htmlFor="groupByCompany" className="text-xs text-slate-700 dark:text-slate-300 font-medium cursor-pointer select-none">
Nach Firma gruppieren
</label>
</div>
</div>
</div>
<Table containerClassName="overflow-visible">
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-slate-200">E-Mail</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-slate-200">Zuletzt angemeldet</TableHead>
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
<TableHead className="text-slate-700 dark:text-slate-200">E-Mail</TableHead>
<TableHead className="text-slate-700 dark:text-slate-200">Rolle</TableHead>
<TableHead className="text-slate-700 dark:text-slate-200">Firma</TableHead>
<TableHead className="text-slate-700 dark:text-slate-200">Zuletzt angemeldet</TableHead>
<TableHead className="text-right text-slate-700 dark:text-slate-200">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{initialUsers.map((user) => {
const isBanned = !!user.banned_until
{displayUsers.map((user: any) => {
const role: Role = (['admin', 'partner', 'verwaltung', 'gesperrt'].includes(user.role) ? user.role : 'partner') as Role
const isLoadingRole = loadingId === user.id + '-role'
const isLoadingCompany = loadingId === user.id + '-company'
const isLoadingDelete = loadingId === user.id
const currentCompanyName = user.company_name || 'Keine Firma'
const showGroupHeader = groupByCompany && currentCompanyName !== lastCompanyName
lastCompanyName = currentCompanyName
return (
<TableRow key={user.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-medium text-white">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-500" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant={isBanned ? "destructive" : "default"} className={isBanned ? "" : "bg-green-500/20 text-green-500 border-green-500/30"}>
{isBanned ? "Gesperrt" : "Aktiv"}
</Badge>
</TableCell>
<TableCell className="text-slate-400 text-sm">
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString('de-DE') : 'Noch nie'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10"
title="Passwort zurücksetzen"
onClick={() => handleResetPassword(user.email)}
>
<Key className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${isBanned ? 'text-green-500 hover:text-green-400 hover:bg-green-500/10' : 'text-orange-500 hover:text-orange-400 hover:bg-orange-500/10'}`}
title={isBanned ? "Entsperren" : "Sperren"}
onClick={() => handleToggleBan(user.id, isBanned)}
>
{isBanned ? <ShieldAlert className="h-4 w-4" /> : <UserX className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
title="Löschen"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
<Fragment key={user.id}>
{showGroupHeader && (
<TableRow className="bg-slate-50 dark:bg-white/5 border-y border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/5">
<TableCell colSpan={5} className="font-semibold text-primary py-2 px-4 text-xs tracking-wider uppercase">
Firma: {currentCompanyName}
</TableCell>
</TableRow>
)}
<TableRow className="border-slate-150 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
<TableCell className="font-medium text-slate-900 dark:text-white">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-500" />
<span>{user.email}</span>
</div>
<div className="flex gap-2 mt-1 pl-6">
<input
type="text"
defaultValue={user.first_name || ''}
placeholder="Vorname"
onBlur={async (e) => {
const val = e.target.value.trim()
if (val !== (user.first_name || '')) {
try {
const res = await updateUserProfile(user.id, { first_name: val || null, last_name: user.last_name })
if (res.success) {
router.refresh()
} else {
alert(res.error || 'Fehler beim Aktualisieren des Vornamens.')
}
} catch (err: any) {
console.error('Failed to update first name:', err)
alert('Fehler beim Aktualisieren des Vornamens: ' + (err.message || JSON.stringify(err)))
}
}
}}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur()
}
}}
className="bg-transparent border-b border-transparent hover:border-slate-300 dark:hover:border-white/20 focus:border-primary focus:outline-none text-xs text-slate-600 dark:text-slate-400 w-20 px-1 py-0.5 transition-all placeholder:text-slate-500 placeholder:italic"
/>
<input
type="text"
defaultValue={user.last_name || ''}
placeholder="Nachname"
onBlur={async (e) => {
const val = e.target.value.trim()
if (val !== (user.last_name || '')) {
try {
const res = await updateUserProfile(user.id, { first_name: user.first_name, last_name: val || null })
if (res.success) {
router.refresh()
} else {
alert(res.error || 'Fehler beim Aktualisieren des Nachnamens.')
}
} catch (err: any) {
console.error('Failed to update last name:', err)
alert('Fehler beim Aktualisieren des Nachnamens: ' + (err.message || JSON.stringify(err)))
}
}
}}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur()
}
}}
className="bg-transparent border-b border-transparent hover:border-slate-300 dark:hover:border-white/20 focus:border-primary focus:outline-none text-xs text-slate-600 dark:text-slate-400 w-24 px-1 py-0.5 transition-all placeholder:text-slate-500 placeholder:italic"
/>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{isLoadingRole ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-400" />
) : (
<select
value={role}
onChange={(e) => handleRoleChange(user.id, e.target.value as Role)}
className={`text-xs font-semibold rounded px-3 py-1.5 cursor-pointer focus:outline-none focus:ring-2 transition-colors bg-white dark:bg-slate-950/80 ${role === 'admin'
? 'text-purple-600 dark:text-purple-300 border border-purple-500/40 focus:ring-purple-500/50'
: role === 'verwaltung'
? 'text-emerald-600 dark:text-emerald-300 border border-emerald-500/40 focus:ring-emerald-500/50'
: role === 'gesperrt'
? 'text-red-600 dark:text-red-300 border border-red-500/40 focus:ring-red-500/50'
: 'text-blue-600 dark:text-blue-300 border border-blue-500/40 focus:ring-blue-500/50'
}`}
style={{ appearance: 'auto' }}
>
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
<option value="verwaltung" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Verwaltung</option>
<option value="gesperrt" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Gesperrt</option>
</select>
)}
</div>
</TableCell>
<TableCell>
<div className="relative" ref={companyDropdownOpen === user.id ? dropdownRef : undefined}>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-500 shrink-0" />
{isLoadingCompany ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-400" />
) : (
<button
type="button"
onClick={() => {
setCompanyDropdownOpen(companyDropdownOpen === user.id ? null : user.id)
setCompanySearch('')
}}
className="text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded px-3 py-1.5 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer focus:outline-none focus:ring-1 focus:ring-primary/50 max-w-[180px] truncate text-left"
>
{user.company_name || 'Keine Firma'}
</button>
)}
</div>
{companyDropdownOpen === user.id && (
<div className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-md shadow-lg p-1 flex flex-col max-h-60 overflow-y-auto">
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-slate-100 dark:border-white/5 mb-1 sticky top-0 bg-white dark:bg-slate-900 z-10">
<Search className="h-3.5 w-3.5 text-slate-500 shrink-0" />
<input
type="text"
placeholder="Firma suchen..."
value={companySearch}
onChange={(e) => setCompanySearch(e.target.value)}
className="w-full text-xs bg-transparent border-0 p-0 focus:outline-none focus:ring-0 text-slate-900 dark:text-slate-200 placeholder-slate-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
<button
onClick={() => handleCompanyChange(user.id, '')}
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-slate-100 dark:hover:bg-white/5 text-red-500 font-medium"
>
Keine Firma (Zuweisung aufheben)
</button>
{companies
.filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase()))
.map((c) => (
<button
key={c.id}
onClick={() => handleCompanyChange(user.id, c.id)}
className={`w-full text-left px-2 py-1.5 text-xs rounded hover:bg-slate-100 dark:hover:bg-white/5 transition-colors ${user.company_id === c.id
? 'bg-primary/10 text-primary font-medium'
: 'text-slate-700 dark:text-slate-300'
}`}
>
{c.name}
</button>
))}
</div>
)}
</div>
</TableCell>
<TableCell className="text-slate-400 text-sm">
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString('de-DE') : 'Noch nie'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10"
title="Passwort zurücksetzen"
onClick={() => handleResetPassword(user.email)}
>
<Key className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={role === 'gesperrt' ? 'h-8 w-8 text-green-500 hover:text-green-400 hover:bg-green-500/10' : 'h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10'}
title={role === 'gesperrt' ? 'Entsperren' : 'Sperren'}
onClick={() => handleToggleLock(user.id, role)}
disabled={loadingId === user.id + '-lock'}
>
{loadingId === user.id + '-lock' ? <Loader2 className="h-4 w-4 animate-spin" /> : role === 'gesperrt' ? <Unlock className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
title="Löschen"
onClick={() => handleDelete(user.id)}
disabled={isLoadingDelete}
>
{isLoadingDelete ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</div>
</TableCell>
</TableRow>
</Fragment>
)
})}
</TableBody>

View File

@@ -21,9 +21,6 @@ export async function AuthButton() {
<Button asChild size="sm" variant={"outline"}>
<Link href="/auth/login">Sign in</Link>
</Button>
<Button asChild size="sm" variant={"default"}>
<Link href="/auth/sign-up">Sign up</Link>
</Button>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { resetPassword } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -26,19 +26,18 @@ export function ForgotPasswordForm({
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
const supabase = createClient();
setIsLoading(true);
setError(null);
try {
// The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/update-password`,
});
if (error) throw error;
setSuccess(true);
const res = await resetPassword(email);
if (res.success) {
setSuccess(true);
} else {
setError(res.error || "Ein Fehler ist aufgetreten.");
}
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
} finally {
setIsLoading(false);
}
@@ -49,34 +48,34 @@ export function ForgotPasswordForm({
{success ? (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Check Your Email</CardTitle>
<CardDescription>Password reset instructions sent</CardDescription>
<CardTitle className="text-2xl">E-Mails prüfen</CardTitle>
<CardDescription>Link zum Zurücksetzen gesendet</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
If you registered using your email and password, you will receive
a password reset email.
Wenn Sie sich mit E-Mail und Passwort registriert haben, erhalten Sie
in Kürze eine E-Mail zum Zurücksetzen Ihres Passworts.
</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Reset Your Password</CardTitle>
<CardTitle className="text-2xl">Passwort zurücksetzen</CardTitle>
<CardDescription>
Type in your email and we&apos;ll send you a link to reset your
password
Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link,
um Ihr Passwort zurückzusetzen.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleForgotPassword}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
placeholder="name@beispiel.de"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -84,16 +83,16 @@ export function ForgotPasswordForm({
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Sending..." : "Send reset email"}
{isLoading ? "Wird gesendet..." : "Link senden"}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Already have an account?{" "}
Bereits ein Konto vorhanden?{" "}
<Link
href="/auth/login"
className="underline underline-offset-4"
>
Login
Anmelden
</Link>
</div>
</form>

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect } from "react";
import { createBrowserClient } from "@supabase/ssr";
import { useRouter } from "next/navigation";
import { signOut } from "@/lib/actions/auth";
import { resolveSupabaseUrl } from "@/lib/utils";
export function InactivityTracker() {
const router = useRouter();
// Supabase URL & Anon Key auslesen (für Browser-Client)
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Browser Client inline erstellen
const supabase = createBrowserClient(
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
);
useEffect(() => {
// Regelmäßige Überprüfung der Session-Gültigkeit (z.B. wegen Login auf anderem Browser/Gerät)
const interval = setInterval(async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return; // Nicht eingeloggt, keine Prüfung nötig
// Prüfen, ob die Session auf dem Server noch gültig ist (z.B. wegen Login auf anderem Browser)
const { data: { user }, error } = await supabase.auth.getUser();
if (error) {
// Nur bei echten Auth-Fehlern (z.B. ungültiges Token / Session gelöscht) abmelden.
// Netzwerkfehler wie "Failed to fetch" haben keinen HTTP-Status (error.status ist undefined).
const isAuthError = error.status === 400 || error.status === 401 || error.status === 403;
if (isAuthError) {
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
}
await supabase.auth.signOut();
router.push("/auth/login?message=concurrent");
router.refresh();
}
} else if (!user) {
// Keine Fehlermeldung, aber auch kein User-Objekt (Sitzung abgelaufen/gelöscht)
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
}
await supabase.auth.signOut();
router.push("/auth/login?message=concurrent");
router.refresh();
}
}, 5000); // Prüfung alle 5 Sekunden
return () => {
clearInterval(interval);
};
}, [supabase, router]);
return null;
}

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,81 +81,153 @@ 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, product, modules, customer }: any) => (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
<Text>Software Solutions</Text>
</View>
<View style={styles.companyInfo}>
<Text>CASPOS GmbH</Text>
<Text>Musterstraße 1</Text>
<Text>12345 Musterstadt</Text>
<Text>Deutschland</Text>
</View>
</View>
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';
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
const totalNet = items.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const totalTax = Math.round(totalNet * (taxRate / 100) * 100) / 100;
const totalGross = Math.round((totalNet + totalTax) * 100) / 100;
<View style={styles.section}>
<Text style={styles.label}>Rechnungsempfänger</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
</View>
const formattedPrice = (val: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
<View style={styles.section}>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.id}</Text>
<Text>Datum: {new 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 (mtl.)</Text></View>
</View>
<View style={styles.tableRow}>
<View style={styles.tableCol}><Text>{product.name} (Basispaket)</Text></View>
<View style={styles.tableColPrice}><Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</Text></View>
</View>
{modules.map((mod: any) => (
<View key={mod.id} style={styles.tableRow}>
<View style={styles.tableCol}><Text>{mod.name}</Text></View>
<View style={styles.tableColPrice}><Text>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.additional_price)}</Text></View>
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
<Text>Die Kasse</Text>
</View>
))}
</View>
<View style={styles.total}>
<View style={{ width: '40%' }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text>Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 }}>
<Text style={styles.totalLabel}>Gesamtbetrag:</Text>
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
<View style={styles.companyInfo}>
<Text>CASPOS Computerabrechnungssysteme GmbH</Text>
<Text>Alte Bundesstraße 16</Text>
<Text>76846 Hauenstein</Text>
<Text>Deutschland</Text>
</View>
</View>
</View>
<View style={styles.footer}>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
</View>
</Page>
</Document>
);
<Text style={styles.title}>
{isSubscription ? 'Anfragebestätigung: Software-Abonnement' : 'Anfragebestätigung: Einmalige Lizenzen'}
</Text>
<View style={styles.section}>
<Text style={styles.label}>Kunde</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.order_number || order.id}</Text>
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
</View>
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
</View>
{items.map((item: any, idx: number) => (
<View key={idx}>
<View style={styles.tableRow}>
<View style={styles.tableCol}>
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
</View>
<View style={styles.tableColPrice}>
<Text>
{formattedPrice(item.base_price)} {isSubscription ? 'mtl.' : 'einmalig'}
</Text>
</View>
</View>
{item.selected_modules?.map((mod: any, mIdx: number) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
return (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
</Text>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{formattedPrice(price)}
</Text>
</View>
</View>
);
})}
</View>
))}
</View>
<View style={styles.total}>
<View style={{ width: '70%' }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{formattedPrice(totalNet)}{isSubscription ? ' / mtl.' : ''}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{formattedPrice(totalTax)}{isSubscription ? ' / mtl.' : ''}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
<Text style={styles.totalLabel}>
Gesamtbetrag (brutto):
</Text>
<Text style={styles.totalLabel}>
{formattedPrice(totalGross)}{isSubscription ? ' / mtl.' : ''}
</Text>
</View>
</View>
</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>CASPOS Computerabrechnungssysteme GmbH · Amtsgericht Zweibrücken HRB 12345</Text>
<Text>Dies ist ein automatisch erzeugtes Dokument.</Text>
</View>
</Page>
</Document>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { signIn } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useState, useEffect } from "react";
export function LoginForm({
className,
@@ -24,24 +24,38 @@ export function LoginForm({
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [messageParam, setMessageParam] = useState<string | null>(null);
const [errorParam, setErrorParam] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setMessageParam(params.get('message'));
setErrorParam(params.get('error'));
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const supabase = createClient();
setIsLoading(true);
setError(null);
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
// Update this route to redirect to an authenticated route. The user already has an active session.
router.push("/protected");
const res = await signIn(email, password);
if (!res.success) {
setError(res.error || "Ein Fehler ist aufgetreten.");
return;
}
if (nextParam) {
router.push(nextParam);
} else if (res.role === "admin") {
router.push("/admin/einstellungen");
} else {
router.push("/my-customers");
}
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
} finally {
setIsLoading(false);
}
@@ -51,20 +65,20 @@ export function LoginForm({
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardTitle className="text-2xl">Anmelden</CardTitle>
<CardDescription>
Enter your email below to login to your account
Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
placeholder="name@beispiel.de"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -72,12 +86,12 @@ export function LoginForm({
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">Passwort</Label>
<Link
href="/auth/forgot-password"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
Passwort vergessen?
</Link>
</div>
<Input
@@ -88,21 +102,22 @@ export function LoginForm({
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Login"}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<Link
href="/auth/sign-up"
className="underline underline-offset-4"
>
Sign up
</Link>
</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>

View File

@@ -1,6 +1,6 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { signOut } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
@@ -8,9 +8,9 @@ export function LogoutButton() {
const router = useRouter();
const logout = async () => {
const supabase = createClient();
await supabase.auth.signOut();
router.push("/auth/login");
await signOut();
router.push("/");
router.refresh();
};
return <Button onClick={logout}>Logout</Button>;

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { signUp } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -29,7 +29,6 @@ export function SignUpForm({
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
const supabase = createClient();
setIsLoading(true);
setError(null);
@@ -40,14 +39,7 @@ export function SignUpForm({
}
try {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/protected`,
},
});
if (error) throw error;
await signUp(email, password);
router.push("/auth/sign-up-success");
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");

View File

@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, checked, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<label className="relative inline-flex items-center cursor-pointer select-none">
<input
type="checkbox"
className="sr-only peer"
checked={checked}
onChange={handleChange}
ref={ref}
{...props}
/>
<div className={cn(
"w-11 h-6 bg-slate-200 rounded-full peer dark:bg-slate-700",
"peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500/50",
"peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white",
"after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600",
"peer-checked:bg-blue-600"
)} />
</label>
);
}
);
Switch.displayName = "Switch";
export { Switch };

View File

@@ -4,11 +4,11 @@ import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
function Table({ className, containerClassName, ...props }: React.ComponentProps<"table"> & { containerClassName?: string }) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
className={cn("relative w-full overflow-x-auto", containerClassName)}
>
<table
data-slot="table"

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { updatePassword } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -20,23 +20,37 @@ export function UpdatePasswordForm({
...props
}: React.ComponentPropsWithoutRef<"div">) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
const supabase = createClient();
setIsLoading(true);
setError(null);
if (password.length < 6) {
setError("Passwort muss mindestens 6 Zeichen lang sein.");
setIsLoading(false);
return;
}
if (password !== confirmPassword) {
setError("Die Passwörter stimmen nicht überein.");
setIsLoading(false);
return;
}
try {
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
// Update this route to redirect to an authenticated route. The user already has an active session.
router.push("/protected");
const res = await updatePassword(password);
if (res.success) {
router.push("/");
} else {
setError(res.error || "Ein Fehler ist aufgetreten");
}
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten");
} finally {
setIsLoading(false);
}
@@ -44,30 +58,43 @@ export function UpdatePasswordForm({
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<Card className="glass-dark border-white/10 text-white shadow-2xl">
<CardHeader>
<CardTitle className="text-2xl">Reset Your Password</CardTitle>
<CardDescription>
Please enter your new password below.
<CardTitle className="text-2xl">Neues Passwort festlegen</CardTitle>
<CardDescription className="text-slate-300">
Bitte geben Sie Ihr neues Passwort ein und bestätigen Sie es.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleForgotPassword}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Label htmlFor="password">Neues Passwort</Label>
<Input
id="password"
type="password"
placeholder="New password"
placeholder="Neues Passwort"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-white/5 border-white/10 text-white"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Passwort bestätigen"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="bg-white/5 border-white/10 text-white"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Saving..." : "Save new password"}
<Button type="submit" className="w-full bg-primary hover:bg-primary/90" disabled={isLoading}>
{isLoading ? "Speichern..." : "Neues Passwort speichern"}
</Button>
</div>
</form>

58
shop/lib/actions/admin.ts Normal file
View File

@@ -0,0 +1,58 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { verifyAdmin } from '@/lib/actions/auth'
import { Client } from 'pg'
export async function getDatabaseIntegrity() {
try {
await verifyAdmin()
const supabase = await createClient()
const { data, error } = await supabase.rpc('check_database_integrity')
if (error) {
return { success: false, error: error.message }
}
return { success: true, data }
} catch (err: any) {
console.error("Error in getDatabaseIntegrity:", err)
return { success: false, error: err.message || "Unerwarteter Fehler beim Integritätsscan." }
}
}
export async function repairDatabaseSchema() {
try {
await verifyAdmin()
const supabase = await createClient()
const { data, error } = await supabase.rpc('repair_database_schema')
if (error) {
return { success: false, error: error.message }
}
return { success: true, data }
} catch (err: any) {
console.error("Error in repairDatabaseSchema:", err)
return { success: false, error: err.message || "Unerwarteter Fehler bei der Schemareparatur." }
}
}
export async function optimizeDatabaseIndices() {
try {
await verifyAdmin()
// REINDEX cannot be run inside a transaction block (such as inside a PL/pgSQL function).
// Therefore, we connect directly using pg client and execute raw query.
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:54322/postgres';
const client = new Client({
connectionString,
connectionTimeoutMillis: 10000,
});
await client.connect();
await client.query('REINDEX SCHEMA public;');
await client.end();
return { success: true }
} catch (err: any) {
console.error("Error in optimizeDatabaseIndices:", err)
return { success: false, error: err.message || "Unerwarteter Fehler bei der Indexoptimierung." }
}
}

216
shop/lib/actions/auth.ts Normal file
View File

@@ -0,0 +1,216 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { headers } from 'next/headers'
import { sendLockoutEmail } from '@/lib/utils/email'
import { sendMail } from '@/utils/mail'
export async function signIn(email: string, password: string) {
try {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
// Track failed attempts
const { data: usr, error: usrError } = await supabase
.from('users')
.select('failed_attempts, email')
.eq('email', email)
.single()
const attempts = (usr?.failed_attempts ?? 0) + 1
// Update attempts count
await supabase
.from('users')
.update({ failed_attempts: attempts })
.eq('email', email)
// Lock account on 5th failure
if (attempts >= 5) {
await supabase
.from('users')
.update({ role: 'gesperrt' })
.eq('email', email)
// Notify admin
await sendLockoutEmail('info@hephex.de')
return { success: false, error: 'Konto gesperrt nach 5 Fehlversuchen.' }
}
return { success: false, error: error.message }
}
const userId = data.user?.id
if (userId) {
const adminClient = createAdminClient()
let { data: userData, error: userError } = await adminClient
.from('users')
.select('role')
.eq('id', userId)
.single()
if (userError || !userData) {
// Fallback: create database records if missing for this authenticated user
try {
const adminSupabase = createAdminClient()
// Ensure profile exists
await adminSupabase.from('profiles').insert([{ id: userId }]).select()
// Ensure user exists
const { data: insertedUser, error: insertError } = await adminSupabase
.from('users')
.insert([{ id: userId, role: 'partner' }])
.select('role')
.single()
if (!insertError && insertedUser) {
userData = insertedUser
userError = null
}
} catch (adminErr) {
console.error("Failed to backfill user records:", adminErr)
}
}
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin')) {
await supabase.auth.signOut()
if (userData?.role === 'gesperrt') {
return { success: false, error: "Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator." }
}
return { success: false, error: "Zugriff verweigert. Nur registrierte Partner dürfen sich anmelden." }
}
// Andere aktive Sitzungen beenden
await supabase.auth.signOut({ scope: 'others' })
return { success: true, role: userData.role }
}
return { success: true, role: 'partner' }
} catch (err: any) {
console.error("SignIn error:", err)
return { success: false, error: err?.message || "Ein unerwarteter Fehler ist aufgetreten." }
}
}
export async function signUp(email: string, password: string) {
throw new Error("Registrierung ist deaktiviert.")
}
export async function signOut() {
const supabase = await createClient()
const { error } = await supabase.auth.signOut()
if (error) {
throw new Error(error.message)
}
return { success: true }
}
function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?: string, buttonUrl?: string) {
return `
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f8fafc; padding: 40px 10px; margin: 0; width: 100%;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
<!-- Header banner with gradient -->
<div style="background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px;">${title}</h1>
</div>
<!-- Content -->
<div style="padding: 40px 32px; background-color: #ffffff;">
${messageHtml}
${buttonText && buttonUrl ? `
<div style="text-align: center; margin: 32px 0;">
<a href="${buttonUrl}" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 15px; display: inline-block; box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2); transition: all 0.2s ease;">
${buttonText}
</a>
</div>
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 32px; border-top: 1px solid #f1f5f9; padding-top: 20px;">
Falls der Button nicht funktioniert, kopieren Sie bitte diesen Link in Ihren Browser:<br>
<a href="${buttonUrl}" style="color: #2563eb; text-decoration: none; word-break: break-all;">${buttonUrl}</a>
</p>
` : ''}
<hr style="border: 0; border-top: 1px solid #f1f5f9; margin: 32px 0;">
<p style="color: #94a3b8; font-size: 11px; text-align: center; margin: 0; line-height: 1.6;">
Dies ist eine automatisch generierte E-Mail.<br>
© ${new Date().getFullYear()} CASPOS Shop. Alle Rechte vorbehalten.
</p>
</div>
</div>
</div>
`;
}
export async function resetPassword(email: string) {
try {
const admin = createAdminClient()
const { data, error } = await admin.auth.admin.generateLink({
type: 'recovery',
email,
})
if (error) {
return { success: false, error: error.message }
}
const tokenHash = (data as any)?.properties?.hashed_token;
if (!tokenHash) {
return { success: false, error: 'Wiederherstellungs-Token konnte nicht generiert werden.' }
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
const resetLink = `${siteUrl}/auth/verify-link?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`
const messageHtml = `
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-top: 0;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Sie haben das Zurücksetzen Ihres Passworts angefordert.</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Bitte klicken Sie auf den untenstehenden Button, um ein neues Passwort festzulegen:</p>
`;
await sendMail({
to: email,
subject: 'Passwort zurücksetzen CASPOS Shop',
text: `Hallo,\n\nSie haben das Zurücksetzen Ihres Passworts angefordert.\n\nBitte klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:\n${resetLink}\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
html: getBeautifulEmailHtml('Passwort zurücksetzen', messageHtml, 'Passwort zurücksetzen', resetLink)
})
return { success: true }
} catch (err: any) {
console.error('Password reset error:', err)
return { success: false, error: err.message || 'Fehler beim Senden der Passwort-Zurücksetzen-E-Mail.' }
}
}
export async function updatePassword(password: string) {
try {
const supabase = await createClient()
const { error } = await supabase.auth.updateUser({ password })
if (error) {
return { success: false, error: error.message }
}
return { success: true }
} catch (err: any) {
console.error('Update password error:', err)
return { success: false, error: err.message || 'Fehler beim Aktualisieren des Passworts.' }
}
}
export async function verifyAdmin() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error("Nicht authentifiziert.")
const { data: userData, error } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (error || !userData || userData.role !== 'admin') {
throw new Error("Zugriff verweigert. Nur Administratoren erlaubt.")
}
return user
}

View File

@@ -0,0 +1,221 @@
'use server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath, unstable_noStore as noStore } from 'next/cache'
export async function getCompanies() {
noStore()
const admin = createAdminClient()
const { data, error } = await admin.from('companies').select('*')
if (error) throw error
return data
}
export async function createCompany(data: { name: string; street?: string; zip?: string; city?: string; email?: string }) {
const admin = createAdminClient()
const { data: dbData, error } = await admin.from('companies').insert({
name: data.name,
street: data.street || null,
zip: data.zip || null,
city: data.city || null,
email: data.email || null,
}).select().single()
if (error) throw error
revalidatePath('/admin/companies')
revalidatePath('/admin/users')
return dbData
}
export async function updateCompany(id: string, data: { name: string; street?: string; zip?: string; city?: string; email?: string }) {
const admin = createAdminClient()
const { data: dbData, error } = await admin.from('companies').update({
name: data.name,
street: data.street || null,
zip: data.zip || null,
city: data.city || null,
email: data.email || null,
}).eq('id', id).select().single()
if (error) throw error
revalidatePath('/admin/companies')
revalidatePath('/admin/users')
return dbData
}
export async function deleteCompany(id: string) {
const admin = createAdminClient()
const { error } = await admin.from('companies').delete().eq('id', id)
if (error) throw error
revalidatePath('/admin/companies')
revalidatePath('/admin/users')
return true
}
export async function assignCompanyToUser(userId: string, companyId: string | null) {
const admin = createAdminClient()
const { error } = await admin.from('users').update({ company_id: companyId }).eq('id', userId)
if (error) throw error
revalidatePath('/admin/users')
return true
}
export async function getEndCustomersByCompany(companyId: string) {
const admin = createAdminClient()
const { data: customers, error: customersError } = await admin
.from('end_customers')
.select('*')
.eq('partner_id', companyId)
if (customersError) throw customersError
return customers || []
}
export async function getCompanyDetails(companyId: string) {
const admin = createAdminClient()
const { data, error } = await admin.from('companies').select('*').eq('id', companyId).single()
if (error) throw error
return data
}
export async function getEndCustomersByCompanyWithPartners(companyId: string) {
const admin = createAdminClient()
const { data: customers, error: customersError } = await admin
.from('end_customers')
.select('*')
.eq('partner_id', companyId)
if (customersError) throw customersError
const { data: company } = await admin
.from('companies')
.select('name')
.eq('id', companyId)
.single()
const companyName = company?.name || 'Unbekannt'
return customers.map((c) => ({
...c,
partner_name: companyName,
}))
}
export async function getCompanyPartners(companyId: string) {
const admin = createAdminClient()
const { data: usersData, error: usersError } = await admin
.from('users')
.select('id, role')
.eq('company_id', companyId)
if (usersError) throw usersError
if (!usersData || usersData.length === 0) return []
const userIds = usersData.map(u => u.id)
const { data: profiles, error: profilesError } = await admin
.from('profiles')
.select('id, first_name, last_name')
.in('id', userIds)
if (profilesError) throw profilesError
const { data: { users }, error: authError } = await admin.auth.admin.listUsers()
const emailMap = new Map((users || []).map(u => [u.id, u.email || '']))
const profileMap = new Map((profiles || []).map(p => [p.id, p]))
return usersData.map(u => {
const prof = profileMap.get(u.id)
return {
id: u.id,
email: emailMap.get(u.id) || '',
name: prof ? `${prof.first_name || ''} ${prof.last_name || ''}`.trim() || emailMap.get(u.id) || 'Unbekannt' : emailMap.get(u.id) || 'Unbekannt',
}
})
}
export async function adminCreateEndCustomer(data: {
partner_id: string
company_name: string
vat_id?: string
first_name?: string
last_name?: string
street?: string
zip?: string
city?: string
email?: string
bank_iban?: string
bank_bic?: string
bank_name?: string
bank_owner?: string
}) {
const admin = createAdminClient()
const { data: dbData, error } = await admin
.from('end_customers')
.insert([{
partner_id: data.partner_id,
company_name: data.company_name,
vat_id: data.vat_id || null,
first_name: data.first_name || null,
last_name: data.last_name || null,
street: data.street || null,
zip: data.zip || null,
city: data.city || null,
email: data.email || null,
bank_iban: data.bank_iban || null,
bank_bic: data.bank_bic || null,
bank_name: data.bank_name || null,
bank_owner: data.bank_owner || null,
}])
.select()
.single()
if (error) throw error
return dbData
}
export async function adminUpdateEndCustomer(id: string, data: {
partner_id: string
company_name: string
vat_id?: string
first_name?: string
last_name?: string
street?: string
zip?: string
city?: string
email?: string
bank_iban?: string
bank_bic?: string
bank_name?: string
bank_owner?: string
}) {
const admin = createAdminClient()
const { data: dbData, error } = await admin
.from('end_customers')
.update({
partner_id: data.partner_id,
company_name: data.company_name,
vat_id: data.vat_id || null,
first_name: data.first_name || null,
last_name: data.last_name || null,
street: data.street || null,
zip: data.zip || null,
city: data.city || null,
email: data.email || null,
bank_iban: data.bank_iban || null,
bank_bic: data.bank_bic || null,
bank_name: data.bank_name || null,
bank_owner: data.bank_owner || null,
})
.eq('id', id)
.select()
.single()
if (error) throw error
return dbData
}
export async function adminDeleteEndCustomer(id: string) {
const admin = createAdminClient()
const { error } = await admin
.from('end_customers')
.delete()
.eq('id', id)
if (error) throw error
return true
}

View File

@@ -0,0 +1,82 @@
interface EmailDetails {
orderNumber: string
formattedDate: string
customerCompanyName: string
totalDetailsText: string
totalDetailsHtml: 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.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team`
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">${title}</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${intro}</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Details</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
<td style="padding: 4px 0;">${details.orderNumber}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
<td style="padding: 4px 0;">${details.formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td>
<td style="padding: 4px 0;">${details.customerCompanyName}</td>
</tr>
${details.totalDetailsHtml}
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
</div>
`
return { text, html }
}
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 }
}

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