Compare commits

215 Commits

Author SHA1 Message Date
DanielS
05cf32591f style(wizard): unify and minimize top spacing across steps 1-4
All checks were successful
Staging Build / build (push) Successful in 3m8s
2026-09-08 00:20:46 +02:00
DanielS
e78fe7c4b8 feat(email): implement professional responsive b2b html email template
All checks were successful
Staging Build / build (push) Successful in 3m9s
2026-09-08 00:09:16 +02:00
DanielS
ec11ad8498 chore: remove all unicode emojis project-wide and sanitize pdf text 2026-09-08 00:03:27 +02:00
DanielS
40b63b3fa9 feat(customers): add pagination with 5 items per page 2026-09-07 23:34:41 +02:00
DanielS
facf481fdb style(orders): harmonize device cards layout in my-orders 2026-09-07 23:31:05 +02:00
DanielS
5f29bb68a4 feat(wizard): fix step 3 layout scrolling and add step 4 confirmation modal 2026-09-07 23:15:21 +02:00
DanielS
404e7cd314 fix(wizard): make last license date optional in step 2 2026-09-07 23:01:36 +02:00
DanielS
2fc4e50ae1 refactor(wizard): compress step 1 and step 2 layout
All checks were successful
Staging Build / build (push) Successful in 3m40s
2026-09-07 22:55:16 +02:00
DanielS
694e8dfbc1 refactor(wizard): optimize ui ux with no-page-scroll and hub flow
All checks were successful
Staging Build / build (push) Successful in 3m16s
2026-09-07 22:26:13 +02:00
DanielS
2a1897141e chore(branding): update default developer footer text 2026-09-07 21:47:21 +02:00
DanielS
2b1bb77673 feat(branding): add CASPOS Classic Blue preset
All checks were successful
Staging Build / build (push) Successful in 3m8s
2026-08-27 00:39:07 +02:00
DanielS
b692e6d4cf Merge branch 'staging' of https://gitea.hephex.de/admin/webshop into staging
# Conflicts:
#	shop/lib/actions/email-templates.ts
2026-08-27 00:34:39 +02:00
DanielS
0c99da2eeb feat(email): add primaryColor branding param to email templates 2026-08-27 00:32:18 +02:00
DanielS
f68d67de43 feat(wizard): add showValidationWarning prop to summary sidebar 2026-08-27 00:32:09 +02:00
DanielS
073dc99860 chore(auth): add 2FA database logging
Some checks failed
Staging Build / build (push) Has been cancelled
- capture and log insert errors during 2FA code generation

- print verification queries and lookup parameters to console
2026-08-20 17:44:30 +02:00
DanielS
af501d18c7 chore(git): resolve merge conflicts
Some checks failed
Staging Build / build (push) Has been cancelled
2026-08-20 17:39:37 +02:00
DanielS
49a7a0768d feat(admin): update B2B PDF, mail and admin table
- format PDF order number as AE- and add upgrade flags

- style status emails with sleek modern dark theme

- add badge counts to admin table status filter buttons

- implement preiskorrektur inline editing inside admin detail modal

- trigger DB and total price recalculations on snapshot changes
2026-08-20 17:16:40 +02:00
DanielS
e0d9c1d20e fix(wizard): fix UI/UX and usability glitches
- constrain viewport height on wizard routes in HeaderWrapper

- remove nested scrollbar in step-software

- add active module glows and tooltips for locked licenses

- show device name badge in step 3 card header when editing

- update main action button text in summary sidebar when editing

- implement double-click inline delete confirmation in step-summary

- animate customer accordion and add tab-index key bindings
2026-08-20 17:16:25 +02:00
DanielS
5ea0719225 feat(admin): restructure settings into subpages and hub overview
All checks were successful
Staging Build / build (push) Successful in 3m3s
2026-08-18 22:55:20 +02:00
DanielS
3545b77803 fix(wizard): prevent duplicate devices on summary step 2026-08-18 22:55:15 +02:00
DanielS
fff40a68fe fix(admin): group order items by device in modal 2026-08-18 22:55:10 +02:00
DanielS
ea1906f9e4 feat(wizard): add eye-catcher shake animation for missing required categories
All checks were successful
Staging Build / build (push) Successful in 3m41s
2026-08-18 16:43:22 +02:00
DanielS
9dbbbb8b31 fix(wizard): remove duplicate addToBasket invocation in summary sidebar 2026-08-18 16:43:17 +02:00
DanielS
409466af9b fix(wizard): prevent duplicate register item addition on step transition
All checks were successful
Staging Build / build (push) Successful in 3m4s
2026-08-18 16:26:39 +02:00
DanielS
8f3d6e5569 fix(auth): redirect to home instead of admin panel after login 2026-08-18 16:26:35 +02:00
DanielS
8588eda3c9 fix(api): improve error handling and auth check in smtp settings route 2026-08-18 16:26:29 +02:00
DanielS
8a4dbbaf14 feat(orders): format device container cards with register name and license number 2026-08-18 16:16:07 +02:00
DanielS
037e6d749a fix(admin): improve address hover contrast in company tables 2026-08-18 16:16:01 +02:00
DanielS
1c8de6e895 feat(backup): add branding_settings to db backup route 2026-08-18 16:15:57 +02:00
DanielS
4296a9106f feat(orders): enhance admin orders view and approval workflow 2026-08-18 16:15:50 +02:00
DanielS
c27cb858c1 ci(staging): update deployment script comments
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-08-18 15:45:09 +02:00
DanielS
a7816ba3c4 fix(navbar): fix profile dropdown hover gap
Some checks failed
Staging Build / build (push) Failing after 3m54s
2026-08-18 15:30:29 +02:00
DanielS
03087f4fbb feat(theme): configure colors via global css variables 2026-08-18 15:30:24 +02:00
DanielS
f6760d30a4 chore(agents): add rocky agent configuration and skills 2026-08-18 15:30:19 +02:00
DanielS
682863ab26 style(globals): add color-mix transparency rules
All checks were successful
Staging Build / build (push) Successful in 3m13s
2026-08-14 16:04:43 +02:00
DanielS
9ecdc645e8 feat(branding): add global dynamic theme system and fix 2fa mail 2026-08-14 15:57:29 +02:00
DanielS
009b167cbd fix(pdf): fix style property justifyContent in InvoicePDF component
All checks were successful
Staging Build / build (push) Successful in 3m8s
2026-08-11 23:41:00 +02:00
DanielS
c1efc7916f feat(email/pdf): include partner company name, partner user name and email in emails and PDF confirmations 2026-08-11 23:40:37 +02:00
DanielS
d066316b67 fix(wizard): destructure initialLastLicenseDate in OrderWizard props 2026-08-11 23:34:56 +02:00
DanielS
b2a5f7cc64 feat(customers): add customer-level and device-level update/upgrade buttons with automatic license date calculation for update discount 2026-08-11 23:34:35 +02:00
DanielS
9bde60553e fix(types): add end_customer and notes properties to Order and OrderSnapshot types 2026-08-11 23:30:34 +02:00
DanielS
16ca9611d7 style(my-orders): refine order view with end-customer badges, structured device cards, notes and smart search 2026-08-11 23:30:05 +02:00
DanielS
5561dfa708 fix(types): add optional license_number to OrderItem type definition 2026-08-11 23:27:38 +02:00
DanielS
447f0a407c feat(my-orders): add full-text search, pagination (10 per page), and show all statuses including completed 2026-08-11 23:27:14 +02:00
DanielS
386527a35c feat(wizard): allow deleting devices in step 4 summary and redirect home when empty 2026-08-11 23:20:55 +02:00
DanielS
982897a808 fix(wizard): clear basket items and configuration states after order completion 2026-08-11 23:10:47 +02:00
DanielS
2f32d80e9e fix(wizard): reset active category to first category when entering step 3
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-08-11 23:01:50 +02:00
DanielS
4598233cf4 feat(wizard): streamline step 3 with inline inputs and list/edit all devices in step 4 2026-08-11 23:01:04 +02:00
DanielS
edb5873e33 feat(wizard): add confirmation dialog when clicking next with unsaved cash register selection 2026-08-11 22:56:01 +02:00
DanielS
9593cbdb79 style(wizard): minimize gap between basket items and action buttons in summary sidebar
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-08-11 22:46:13 +02:00
DanielS
0404a1dd83 feat(wizard): allow direct basket item switching in step 3 software configuration 2026-08-11 22:44:33 +02:00
DanielS
6eeb56d62a style(wizard): remove duplicate status stepper from step 4 2026-08-11 22:40:10 +02:00
DanielS
17e5b27dfc style(wizard): reduce top padding and header spacing in step 1 and step 3 2026-08-11 22:39:05 +02:00
DanielS
2f2cba1687 style(wizard): remove header and refine cart sidebar UI/UX
All checks were successful
Staging Build / build (push) Successful in 2m52s
2026-08-11 22:30:04 +02:00
DanielS
6748a3ffad fix(wizard): connect direction state to step slide animations 2026-08-11 22:28:28 +02:00
DanielS
18228fa8b4 feat(wizard): add vertical progress stepper with step click navigation 2026-08-11 22:28:01 +02:00
DanielS
2750898524 fix(wizard): remove invalid orientation prop from ProgressStepper
All checks were successful
Staging Build / build (push) Successful in 3m0s
2026-08-11 22:20:02 +02:00
DanielS
f40268992d merge: resolve wizard conflicts with layout updates and clean fixes
Some checks failed
Staging Build / build (push) Failing after 32s
2026-08-11 22:16:35 +02:00
DanielS
b72e16706e feat(navbar): open dropdown on hover 2026-08-11 22:08:54 +02:00
DanielS
9078b5631a feat(wizard): add directional slide animations 2026-08-11 22:08:47 +02:00
DanielS
1c67cd46bb refactor(wizard): revert basket to direct edit/delete layout 2026-08-11 22:08:40 +02:00
DanielS
5b9a0c609d style: remove text-gradient, use global colors 2026-08-11 22:08:30 +02:00
DanielS
f500a63e97 style(wizard): move categories list under left sidebar status stepper
All checks were successful
Staging Build / build (push) Successful in 2m52s
2026-08-11 17:38:40 +02:00
DanielS
46699e8bf3 style(wizard): refine step 3 UI styling and micro interactions
All checks were successful
Staging Build / build (push) Successful in 3m0s
2026-08-11 17:30:17 +02:00
DanielS
866c5522f2 fix(setup): make SMTP optional and adjust setup check 2026-08-11 17:27:14 +02:00
DanielS
1478c61ea8 feat(wizard): move progress stepper to left sidebar 2026-08-11 17:26:49 +02:00
DanielS
7346b42ff3 feat: add custom 404 error page with animated background and navigation links
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-08-11 01:30:23 +02:00
DanielS
bc02a6d000 refactor(wizard): unify status stepper and improve summary sidebar UX 2026-08-11 01:16:30 +02:00
DanielS
883b607482 feat(auth): add email-based 2FA for unknown devices
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-08-11 01:10:28 +02:00
DanielS
497d95d849 refactor: move device name and license inputs to modal popup
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-08-11 00:55:35 +02:00
DanielS
c3e81ee49e style: remove duplicate right-side stepper in wizard step 3
All checks were successful
Staging Build / build (push) Successful in 3m10s
2026-08-11 00:51:17 +02:00
DanielS
ddc7a03a0b style: display step 3 status stepper horizontally 2026-08-11 00:50:19 +02:00
DanielS
e09c247f43 feat: make admin dashboard KPI cards clickable links 2026-08-11 00:45:58 +02:00
DanielS
4b8dfd5b20 style: align heights of step 1 selection boxes in wizard
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-08-11 00:41:18 +02:00
DanielS
ea64ce9ef0 feat: exclude verwalter from security lockout emails
All checks were successful
Staging Build / build (push) Successful in 3m0s
2026-08-11 00:37:13 +02:00
DanielS
407484f69d feat: allow verwalter role to act as admin in wizard 2026-08-11 00:35:55 +02:00
DanielS
73e046e959 style: limit step 3 sidebar scroll to basket items
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-08-11 00:28:13 +02:00
DanielS
3ba7c91e1f feat: CC admins on orders and include partner info 2026-08-11 00:22:39 +02:00
DanielS
6177c0cde1 feat: send security lockout emails to all admin users 2026-08-11 00:20:07 +02:00
DanielS
696528283a style: restructure step 4 layout into scrollable columns 2026-08-10 23:54:41 +02:00
DanielS
cab64028f6 style: add sidebar step indicator to step 3 2026-08-10 23:50:22 +02:00
DanielS
8b698e42ec style: align selection heights in step 1
All checks were successful
Staging Build / build (push) Successful in 2m58s
2026-08-10 23:47:50 +02:00
DanielS
1c9ae2ec31 style: refine step 3 software select UI
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-08-10 23:38:09 +02:00
DanielS
af261ce923 feat: add unique license number check for devices 2026-08-10 23:37:09 +02:00
DanielS
60b9bd89ef style: clean category status badges in wizard 2026-08-10 23:31:02 +02:00
DanielS
f9cb3c0685 refactor: improve wizard layouts and animations 2026-08-10 23:29:42 +02:00
DanielS
2f63d716f2 style(wizard): make billing option cards flex-height to prevent overflow
All checks were successful
Staging Build / build (push) Successful in 3m0s
2026-08-10 23:04:00 +02:00
DanielS
1aa694a665 fix(wizard): resolve build type errors and syntax in step-billing and step-software
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-08-10 22:57:21 +02:00
DanielS
ed654f542c feat(wizard): optimize step 3 layout and add category navigation
Some checks failed
Staging Build / build (push) Failing after 28s
2026-08-10 22:50:24 +02:00
DanielS
be3b0f615d fix(header) change text color from blue to white 2026-08-10 22:36:36 +02:00
DanielS
169afb6538 fix(wizard): resolve type error on monthly_price
All checks were successful
Staging Build / build (push) Successful in 2m56s
2026-08-06 17:52:07 +02:00
DanielS
b3ae720136 refactor(admin): optimize db tools with bento cockpit
Some checks failed
Staging Build / build (push) Failing after 32s
2026-08-06 17:49:42 +02:00
DanielS
d02703af8b refactor(admin): optimize smtp settings with bento grid 2026-08-06 17:49:37 +02:00
DanielS
abd8f54ef7 refactor(admin): redesign general settings into bento grid 2026-08-06 17:49:33 +02:00
DanielS
c08237aa73 refactor(wizard): redesign step summary layout with step indicator 2026-08-06 17:44:51 +02:00
DanielS
6a95896361 feat(wizard): add order notes field and scroll container 2026-08-06 17:42:05 +02:00
DanielS
b40f8018f7 refactor(wizard): hide legacy license date input 2026-08-06 17:42:01 +02:00
DanielS
ec0405a901 refactor(background): use semantic bg-background class
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-08-06 17:32:30 +02:00
DanielS
83d9b796c6 refactor(wizard): polish stepper and summary sidebar ui 2026-08-06 17:32:25 +02:00
DanielS
a94f883ab6 style(css): add centralized oklch color system 2026-08-06 17:32:21 +02:00
DanielS
ec264e2680 feat(home): integrate motion components in home page
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-08-06 17:21:52 +02:00
DanielS
21e01b1b24 feat(ui): add 3-column workspace central section 2026-08-06 17:21:48 +02:00
DanielS
304d25f202 feat(ui): add animated horizontal process steps 2026-08-06 17:21:42 +02:00
DanielS
a2305319e1 feat(ui): add scroll indicator component 2026-08-06 17:21:36 +02:00
DanielS
70ed890702 feat(ui): add reactive oklch ascii shader background 2026-08-06 17:21:32 +02:00
DanielS
b978a6bc64 feat(auth): add 10-minute inactivity auto-logout
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-07-28 11:50:27 +02:00
DanielS
a43d598f54 chore(merge): integrate origin/staging into local staging
All checks were successful
Staging Build / build (push) Successful in 2m57s
Resolved conflict in customer-accordion-list.tsx by keeping
the per-device flattening (DeviceCard) implementation.
2026-07-23 09:47:37 +02:00
DanielS
0e4495292d feat(my-customers): flatten orders into per-device cards
- split orders by device_name into FlattenedDevice entries
- strip device prefixes from product and module names
- render one DeviceCard per Kasse in the customer accordion
- add targeted upgrade URL with orderId, customer_id, device_id
- wizard starts at step 3 in upgrade mode with locked customer
- mark existing licensed modules as read-only with badge
2026-07-23 09:45:14 +02:00
DanielS
c6190b34a8 fix(shop): display Backoffice for Zusatzleistung group in OrderSuccessPage
All checks were successful
Staging Build / build (push) Successful in 2m51s
2026-07-22 17:36:51 +02:00
DanielS
623dab192b feat(shop): display device_name for items in CustomerAccordionList
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-22 17:36:11 +02:00
DanielS
c5285dc907 feat(shop): list order items individually by device name in orders view
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-07-22 17:32:02 +02:00
DanielS
6cd5eb917c feat(shop): display company address instead of id in partner selection
All checks were successful
Staging Build / build (push) Successful in 2m53s
2026-07-22 17:25:08 +02:00
DanielS
061147701e style(shop): rename linked fee section to Backoffice in StepSummary
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-22 17:23:49 +02:00
DanielS
dfdb5d9024 feat(shop): reset customer on partner change and enforce customer selection
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-22 17:23:19 +02:00
DanielS
66d5d8fc98 fix(shop): map correct orderSnapshot and labels in InvoicePDF and routes
All checks were successful
Staging Build / build (push) Successful in 2m51s
2026-07-22 17:20:16 +02:00
DanielS
f130f24c86 feat(shop): move linked fee configuration to product level
All checks were successful
Staging Build / build (push) Successful in 2m52s
2026-07-22 17:04:38 +02:00
DanielS
06cba405f3 fix(shop): resolve postgrest join ambiguity for product_modules
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-07-22 15:27:38 +02:00
DanielS
b8820f59b7 style(shop): resolve merge conflict in license lookup placeholder
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-07-22 15:12:36 +02:00
DanielS
1c31918768 feat(shop): add dynamic linked fees to modules 2026-07-22 15:09:05 +02:00
DanielS
086a893e19 docs(dashboard): clarify calendar popup info for workshops and partners
All checks were successful
Staging Build / build (push) Successful in 2m50s
Update calendar modal description to specify workshops (online/onsite),
trainings, and Blue Wednesday partner registrations.
2026-07-22 12:54:26 +02:00
DanielS
61adf76da5 feat(dashboard): align quick access buttons in 3 columns with calendar popup
All checks were successful
Staging Build / build (push) Successful in 2m59s
Convert quick access grid to 3 columns and open in-app dialog modal for calendar button.
2026-07-22 12:45:15 +02:00
DanielS
2497063e82 fix(dashboard): import Calendar icon in HomeClient
All checks were successful
Staging Build / build (push) Successful in 2m59s
Add missing Calendar import from lucide-react.
2026-07-22 12:39:53 +02:00
DanielS
0a60526800 feat(dashboard): mark calendar button as planned in quick access
Some checks failed
Staging Build / build (push) Failing after 31s
Add planned status badge to appointment calendar quick access button.
2026-07-22 12:33:04 +02:00
DanielS
fef1ccd0f5 style(wizard): rename CAS references to CASPOS
All checks were successful
Staging Build / build (push) Successful in 2m49s
Replace all occurrences of 'CAS-Lizenz' and related strings
with 'CASPOS' across license-lookup-panel, step-billing,
and order-wizard. Update mock key to 995502-00 (GASTRO edition).
2026-07-22 11:07:30 +02:00
DanielS
3200784c51 style(wizard): remove aligned spacing in license-lookup-panel
All checks were successful
Staging Build / build (push) Successful in 2m57s
Update mock license keys to CASPOS format and normalize
object property alignment to standard single-space style.
2026-07-22 09:13:31 +02:00
DanielS
acd8d6575c chore(wizard): update demo license keys in lookup panel 2026-07-21 11:32:30 +02:00
DanielS
3db3606f17 style(wizard): align partner and customer list color scheme
All checks were successful
Staging Build / build (push) Successful in 2m53s
2026-07-21 11:17:56 +02:00
DanielS
f1877cf893 feat(wizard): unify partner and customer selection list UI
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-07-21 11:07:48 +02:00
DanielS
ac60995c1d feat(wizard): add search filter to admin partner select
All checks were successful
Staging Build / build (push) Successful in 2m56s
2026-07-21 10:58:17 +02:00
DanielS
07208fcad0 feat(wizard): add partner filter and pagination to customer step
All checks were successful
Staging Build / build (push) Successful in 2m52s
2026-07-21 10:43:21 +02:00
DanielS
8b4fd5f02e feat(dashboard): add customer accordion view and extension flow
All checks were successful
Staging Build / build (push) Successful in 3m1s
2026-07-21 08:43:00 +02:00
DanielS
86b63ce77e feat(checkout): implement wizard and admin actions
All checks were successful
Staging Build / build (push) Successful in 3m11s
2026-07-20 23:18:40 +02:00
DanielS
2b372be228 feat(orders): support active status and update RLS
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-20 23:00:02 +02:00
DanielS
13f647409f fix(sync): query customers by vowels to avoid crash
All checks were successful
Staging Build / build (push) Successful in 2m56s
2026-07-15 23:50:13 +02:00
DanielS
5d3eed874c feat(sync): add customer synchronization from lic server
All checks were successful
Staging Build / build (push) Successful in 3m0s
2026-07-15 22:48:10 +02:00
DanielS
3a090b7664 docs: document licserver management api
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-07-15 16:18:30 +02:00
DanielS
e2d92a031f feat(licserver): add partner customers proxy API 2026-07-15 16:14:31 +02:00
DanielS
6b9023c871 feat(wizard): connect license lookup panel to real lic server api endpoint
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-15 01:01:01 +02:00
DanielS
f783a98392 docs: add lic server api spec and index in second brain 2026-07-15 00:58:13 +02:00
DanielS
d5593900fb feat(cron): add automated sync endpoint and vercel cron configuration for partners
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-07-15 00:30:13 +02:00
DanielS
92eb7454dc docs: update database schema documentation in second brain and grand functions 2026-07-15 00:27:03 +02:00
DanielS
cb4787e595 style(admin): replace browser alert and confirm with dialog modals
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-07-15 00:21:38 +02:00
DanielS
3fb80e6404 feat(admin): add manual partner import from lic server
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-15 00:07:31 +02:00
DanielS
a318a5e225 fix(admin): wrap access check in try-catch to prevent loader hanging
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-07-14 23:52:04 +02:00
DanielS
1b49348577 style: simplify home page layout and fix navbar link 2026-07-14 23:44:21 +02:00
DanielS
fb61f5f396 style: remove text gradients and centered badges 2026-07-14 23:42:28 +02:00
DanielS
7119383139 fix(security): harden licserver config access controls
All checks were successful
Staging Build / build (push) Successful in 2m50s
- Add auth guard to getLicServerConfig() - unauthenticated callers
  now receive null config instead of DB values
- Remove non-existent 'superadmin' role from saveLicServerConfig()
  check (only 'admin' exists per project schema)
- New migration: restrict settings table WRITE to admins only
  (previously any authenticated user could overwrite licserver_api_key)
2026-07-14 17:35:12 +02:00
DanielS
fa93e060c2 feat(admin): LicServer config manageable via admin settings UI
Some checks failed
Staging Build / build (push) Has been cancelled
- Migration: add licserver_base_url/api_key cols to settings table
- New: lib/actions/licserver-config.ts (read/write/test server actions)
- Updated: proxy routes read config from DB with env-var fallback
- Updated: admin/einstellungen adds LicServer card with URL + API key
  input, show/hide toggle, Save and Connection Test buttons
2026-07-14 17:30:37 +02:00
DanielS
be4e9204a3 feat(licserver): add partner API proxy routes and types
All checks were successful
Staging Build / build (push) Successful in 2m54s
- New: lib/types/licserver.ts (PartnerDto, paged result, problem)
- New: app/api/licserver/partners/route.ts (GET search/list)
- New: app/api/licserver/partners/[id]/route.ts (GET by UUID)
- Updated: .env.example with LICSERVER_BASE_URL + LICSERVER_API_KEY

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

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

18
.agents/agents.md Normal file
View File

@@ -0,0 +1,18 @@
# Antigravity Agent Configuration (Project Rocky)
## Agent: Rocky_Manager
- **Role**: Hauptkoordinator des Rocky-Projekts.
- **Responsibility**: Verteilt Aufgaben an Spezialisten basierend auf den Skill-Anforderungen.
## Agent: Dokumenten_Spezialist
- **Role**: Verantwortlich für Exporte und Berichte.
- **Assigned_Skills**: [".agents/skills/pdf_skill.md"]
## Agent: Kommunikations_Spezialist
- **Role**: Verantwortlich für Benachrichtigungen und Kundenkontakt.
- **Assigned_Skills**: [".agents/skills/email_skill.md"]
## Agent: Compliance_Supervisor
- **Role**: Prüft den geschriebenen Code auf Datenschutzfehler.
- **Assigned_Skills**: [".agents/skills/dsgvo_skill.md"]
- **Rule**: "Blockiere jeden Git-Commit, wenn der Code gegen die DSGVO-Vorgaben verstößt."

View File

@@ -0,0 +1,6 @@
# Skill: DSGVO & Datenschutz-Compliance (Rocky-Projekt)
## Regeln für Aufgaben:
1. **Daten-Minimierung**: Spezialisten dürfen personenbezogene Daten (wie E-Mail-Adressen oder Klarnamen) niemals in Log-Dateien oder im Git-Verlauf speichern.
2. **Verschlüsselung**: Daten im Rocky-Projekt müssen "at rest" und "in transit" (mindestens TLS 1.3) verschlüsselt werden.
3. **Anonymisierung**: Für Testzwecke im Terminal-Subagent dürfen nur synthetische Dummy-Daten verwendet werden. Keine echten Kundendaten nutzen!

View File

@@ -0,0 +1,6 @@
# Skill: E-Mail-Kommunikation (Rocky-Projekt)
## Regeln für Aufgaben:
1. **Tonalität**: Professionell, höflich und im Corporate Design des Rocky-Projekts.
2. **Sicherheit**: Sende niemals Passwörter, Token oder unverschlüsselte API-Keys per E-Mail.
3. **Fallbacks**: Wenn ein E-Mail-Versand fehlschlägt, schreibe den Fehler sofort in die lokale Log-Datei `rocky_mail_errors.log` und benachrichtige den Manager-Agenten.

View File

@@ -31,7 +31,7 @@ graph TD
- **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-Garantie**: Basis-Produkte werden im UI niemals durch Modulausschlüsse ausgeblendet (Vermeidung von UI-Deadlocks). Stattdessen werden inkompatible Moduloptionen im JSX deaktiviert (`disabled`).
- **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.
@@ -51,26 +51,25 @@ graph TD
- 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`: Zahlungsart `invoice` (Rechnung).
- Typ `subscription`: Zahlungsart `sepa`.
- Friert für jede Order einen konsolidierten `order_snapshot` (JSONB) und `customer_snapshot` (JSONB) ein.
- Triggert die PDF-Rechnungserstellung und den E-Mail-Versand unabhängig für jede generierte Order (gibt Order-IDs für Post-Processing zurück).
- Typ `purchase`: Anfrage für Kauf-Lizenzen (Zahlungsart: Vormerkung/Angebot).
- Typ `subscription`: Anfrage für Software-Abonnement (Zahlungsart: SEPA).
- Friert für jede Order einen konsolidierten `order_data` (JSONB) und `customer_data` (JSONB) ein.
- Triggert die PDF-Erstellung und den E-Mail-Versand unabhängig für jede generierte Order (gibt Order-IDs für Post-Processing zurück).
---
### F. PDF-Rechnungsgenerierung & Mail-Versand (Post-Processing)
- **Snapshot-Exklusivität**: Die Rechnungsgenerierung erfolgt ausschließlich auf Basis der in `orders.order_data` und `orders.customer_data` eingefrorenen Snapshots.
- **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-Rechnung**.
- Generiert eine klassische **Anfragebestätigung Kauf**.
- Weist die einmalige Gesamtsumme aus.
- Beinhaltet ein explizites Zahlungsziel ("Zahlbar innerhalb von 14 Tagen ohne Abzug").
- **Typ `subscription` (Abonnement)**:
- Generiert eine **Vertragsbestätigung (Abonnement)**.
- Generiert eine **Anfragebestätigung Abonnement**.
- Weist monatlich wiederkehrende Kosten aus.
- Beinhaltet das SEPA-Lastschriftmandat inklusive Kontoinhaber und IBAN.
- **Archivierung & Benachrichtigung**:
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
- E-Mail-Versand mit PDF-Anhang an den Besteller.
- E-Mail-Versand mit PDF-Anhang an den Partner.

View File

@@ -28,7 +28,7 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `is_required` (BOOLEAN) <-- *Muss ausgewählt werden*
- `allow_multiple` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
- `allow_multiselect` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
- `sort_order` (INTEGER) <-- *Sortierung im Wizard*
- `show_in_branches` (BOOLEAN)
- `preselect` (BOOLEAN)
@@ -43,9 +43,10 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
- `tax_rate` (DECIMAL)
- `billing_interval` (TEXT: `'one_time'` / `'monthly'`)
- `show_in_branches` (BOOLEAN)
- `allow_update_discount` (BOOLEAN) <-- *Berechtigung für Update-Rabatt*
### `modules` (Zusatzmodule / Erweiterungen)
- Optionale Erweiterungen für Produkte.
- 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`)
@@ -77,8 +78,20 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
- `type` (TEXT: `'purchase'` / `'subscription'`) <-- *Wichtig für den Checkout-Split*
- `payment_method` (TEXT) <-- *Rechnung bei Kauf, SEPA bei Abo*
- `total_price` (DECIMAL)
- `customer_snapshot` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
- `order_snapshot` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
- `customer_data` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
- `order_data` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
- `pdf_url` (TEXT)
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
- `created_at` (TIMESTAMPTZ)
### `settings` (Systemeinstellungen)
- Globale Shopeinstellungen (SMTP und LicServer).
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
- `host` (TEXT)
- `port` (INTEGER)
- `secure` (BOOLEAN)
- `user` (TEXT)
- `pass` (TEXT)
- `licserver_base_url` (TEXT)
- `licserver_api_key` (TEXT)
- `updated_at` (TIMESTAMPTZ)

View File

@@ -13,3 +13,8 @@ RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierun
- 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

@@ -16,6 +16,7 @@ Getestet in [wizard-state.test.ts](file:///c:/source/webshop/shop/lib/wizard-sta
### 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,7 @@
skill: lucide-icons-guide
description: Integration, Auswahl und Optimierung von Lucide React Icons (lucide.dev) für Next.js 15 (RSC/Server Actions) und Tailwind CSS.
rules:
- Importiere Icons direkt aus `lucide-react` (z. B. `import { Package, ShieldCheck, ShoppingCart } from 'lucide-react'`).
- Achte bei React Server Components (RSC) darauf, dass Icons performant gerendert und keine unnötigen Client-Side Bundles erzeugt werden.
- Verwende konsistente Größen (`size={18}` oder Tailwind `w-5 h-5`) und Stile (z. B. `strokeWidth={1.75}`) passend zum B2B-Design.
- Nutze dynamische Icon-Name-Renderings nur über eine typsichere Map/Registry, um Unused-Code-Elimination (Tree Shaking) zu gewährleisten.

View File

@@ -0,0 +1,6 @@
# Skill: PDF-Verarbeitung & Generierung (Rocky-Projekt)
## Regeln für Aufgaben:
1. **Formatierung**: Nutze ausschließlich barrierefreie PDF/A-Formate für Rechnungen und Berichte im Rocky-Projekt.
2. **Metadaten**: Jedes generierte PDF muss automatisch den Projektnamen `Rocky` und einen eindeutigen Zeitstempel im Datei-Header enthalten.
3. **Validierung**: Vor dem Speichern muss der Agent prüfen, ob alle Textfelder innerhalb der Seitenränder liegen (kein Textumbruch-Fehler).

View File

@@ -33,4 +33,9 @@ python3 .agents/skills/uiux-designer/scripts/search.py "<keywords>" --design-sys
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --domain <domain>
### Step 4: Stack Guidelines
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --stack html-tailwind
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --stack html-tailwind
## Anti-AI-Design Guidelines
- **NO Centered Badges/Pills**: Do NOT place centered badges or pills (e.g. "CASPOS Lizenz-Portal" or "Ablauf") above main headings. They look like generic, low-quality AI-generated templates.
- **NO Text Gradients**: Avoid text gradients (e.g. `bg-gradient-to-r ... bg-clip-text text-transparent`) on headings. Use flat colors (like `text-white` or solid brand colors).

View File

@@ -2,3 +2,4 @@
- [Supabase Schema](file:///c:/source/webshop/.brain/supabase-schema.md)
- [ADR Log](file:///c:/source/webshop/.brain/adr-log.md)
- [LicServer API Spezifikation](file:///c:/source/webshop/.brain/licserver-api.md)

227
.brain/licserver-api.md Normal file
View File

@@ -0,0 +1,227 @@
# LicServer API Spezifikation (v1)
Dokumentation der REST-Schnittstellen des CASPOS Lizenzservers.
## 1. Endpunkte (Port 9981 - Activation API)
### Activation (Aktivierung)
#### `POST /v1/activate`
Aktiviert eine neue Lizenz.
**Request Body (`application/json`):**
```json
{
"productId": "string",
"productVersion": "string",
"activationCode": "string",
"hardwareBindingType": "string",
"hardwareBindingId": "string"
}
```
**Response `200 OK` (`application/json`):**
```json
{
"licenseData": {
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
},
"apiKey": "string",
"secret": "string"
}
```
**Response Headers:**
- `ETag` (string): Der ETag-Wert der Lizenz.
---
#### `POST /v1/activate-existing`
Aktiviert eine bereits vorhandene Lizenz erneut.
**Request Body (`application/json`):**
```json
{
"productId": "string",
"productVersion": "string",
"serialNumber": "string",
"proofOfPossessionHash": "string",
"salt": "string",
"hardwareBindingType": "string",
"hardwareBindingId": "string"
}
```
**Response `200 OK` (`application/json`):**
*Gleiche Struktur wie bei `/v1/activate`.*
---
### Licenses (Lizenzen)
#### `GET /v1/licenses/{id}`
Holt die Lizenz-Details zu einer bestimmten Lizenz-ID.
**Request Parameters:**
- `id` (path, string, required): Die UUID der Lizenz.
- `If-None-Match` (header, string, optional): ETag zur Cache-Validierung.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
}
```
**Response `304 Not Modified`:**
Falls die Lizenz nicht verändert wurde.
**Response `404 Not Found` / `403 Forbidden`:**
Standard Fehlerobjekt `ProblemDetails`.
---
## 2. Endpunkte (Port 9980 - Management API)
Alle Anfragen an die Management-API müssen authentifiziert sein.
- **Header-Format:** `X-Api-Key: <Schlüssel>`
### Partners (Partner)
#### `GET /api-v1/partners`
Gibt eine paginierte Liste aller Partner zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{id}`
Holt Partner-Details zu einer bestimmten ID.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
```
### Customers (Kunden von Partnern)
#### `GET /api-v1/partners/{partnerId}/customers`
Gibt eine paginierte Liste aller Kunden eines bestimmten Partners zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{partnerId}/customers/{customerId}`
Holt detaillierte Kundeninformationen.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string",
"partnerName": "string",
"taxNumber": "string",
"vatIdNumber": "string",
"notes": "string",
"locations": []
}
```
---
## 3. Typen & Schemas
### `LicenseData`
```json
{
"id": "string",
"serialNumber": "string",
"productId": "string",
"filename": "string",
"content": "string" // Base64 Byte-Inhalt
}
```
### `ProblemDetails`
```json
{
"type": "string",
"title": "string",
"status": 0,
"detail": "string",
"instance": "string"
}
```

View File

@@ -46,3 +46,19 @@
- `order_data` (JSONB)
- `pdf_url` (TEXT)
- `status` (TEXT)
## `categories` (Kategorien)
- Steuert das Layout und Verhalten im Wizard.
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `is_required` (BOOLEAN)
- `allow_multiselect` (BOOLEAN)
- `sort_order` (INTEGER)
- `show_in_branches` (BOOLEAN)
## `settings` (Systemeinstellungen)
- Globale Shopeinstellungen (SMTP & Lizenzserver).
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
- `host`, `port`, `secure`, `user`, `pass` (SMTP-Konfiguration)
- `licserver_base_url`, `licserver_api_key` (CASPOS Lizenzserver-Einstellungen)
- `updated_at` (TIMESTAMPTZ)

View File

@@ -50,7 +50,6 @@ jobs:
docker builder prune -f
working-directory: .
# KORREKTUR: IP-Adresse exakt auf Ihren Proxmox-Staging-Container eingestellt
- name: Deploy to Proxmox LXC via SSH
uses: appleboy/ssh-action@v0.1.5
@@ -60,6 +59,7 @@ jobs:
key: ${{ secrets.PROXMOX_SSH_KEY }}
script: |
cd /opt/webshop/supabase/supabase-project
# 1. Altes Image durch das neue ersetzen
docker compose pull webshop
# 2. Container im Hintergrund neu starten
docker compose up -d webshop
docker image prune -f

249
AsciiShaderBackground.astro Normal file
View File

@@ -0,0 +1,249 @@
---
// AsciiShaderBackground.astro
interface Props {
className?: string;
}
const { className = "" } = Astro.props;
---
<div class={`ascii-shader-container ${className}`}>
<canvas id="ascii-shader-canvas"></canvas>
<div class="grid-overlay"></div>
</div>
<style>
.ascii-shader-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
overflow: hidden;
background-color: oklch(0.145 0.01 148);
pointer-events: none;
}
#ascii-shader-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
will-change: transform;
pointer-events: none;
}
.grid-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 40px 40px;
background-image:
linear-gradient(to right, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px),
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px);
pointer-events: none;
}
</style>
<script>
// OKLCH Palette definition
interface OKLCHColor {
l: number;
c: number;
h: number;
}
// Palette defining color stops based on scroll progress
const PALETTE = {
yellow: { l: 0.88, c: 0.18, h: 96 },
green: { l: 0.76, c: 0.15, h: 155 },
blue: { l: 0.68, c: 0.18, h: 255 },
purple: { l: 0.64, c: 0.18, h: 302 },
pink: { l: 0.72, c: 0.20, h: 345 }
};
// Helper for shortest angle interpolation in OKLCH hue space
function interpolateHue(h1: number, h2: number, t: number): number {
let diff = (h2 - h1) % 360;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
return (h1 + diff * t + 360) % 360;
}
// Interpolate two OKLCH colors
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
return {
l: c1.l + (c2.l - c1.l) * t,
c: c1.c + (c2.c - c1.c) * t,
h: interpolateHue(c1.h, c2.h, t)
};
}
// Calculate dynamic colors based on scroll progress (0.0 to 1.0)
function getScrollColors(progress: number): { primary: OKLCHColor; secondary: OKLCHColor } {
if (progress < 0.33) {
// Hero to stage 1: Full spectrum (Pink -> Yellow)
const t = progress / 0.33;
return {
primary: lerpOKLCH(PALETTE.pink, PALETTE.yellow, t),
secondary: lerpOKLCH(PALETTE.purple, PALETTE.green, t)
};
} else if (progress < 0.66) {
// Stage 1 to stage 2: Yellow/Cream to Green
const t = (progress - 0.33) / 0.33;
return {
primary: lerpOKLCH(PALETTE.yellow, PALETTE.green, t),
secondary: lerpOKLCH(PALETTE.green, PALETTE.blue, t)
};
} else {
// Stage 2 to end: Green/Blue focus
const t = (progress - 0.66) / 0.34;
return {
primary: lerpOKLCH(PALETTE.green, PALETTE.blue, t),
secondary: lerpOKLCH(PALETTE.blue, PALETTE.purple, t)
};
}
}
class AsciiShaderEngine {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private mouse = { x: 0, y: 0, targetX: 0, targetY: 0 };
private scrollProgress = 0;
private targetScrollProgress = 0;
private chars = ['/', '\\', '-', '|', '+', '='];
private fontSize = 16;
private cols = 0;
private rows = 0;
private animationFrameId = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const context = canvas.getContext('2d');
if (!context) throw new Error('Could not get 2D context');
this.ctx = context;
this.init();
}
private init(): void {
this.resize();
this.bindEvents();
this.render();
}
private resize = (): void => {
const width = window.innerWidth;
const height = window.innerHeight;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.ctx.scale(dpr, dpr);
this.cols = Math.ceil(width / this.fontSize);
this.rows = Math.ceil(height / this.fontSize);
if (this.mouse.x === 0 && this.mouse.y === 0) {
this.mouse.x = width / 2;
this.mouse.y = height / 2;
this.mouse.targetX = width / 2;
this.mouse.targetY = height / 2;
}
};
private bindEvents(): void {
window.addEventListener('resize', this.resize);
window.addEventListener('pointermove', (e: PointerEvent) => {
this.mouse.targetX = e.clientX;
this.mouse.targetY = e.clientY;
});
window.addEventListener('scroll', () => {
const maxScroll = Math.max(
document.body.scrollHeight - window.innerHeight,
1
);
this.targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1);
}, { passive: true });
}
private render = (time: number = 0): void => {
// Dampened mouse easing (lerp factor 0.05)
this.mouse.x += (this.mouse.targetX - this.mouse.x) * 0.05;
this.mouse.y += (this.mouse.targetY - this.mouse.y) * 0.05;
// Dampened scroll progress easing
this.scrollProgress += (this.targetScrollProgress - this.scrollProgress) * 0.05;
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Get current OKLCH interpolated colors
const { primary, secondary } = getScrollColors(this.scrollProgress);
this.ctx.font = `${this.fontSize}px monospace`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
const timeSec = time * 0.001;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const x = c * this.fontSize + this.fontSize / 2;
const y = r * this.fontSize + this.fontSize / 2;
// Vector field calculations based on distance to cursor & wave function
const dx = x - this.mouse.x;
const dy = y - this.mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Dynamic angle field
const angle = Math.atan2(dy, dx) + Math.sin(dist * 0.01 - timeSec) * 0.5;
const charIndex = Math.floor(Math.abs(Math.sin(angle + dist * 0.005)) * this.chars.length) % this.chars.length;
const char = this.chars[charIndex];
// Color blend based on vector field intensity
const blendFactor = Math.min(Math.max(1 - dist / 400, 0), 1);
const currentColor = lerpOKLCH(secondary, primary, blendFactor);
// Render character with OKLCH styling
this.ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)})`;
this.ctx.fillText(char, x, y);
}
}
this.animationFrameId = requestAnimationFrame(this.render);
};
public destroy(): void {
window.removeEventListener('resize', this.resize);
cancelAnimationFrame(this.animationFrameId);
}
}
// Lifecycle handler for Astro / SPA transitions
function initShader() {
const canvas = document.getElementById('ascii-shader-canvas') as HTMLCanvasElement;
if (canvas) {
new AsciiShaderEngine(canvas);
}
}
// Initial load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initShader);
} else {
initShader();
}
// Astro page swap support
document.addEventListener('astro:page-load', initShader);
</script>

View File

@@ -2,3 +2,7 @@
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
# CASPOS LicServer (internal container network — server-side only, never expose to browser)
LICSERVER_BASE_URL=http://192.168.178.174:9980
LICSERVER_API_KEY=your-licserver-api-key

View File

@@ -4,6 +4,11 @@ import { createClient } from '@/lib/supabase/server';
import { createHash } from 'crypto';
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
import { getProducts, getCategories } from '@/lib/actions/products';
import { sendMail } from '@/utils/mail';
import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf';
import { generateOrderEmailHtml, generateOrderEmailSubject } from '@/lib/actions/email-templates';
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
const year = new Date().getFullYear();
@@ -16,12 +21,171 @@ function hashOrderPayload(endCustomerId: string, items: any[]): string {
return createHash('sha256').update(str).digest('hex');
}
// Asynchroner Hintergrund-Worker für Post-Processing (PDF-Erstellung und E-Mail)
async function triggerPostProcessing(orderId: string, supabase: any, customerSnapshot: any, orderSnapshot: any, type: string, email: string) {
try {
const { data: order, error } = await supabase
.from('orders')
.select('*')
.eq('id', orderId)
.single();
if (error || !order) {
console.error(`Post-Processing failed: Order ${orderId} not found`);
return;
}
// PDF generieren
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order,
customer: customerSnapshot,
orderSnapshot,
})
);
const fileName = `ab_${order.id}.pdf`;
const { error: uploadError } = await supabase
.storage
.from('invoices')
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
if (!uploadError) {
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
} else {
console.error(`PDF Upload failed for order ${orderId}:`, uploadError);
}
// E-Mail versenden
if (email) {
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
const taxRate = orderSnapshot.tax_rate ?? 19;
const items = orderSnapshot.items ?? [];
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
let totalDetailsText = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
} else if (oneTimeNet > 0) {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
} else {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
}
let totalDetailsHtml = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsHtml = `
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
} else if (oneTimeNet > 0) {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
`;
} else {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
}
const emailTemplate = generateOrderEmailHtml({
orderNumber: order.order_number,
status: 'pending',
formattedDate,
customerCompanyName: customerSnapshot.company_name,
items,
taxRate,
oneTimeNet,
monthlyNet,
});
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
await sendMail({
to: email,
subject: mailSubject,
text: emailTemplate.text,
html: emailTemplate.html,
attachments: [
{
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
content: buffer,
contentType: 'application/pdf',
}
]
});
}
} catch (err) {
console.error(`Error in post-processing for order ${orderId}:`, err);
}
}
export async function checkoutAction(params: {
items: any[];
endCustomerId: string;
customerProfile?: any;
lastLicenseDate?: string | null;
}) {
const { items, endCustomerId, customerProfile } = params;
const { items, endCustomerId, customerProfile, lastLicenseDate } = params;
const supabase = await createClient();
// 1. Session & Auth Check
@@ -36,7 +200,7 @@ export async function checkoutAction(params: {
.eq('id', user.id)
.single();
const isAdminUser = dbUser?.role === 'admin';
const isAdminUser = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung';
if (!isAdminUser && !dbUser?.company_id) {
throw new Error('Kein Unternehmen zugewiesen. Zugriff verweigert.');
}
@@ -94,9 +258,15 @@ export async function checkoutAction(params: {
products,
categories,
type === 'purchase' ? 'one_time' : 'monthly',
item.moduleQuantities
item.moduleQuantities,
type === 'purchase' ? lastLicenseDate : null
);
orderItemsList.push(...itemSnapshot.items);
const itemsWithDevice = itemSnapshot.items.map(i => ({
...i,
device_name: item.deviceName,
license_number: item.licenseNumber
}));
orderItemsList.push(...itemsWithDevice);
total += itemSnapshot.total;
}
@@ -107,7 +277,8 @@ export async function checkoutAction(params: {
total: total,
tax_rate: 19,
tax_amount: Math.round(total * 0.19 * 100) / 100,
subtotal: Math.round((total / 1.19) * 100) / 100
subtotal: Math.round((total / 1.19) * 100) / 100,
last_license_date: type === 'purchase' ? lastLicenseDate : null
};
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
@@ -132,6 +303,13 @@ export async function checkoutAction(params: {
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');

View File

@@ -9,7 +9,7 @@ export default async function AdminCategoriesPage() {
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<LayoutGrid className="w-8 h-8 text-primary" />
Kategorien
</h2>

View File

@@ -245,32 +245,32 @@ export default function CompanyCustomersPage() {
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
{filteredCustomers.map((customer) => (
<tr key={customer.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
<tr key={customer.id} className="hover:bg-slate-100 dark:hover:bg-white/10 transition-colors group">
<td className="py-4">
<div className="font-semibold text-slate-900 dark:text-white">
{customer.company_name}
</div>
{(customer.first_name || customer.last_name) && (
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 mt-0.5">
<User className="w-3 h-3 text-slate-400 shrink-0" />
<div className="text-xs text-slate-600 dark:text-slate-300 font-medium flex items-center gap-1 mt-0.5">
<User className="w-3 h-3 text-indigo-500 dark:text-indigo-400 shrink-0" />
<span>{customer.first_name || ''} {customer.last_name || ''}</span>
</div>
)}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
{customer.street ? (
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<MapPin className="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-400 shrink-0" />
<span>
{customer.street}, {customer.zip} {customer.city}
</span>
</div>
) : (
<span className="text-slate-400 dark:text-slate-600"></span>
<span className="text-slate-400 dark:text-slate-500"></span>
)}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
{customer.email || <span className="text-slate-400 dark:text-slate-600"></span>}
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
{customer.email || <span className="text-slate-400 dark:text-slate-500"></span>}
</td>
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
{customer.vat_id || <span className="text-slate-400 dark:text-slate-600"></span>}

View File

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

View File

@@ -0,0 +1,124 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Loader2, Palette, Save } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
import type { BrandingSettings } from '@/lib/constants/branding';
import { ColorThemePicker } from '@/components/admin/ColorThemePicker';
import { useTheme } from '@/components/ThemeProvider';
import { useRouter } from 'next/navigation';
export default function BrandingPage() {
const [loading, setLoading] = useState(true);
const [branding, setBranding] = useState<BrandingSettings>({
companyName: '',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
});
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
const { refreshBranding } = useTheme();
const router = useRouter();
useEffect(() => {
async function loadData() {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const brandRes = await getBrandingSettings();
if (brandRes) setBranding(brandRes);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
loadData();
}, [router]);
const handleSave = async () => {
setSaving(true);
setMsg('');
try {
const res = await saveBrandingSettings(branding);
if (res.success) {
await refreshBranding();
setMsgType('success');
setMsg('Farbschema erfolgreich gespeichert.');
} else {
setMsgType('error');
setMsg(res.error || 'Fehler beim Speichern.');
}
} catch (err: any) {
setMsgType('error');
setMsg(err.message || 'Fehler beim Speichern.');
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
<Palette className="w-8 h-8 text-violet-400" />
Branding & Design
</h1>
<p className="text-slate-400 text-xs mt-1">
Wählen Sie das primäre Farbschema und Akzente für den gesamten Webshop.
</p>
</div>
<Button onClick={handleSave} disabled={saving} className="bg-violet-600 hover:bg-violet-500 text-white rounded-xl px-5 h-10 text-xs font-bold">
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
Farben Speichern
</Button>
</div>
{msg && (
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
msgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{msg}
</div>
)}
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
<ColorThemePicker
colorScheme={branding.colorScheme}
primaryColor={branding.primaryColor}
accentColor={branding.accentColor}
companyName={branding.companyName}
onChange={(scheme, primary, accent) =>
setBranding({
...branding,
colorScheme: scheme,
primaryColor: primary,
accentColor: accent,
})
}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, Building2, MapPin, Receipt, Save } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
import type { BrandingSettings } from '@/lib/constants/branding';
import { useRouter } from 'next/navigation';
export default function FirmendatenPage() {
const [loading, setLoading] = useState(true);
const [branding, setBranding] = useState<BrandingSettings>({
companyName: '',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
});
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
const router = useRouter();
useEffect(() => {
async function loadData() {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const brandRes = await getBrandingSettings();
if (brandRes) setBranding(brandRes);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
loadData();
}, [router]);
const handleSave = async () => {
setSaving(true);
setMsg('');
try {
const res = await saveBrandingSettings(branding);
if (res.success) {
setMsgType('success');
setMsg('Firmendaten erfolgreich gespeichert.');
} else {
setMsgType('error');
setMsg(res.error || 'Fehler beim Speichern.');
}
} catch (err: any) {
setMsgType('error');
setMsg(err.message || 'Fehler beim Speichern.');
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
<Building2 className="w-8 h-8 text-blue-400" />
Firmendaten & Anschrift
</h1>
<p className="text-slate-400 text-xs mt-1">
Verwaltung der eigenen Unternehmensanschrift für Rechnungen, Dokumente und Footereinträge.
</p>
</div>
{msg && (
<div className={`p-3.5 rounded-xl text-xs font-medium border ${msgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{msg}
</div>
)}
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs font-bold text-slate-300">Firmenname *</Label>
<Input
value={branding.companyName}
onChange={(e) => setBranding({ ...branding, companyName: e.target.value })}
placeholder="z. B. Meine Firma GmbH"
className="bg-slate-950/80 border-slate-800 text-white text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold text-slate-300">Logo-URL (Optional)</Label>
<Input
value={branding.logoUrl || ''}
onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })}
placeholder="https://domain.de/logo.png oder /assets/logo.png"
className="bg-slate-950/80 border-slate-800 text-white text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold text-slate-300">Entwickler / Subline Beschriftung (Footer & Header)</Label>
<Input
value={branding.developerFooter || ''}
onChange={(e) => setBranding({ ...branding, developerFooter: e.target.value })}
placeholder="CASPOS Shop"
className="bg-slate-950/80 border-slate-800 text-white text-sm"
/>
</div>
<div className="grid md:grid-cols-2 gap-4 pt-3 border-t border-slate-800">
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Hauptanschrift
</span>
<div className="space-y-1.5">
<Label className="text-xs text-slate-400">Straße &amp; Nr.</Label>
<Input
value={branding.street}
onChange={(e) => setBranding({ ...branding, street: e.target.value })}
placeholder="Musterstraße 12"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs text-slate-400">PLZ</Label>
<Input
value={branding.zip}
onChange={(e) => setBranding({ ...branding, zip: e.target.value })}
placeholder="12345"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="col-span-2">
<Label className="text-xs text-slate-400">Ort</Label>
<Input
value={branding.city}
onChange={(e) => setBranding({ ...branding, city: e.target.value })}
placeholder="Musterstadt"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
</span>
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={branding.sameBillingAddress}
onChange={(e) => setBranding({ ...branding, sameBillingAddress: e.target.checked })}
className="w-3.5 h-3.5 rounded border-slate-700 bg-slate-950 text-blue-500"
/>
Gleiche wie Hauptanschrift
</label>
</div>
{!branding.sameBillingAddress ? (
<div className="space-y-3 p-3 rounded-xl bg-slate-950/40 border border-slate-800">
<div className="space-y-1.5">
<Label className="text-xs text-slate-400">Rechnungsstraße &amp; Nr.</Label>
<Input
value={branding.billingStreet}
onChange={(e) => setBranding({ ...branding, billingStreet: e.target.value })}
placeholder="Rechnungsstraße 45"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs text-slate-400">PLZ</Label>
<Input
value={branding.billingZip}
onChange={(e) => setBranding({ ...branding, billingZip: e.target.value })}
placeholder="54321"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
<div className="col-span-2">
<Label className="text-xs text-slate-400">Ort</Label>
<Input
value={branding.billingCity}
onChange={(e) => setBranding({ ...branding, billingCity: e.target.value })}
placeholder="Rechnungsstadt"
className="bg-slate-950/80 border-slate-800 text-white text-xs"
/>
</div>
</div>
</div>
) : (
<div className="p-3 rounded-xl bg-slate-950/40 border border-slate-800/60 text-xs text-slate-500 italic">
Verwendet automatisch die Hauptanschrift oben.
</div>
)}
</div>
</div>
<div className="flex justify-end pt-4 border-t border-slate-800">
<Button onClick={handleSave} disabled={saving} className="bg-blue-600 hover:bg-blue-500 text-white rounded-xl px-6 h-10 text-xs font-bold">
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
Speichern
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function LicServerPage() {
const [loading, setLoading] = useState(true);
const [licUrl, setLicUrl] = useState('');
const [licKey, setLicKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [licSaving, setLicSaving] = useState(false);
const [licTesting, setLicTesting] = useState(false);
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
const [licMsg, setLicMsg] = useState('');
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
const router = useRouter();
useEffect(() => {
async function loadData() {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data } = await supabase.from('settings').select('licserver_base_url, licserver_api_key').eq('id', 'licserver').maybeSingle();
if (data) {
setLicUrl(data.licserver_base_url || '');
setLicKey(data.licserver_api_key || '');
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
loadData();
}, [router]);
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
<KeyRound className="w-8 h-8 text-violet-400" />
CASPOS LicServer
</h1>
<p className="text-slate-400 text-xs mt-1">
REST API-Anbindung für die automatisierte Lizenzierung und Schlüsselgenerierung.
</p>
</div>
{licStatus && (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border ${
licStatus.ok
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
: 'bg-amber-500/10 border-amber-500/20 text-amber-400'
}`}>
{licStatus.ok ? <CheckCircle2 className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />}
{licStatus.message}
</div>
)}
</div>
{licMsg && (
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
licMsgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{licMsg}
</div>
)}
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="lic-url" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
</Label>
<Input
id="lic-url"
value={licUrl}
onChange={e => setLicUrl(e.target.value)}
placeholder="http://192.168.178.174:9980"
className="text-sm font-mono bg-slate-950/80 border-slate-800 text-white rounded-xl"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lic-key" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
</Label>
<div className="relative">
<Input
id="lic-key"
type={showKey ? 'text' : 'password'}
value={licKey}
onChange={e => setLicKey(e.target.value)}
placeholder="Ihr X-Api-Key"
className="text-sm font-mono pr-10 bg-slate-950/80 border-slate-800 text-white rounded-xl"
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
aria-label={showKey ? 'Key verbergen' : 'Key anzeigen'}
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-800">
<Button
disabled={licSaving || licTesting}
onClick={async () => {
setLicSaving(true);
setLicMsg('');
setLicStatus(null);
const res = await saveLicServerConfig(licUrl, licKey);
setLicMsgType(res.success ? 'success' : 'error');
setLicMsg(res.success ? 'Konfiguration gespeichert.' : (res.error || 'Fehler beim Speichern'));
setLicSaving(false);
}}
className="bg-violet-600 hover:bg-violet-500 text-white rounded-xl px-5 h-10 text-xs font-bold"
>
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
Speichern
</Button>
<Button
variant="outline"
disabled={licSaving || licTesting}
onClick={async () => {
setLicTesting(true);
setLicStatus(null);
await saveLicServerConfig(licUrl, licKey);
const result = await testLicServerConnection();
setLicStatus(result);
setLicTesting(false);
}}
className="border-slate-700 text-slate-300 hover:text-white rounded-xl px-5 h-10 text-xs font-bold"
>
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
Verbindung testen
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,236 +1,126 @@
/* Admin Settings with Database Import / Export functionality */
'use client';
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Building2, Palette, KeyRound, Mail, Sliders, ArrowRight, Loader2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function AdminSettings() {
const [demoActive, setDemoActive] = useState(true);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [statusMsg, setStatusMsg] = useState('');
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const menuItems = [
{
title: 'Firmendaten & Anschrift',
description: 'Verwaltung der eigenen Firmenanschrift, Rechnungsadresse und Sublines für Dokumente.',
href: '/admin/einstellungen/firmendaten',
icon: Building2,
color: 'text-blue-400',
bgColor: 'bg-blue-500/10 border-blue-500/20',
},
{
title: 'Branding & Farbschema',
description: 'Anpassung von Farbschemata (Primary / Accent), Buttons und Themes für den Webshop.',
href: '/admin/einstellungen/branding',
icon: Palette,
color: 'text-violet-400',
bgColor: 'bg-violet-500/10 border-violet-500/20',
},
{
title: 'CASPOS LicServer',
description: 'REST API Konfiguration für den Lizenzserver & automatisierte Schlüsselgenerierung.',
href: '/admin/einstellungen/licserver',
icon: KeyRound,
color: 'text-amber-400',
bgColor: 'bg-amber-500/10 border-amber-500/20',
},
{
title: 'SMTP E-Mail Server',
description: 'Serverdaten & Authentifizierung für Bestellbestätigungen und Benachrichtigungen.',
href: '/admin/settings',
icon: Mail,
color: 'text-sky-400',
bgColor: 'bg-sky-500/10 border-sky-500/20',
},
{
title: 'System & Backup',
description: 'Demo-Banner Steuerung sowie ZIP-Export und Wiederherstellung der Datenbank.',
href: '/admin/einstellungen/system',
icon: Sliders,
color: 'text-emerald-400',
bgColor: 'bg-emerald-500/10 border-emerald-500/20',
},
];
export default function AdminSettingsOverview() {
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
async function checkAccess() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
} else {
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data } = await supabase.from('users').select('role').eq('id', user.id).single();
if (!data || data.role === 'verwaltung') {
router.push('/admin');
return;
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
checkAccess();
}, [router]);
const toggleDemo = (checked: boolean) => {
setDemoActive(checked);
localStorage.setItem('demo_banner_disabled', (!checked).toString());
window.dispatchEvent(new Event('storage_demo_changed'));
};
const handleExport = async () => {
setExporting(true);
setStatusMsg('Export läuft...');
setStatusType('info');
try {
const res = await fetch('/api/admin/db-backup');
if (!res.ok) throw new Error('Export fehlgeschlagen');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
setStatusMsg('Export erfolgreich abgeschlossen.');
setStatusType('success');
} catch (err: any) {
console.error(err);
setStatusMsg(`Fehler beim Export: ${err.message}`);
setStatusType('error');
} finally {
setExporting(false);
}
};
const handleImport = async () => {
if (!selectedFile) return;
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
return;
}
setImporting(true);
setStatusMsg('Import läuft...');
setStatusType('info');
try {
const formData = new FormData();
formData.append('file', selectedFile);
const res = await fetch('/api/admin/db-backup', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
setStatusMsg('Datenbank erfolgreich importiert!');
setStatusType('success');
setSelectedFile(null);
} catch (err: any) {
console.error(err);
setStatusMsg(`Fehler beim Import: ${err.message}`);
setStatusType('error');
} finally {
setImporting(false);
}
};
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
Verwalten Sie globale Shopeinstellungen.
<div className="p-6 max-w-6xl mx-auto space-y-6">
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<h1 className="text-3xl font-extrabold tracking-tight text-white">Systemeinstellungen</h1>
<p className="text-slate-400 text-xs mt-1">
Wählen Sie eine Kategorie aus, um Konfigurationen, Server-Anbindungen oder Branding anzupassen.
</p>
</div>
</motion.div>
{/* Allgemeine Einstellungen */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-4">
<h2 className="text-lg font-bold flex items-center gap-2">
Allgemeine Einstellungen
</h2>
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-lg border border-slate-100 dark:border-white/5">
<div>
<p className="font-medium text-sm text-slate-900 dark:text-white">Demo-Warnmeldungen</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Schaltet gelbe Banner ein oder aus.</p>
</div>
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
</div>
</div>
{/* Datenimport & -export */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-6">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<Database className="w-5 h-5 text-primary" />
Datenimport & -export
</h2>
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
Sichern Sie Ihre gesamte Instanz oder stellen Sie Daten wieder her.
</p>
</div>
{statusMsg && (
<div className={`p-4 rounded-lg text-sm border ${
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
{statusMsg}
</div>
)}
<div className="grid md:grid-cols-2 gap-6">
{/* Export Card */}
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
<div className="space-y-2">
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
<Download className="w-4 h-4 text-primary" />
Daten exportieren
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
</p>
</div>
<Button
onClick={handleExport}
disabled={exporting || importing}
className="w-full bg-primary hover:bg-primary/90 text-white"
>
{exporting ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
) : (
<><Download className="w-4 h-4 mr-2" /> ZIP-Backup herunterladen</>
)}
</Button>
</div>
{/* Import Card */}
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
<div className="space-y-2">
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
<Upload className="w-4 h-4 text-amber-500" />
Daten importieren
</h3>
<div className="flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 p-2.5 rounded-lg text-amber-600 dark:text-amber-400 text-[11px] leading-snug">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
<strong>Warnung:</strong> Der Import überschreibt alle Daten dieser Instanz unwiderruflich!
</span>
</div>
</div>
<div className="space-y-3">
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
<input
type="file"
accept=".zip"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={exporting || importing}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="text-center text-xs text-slate-500 dark:text-slate-400">
{selectedFile ? (
<span className="text-primary font-semibold">{selectedFile.name}</span>
) : (
"ZIP-Datei auswählen oder hierher ziehen"
)}
</div>
</div>
<Button
onClick={handleImport}
disabled={!selectedFile || exporting || importing}
variant="secondary"
className="w-full border border-slate-200 dark:border-white/10"
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{menuItems.map((item, idx) => {
const Icon = item.icon;
return (
<Link key={idx} href={item.href}>
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md h-full flex flex-col justify-between space-y-4 hover:border-slate-700 transition-all shadow-md group cursor-pointer"
>
{importing ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Import läuft...</>
) : (
<><Upload className="w-4 h-4 mr-2" /> ZIP-Backup einspielen</>
)}
</Button>
</div>
</div>
</div>
<div className="space-y-3">
<div className={`w-12 h-12 rounded-xl border flex items-center justify-center ${item.bgColor}`}>
<Icon className={`w-6 h-6 ${item.color}`} />
</div>
<div>
<h3 className="font-bold text-lg text-white group-hover:text-primary transition-colors">
{item.title}
</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
{item.description}
</p>
</div>
</div>
<div className="flex items-center text-xs font-bold text-slate-300 group-hover:text-primary pt-3 border-t border-slate-800/80 gap-1.5 transition-colors">
<span>Einstellungen öffnen</span>
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
</div>
</motion.div>
</Link>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,226 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Download, Upload, Loader2, Sliders, ShieldAlert, FileArchive } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useRouter } from 'next/navigation';
export default function SystemPage() {
const [loading, setLoading] = useState(true);
const [demoActive, setDemoActive] = useState(true);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [statusMsg, setStatusMsg] = useState('');
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const router = useRouter();
useEffect(() => {
async function loadData() {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
loadData();
}, [router]);
const toggleDemo = (checked: boolean) => {
setDemoActive(checked);
localStorage.setItem('demo_banner_disabled', (!checked).toString());
window.dispatchEvent(new Event('storage_demo_changed'));
};
const handleExport = async () => {
setExporting(true);
setStatusMsg('Export läuft...');
setStatusType('info');
try {
const res = await fetch('/api/admin/db-backup');
if (!res.ok) throw new Error('Export fehlgeschlagen');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
setStatusMsg('Export erfolgreich abgeschlossen.');
setStatusType('success');
} catch (err: any) {
console.error(err);
setStatusMsg(`Fehler beim Export: ${err.message}`);
setStatusType('error');
} finally {
setExporting(false);
}
};
const handleImport = async () => {
if (!selectedFile) return;
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
return;
}
setImporting(true);
setStatusMsg('Import läuft...');
setStatusType('info');
try {
const formData = new FormData();
formData.append('file', selectedFile);
const res = await fetch('/api/admin/db-backup', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
setStatusMsg('Datenbank erfolgreich importiert!');
setStatusType('success');
setSelectedFile(null);
} catch (err: any) {
console.error(err);
setStatusMsg(`Fehler beim Import: ${err.message}`);
setStatusType('error');
} finally {
setImporting(false);
}
};
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
<Sliders className="w-8 h-8 text-amber-400" />
System & Backup
</h1>
<p className="text-slate-400 text-xs mt-1">
Verwaltung von System-Bannern, Datenbank-Sicherungen und Wiederherstellungen.
</p>
</div>
{statusMsg && (
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
statusType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' :
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
'bg-sky-500/10 border-sky-500/20 text-sky-400'
}`}>
{statusMsg}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col justify-between space-y-4">
<div className="space-y-3">
<div className="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/20 text-sky-400 flex items-center justify-center">
<Sliders className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-base text-white">System Banner</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
Gelbe Demo-Warnmeldungen im gesamten Shop aktivieren oder stummschalten.
</p>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-slate-800/80">
<span className="text-xs font-semibold text-slate-300">Banner aktiv</span>
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
</div>
</div>
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col justify-between space-y-4">
<div className="space-y-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
<Download className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-base text-white">DB Backup</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
Lädt alle Produkte, Firmen, Lizenzen &amp; Einstellungen als ZIP-Archiv herunter.
</p>
</div>
</div>
<Button
onClick={handleExport}
disabled={exporting || importing}
className="w-full bg-blue-600 hover:bg-blue-500 text-white rounded-xl h-10 text-xs font-bold"
>
{exporting ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
) : (
<><Download className="w-4 h-4 mr-2" /> ZIP Export</>
)}
</Button>
</div>
<div className="md:col-span-2 p-5 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
<Upload className="w-5 h-5" />
</div>
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-400 bg-amber-500/10 px-2.5 py-1 rounded-full border border-amber-500/20 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Überschreibt DB
</span>
</div>
<div>
<h3 className="font-bold text-base text-white">Daten Wiederherstellung</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
Spielen Sie eine gesicherte ZIP-Sicherungsdatei ein. Warnung: Überschreibt aktuelle Datenbankinhalte.
</p>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-3 pt-2">
<div className="relative border border-dashed border-slate-700/80 rounded-xl p-3 hover:bg-slate-800/50 transition cursor-pointer flex items-center justify-center text-center">
<input
type="file"
accept=".zip"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={exporting || importing}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<span className="text-xs font-medium text-slate-300 truncate px-2 flex items-center gap-1.5">
<FileArchive className="w-4 h-4 text-amber-400 shrink-0" />
{selectedFile ? selectedFile.name : 'ZIP-Datei auswählen'}
</span>
</div>
<Button
onClick={handleImport}
disabled={!selectedFile || exporting || importing}
variant="outline"
className="w-full border-amber-500/30 text-amber-300 hover:bg-amber-500/10 rounded-xl h-10 text-xs font-bold"
>
{importing ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Importiert...</>
) : (
<><Upload className="w-4 h-4 mr-2" /> ZIP Einspielen</>
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -77,17 +77,25 @@ export default async function AdminLayout({
<div className="pt-2 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
</div>
<AdminNavLink href="/admin/einstellungen">
<Settings className="w-5 h-5" />
Allgemein
<AdminNavLink href="/admin/einstellungen/firmendaten">
<Building2 className="w-5 h-5" />
Firmendaten
</AdminNavLink>
<AdminNavLink href="/admin/einstellungen/branding">
<Sparkles className="w-5 h-5 text-violet-400" />
Branding & Design
</AdminNavLink>
<AdminNavLink href="/admin/einstellungen/licserver">
<Wrench className="w-5 h-5 text-amber-400" />
LicServer
</AdminNavLink>
<AdminNavLink href="/admin/settings">
<Wrench className="w-5 h-5" />
SMTP
<Settings className="w-5 h-5 text-sky-400" />
SMTP Server
</AdminNavLink>
<AdminNavLink href="/admin/tools">
<Database className="w-5 h-5" />
DB-Tools
<AdminNavLink href="/admin/einstellungen/system">
<Database className="w-5 h-5 text-emerald-400" />
System & Backup
</AdminNavLink>
</>
)}

View File

@@ -7,8 +7,8 @@ export default async function AdminOrdersPage() {
<div className="p-8 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight text-gradient">Bestellungen</h1>
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte Annfragesbestätigungen zu.</p>
<h1 className="text-3xl font-bold tracking-tight text-white">Anfragen & Bestellungen</h1>
<p className="text-slate-400">Verwalten Sie alle Anfragen, prüfen Sie Details und greifen Sie auf generierte Anfragebestätigungen zu.</p>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
import { AdminRecentOrders } from '@/components/admin/recent-orders'
import Link from 'next/link'
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
async function getKpis() {
@@ -40,55 +41,63 @@ export default async function AdminDashboard() {
return (
<div className="p-8 space-y-8">
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
<h2 className="text-3xl font-bold tracking-tight text-white">Dashboard</h2>
{/* KPI Cards echte DB-Daten */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
<TrendingUp className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
</div>
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
</CardContent>
</Card>
<Link href="/admin/orders" className="block transition-transform duration-300 hover:scale-[1.02]">
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
<TrendingUp className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
</div>
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
</CardContent>
</Card>
</Link>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
<ShoppingCart className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{orderCount}</div>
<p className="text-xs text-slate-400">Gesamt</p>
</CardContent>
</Card>
<Link href="/admin/orders" className="block transition-transform duration-300 hover:scale-[1.02]">
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
<ShoppingCart className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{orderCount}</div>
<p className="text-xs text-slate-400">Gesamt</p>
</CardContent>
</Card>
</Link>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
<Package className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{productCount}</div>
<p className="text-xs text-slate-400">Im Katalog</p>
</CardContent>
</Card>
<Link href="/admin/products" className="block transition-transform duration-300 hover:scale-[1.02]">
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
<Package className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{productCount}</div>
<p className="text-xs text-slate-400">Im Katalog</p>
</CardContent>
</Card>
</Link>
<Card className="glass-dark border-white/5">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
<Users className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{userCount}</div>
<p className="text-xs text-slate-400">Registrierte User</p>
</CardContent>
</Card>
<Link href="/admin/users" className="block transition-transform duration-300 hover:scale-[1.02]">
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
<Users className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{userCount}</div>
<p className="text-xs text-slate-400">Registrierte User</p>
</CardContent>
</Card>
</Link>
</div>
{/* Letzte Bestellungen Client Component mit 30s-Polling */}

View File

@@ -10,7 +10,7 @@ export default async function AdminProductsPage() {
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<Package className="w-8 h-8 text-primary" />
Produkte & Module
</h2>

View File

@@ -1,9 +1,13 @@
"use client";
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { createClient } from '@/lib/supabase/client';
import { Loader2 } from 'lucide-react';
import { Loader2, Mail, Server, ShieldCheck, Key, Send, CheckCircle2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface Settings {
host: string;
@@ -13,39 +17,60 @@ interface Settings {
pass: string;
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: 15 },
visible: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 80, damping: 15 } }
};
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const router = useRouter();
// Load current settings and verify access
useEffect(() => {
async function checkAccessAndLoad() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
return;
}
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
fetch('/api/admin/smtp-settings')
.then((res) => res.json())
.then((data) => {
if (data.settings) setSettings(data.settings as Settings);
else setMessage('Failed to load settings');
})
.catch(() => setMessage('Failed to load settings'))
.finally(() => setLoading(false));
const [userRes, smtpRes] = await Promise.all([
supabase.from('users').select('role').eq('id', user.id).single(),
fetch('/api/admin/smtp-settings').then(res => res.json())
]);
if (!userRes.data || userRes.data.role === 'verwaltung') {
router.push('/admin');
return;
}
if (smtpRes.settings) {
setSettings(smtpRes.settings as Settings);
} else {
setMessage('Fehler beim Laden der SMTP-Einstellungen.');
setMsgType('error');
}
} catch (err) {
setMessage('Fehler beim Laden der Einstellungen.');
setMsgType('error');
} finally {
setLoading(false);
}
}
checkAccessAndLoad();
}, [router]);
@@ -62,110 +87,195 @@ export default function SettingsPage() {
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage('');
const res = await fetch('/api/admin/smtp-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
const data = await res.json();
if (res.ok) setMessage('Settings saved');
else setMessage(data.error || 'Error saving settings');
try {
const res = await fetch('/api/admin/smtp-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
const data = await res.json();
if (res.ok) {
setMessage('SMTP-Einstellungen erfolgreich gespeichert.');
setMsgType('success');
} else {
setMessage(data.error || 'Fehler beim Speichern.');
setMsgType('error');
}
} catch {
setMessage('Verbindungsfehler beim Speichern.');
setMsgType('error');
} finally {
setSaving(false);
}
};
const handleTestEmail = async () => {
setTesting(true);
setMessage('');
const testPayload = {
to: settings?.user || '', // send to the configured user address
subject: 'Test Email from Webshop Admin',
text: 'This is a test email to verify SMTP configuration.',
};
const res = await fetch('/api/admin/send-test-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testPayload),
});
const data = await res.json();
if (res.ok) setMessage('Test email sent successfully');
else setMessage(data.error || 'Failed to send test email');
try {
const testPayload = {
to: settings?.user || '',
subject: 'Test-E-Mail aus dem CASPOS Webshop Admin',
text: 'Dies ist eine automatische Test-E-Mail zur Bestätigung der SMTP-Konfiguration.',
};
const res = await fetch('/api/admin/send-test-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testPayload),
});
const data = await res.json();
if (res.ok) {
setMessage('Test-E-Mail erfolgreich versendet.');
setMsgType('success');
} else {
setMessage(data.error || 'Versand der Test-E-Mail fehlgeschlagen.');
setMsgType('error');
}
} catch {
setMessage('Fehler beim Senden der Test-E-Mail.');
setMsgType('error');
} finally {
setTesting(false);
}
};
if (loading) return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
if (loading) {
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="max-w-2xl mx-auto p-8 bg-black/30 backdrop-blur-xl rounded-lg text-white">
<h1 className="text-2xl font-bold mb-6">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>
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-6">
{/* Header */}
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1">
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Mail className="w-8 h-8 text-sky-400" />
SMTP-Einstellungen
</h1>
<p className="text-slate-400 text-xs">
Konfigurieren Sie den E-Mail-Server für transaktionale Benachrichtigungen und Bestellbestätigungen.
</p>
</motion.div>
{/* Dynamic Status Message */}
{message && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className={`p-3.5 rounded-xl text-xs font-semibold border flex items-center gap-2 ${
msgType === 'success'
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
: 'bg-destructive/10 border-destructive/20 text-destructive'
}`}
>
{msgType === 'success' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
{message}
</motion.div>
)}
{/* Bento Grid layout for SMTP */}
<form onSubmit={handleSave}>
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Bento Box 1: Server Connection Details (2 Spalten) */}
<motion.div variants={itemVariants} className="md:col-span-2 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md">
<div className="flex items-center gap-2 text-sky-400 font-bold text-sm border-b border-slate-800 pb-3">
<Server className="w-4 h-4" />
<span>Server & Serververbindung</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="host" className="text-xs font-bold text-slate-300">SMTP Host</Label>
<Input
id="host"
name="host"
type="text"
required
value={settings?.host ?? ''}
onChange={handleChange}
placeholder="smtp.beispiel.de"
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="port" className="text-xs font-bold text-slate-300">Port</Label>
<Input
id="port"
name="port"
type="number"
required
value={settings?.port ?? ''}
onChange={handleChange}
placeholder="587"
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
/>
</div>
</div>
<div className="pt-2">
<label className="inline-flex items-center gap-2 cursor-pointer p-3 rounded-xl bg-slate-950/50 border border-slate-800/80 hover:bg-slate-800/40 transition">
<input
name="secure"
type="checkbox"
checked={settings?.secure ?? false}
onChange={handleChange}
className="w-4 h-4 rounded text-sky-500 bg-slate-900 border-slate-700"
/>
<span className="text-xs font-semibold text-slate-200">SSL / TLS Verschlüsselung aktivieren</span>
</label>
</div>
</motion.div>
{/* Bento Box 2: Auth Credentials (1 Spalte) */}
<motion.div variants={itemVariants} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md flex flex-col justify-between">
<div className="space-y-4">
<div className="flex items-center gap-2 text-sky-400 font-bold text-sm border-b border-slate-800 pb-3">
<Key className="w-4 h-4" />
<span>Zugangsdaten</span>
</div>
<div className="space-y-1.5">
<Label htmlFor="user" className="text-xs font-bold text-slate-300">SMTP Benutzer</Label>
<Input
id="user"
name="user"
type="email"
required
value={settings?.user ?? ''}
onChange={handleChange}
placeholder="absender@beispiel.de"
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="pass" className="text-xs font-bold text-slate-300">Passwort</Label>
<Input
id="pass"
name="pass"
type="password"
required
value={settings?.pass ?? ''}
onChange={handleChange}
placeholder="••••••••••••"
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
/>
</div>
</div>
{/* Submit & Test Buttons */}
<div className="flex flex-col gap-2 pt-4 border-t border-slate-800">
<Button type="submit" disabled={saving || testing} className="w-full bg-sky-600 hover:bg-sky-500 text-white rounded-xl h-10 text-xs font-bold">
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Speichert...</> : 'Konfiguration speichern'}
</Button>
<Button type="button" onClick={handleTestEmail} disabled={saving || testing} variant="outline" className="w-full border-slate-700 text-slate-300 hover:text-white rounded-xl h-10 text-xs font-bold">
{testing ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Sendet...</> : <><Send className="w-3.5 h-3.5 mr-2 text-emerald-400" /> Test-Mail senden</>}
</Button>
</div>
</motion.div>
</motion.div>
</form>
</div>
);

View File

@@ -1,16 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import {
getDatabaseIntegrity,
repairDatabaseSchema,
optimizeDatabaseIndices
import { motion } from "framer-motion";
import {
getDatabaseIntegrity,
repairDatabaseSchema,
optimizeDatabaseIndices
} from "@/lib/actions/admin";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2, RefreshCw, Wrench, Database, AlertTriangle, CheckCircle2, XCircle, Terminal, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { Loader2, RefreshCw, Wrench, Database, CheckCircle2, XCircle, Terminal } from "lucide-react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
@@ -26,6 +25,19 @@ interface IntegrityData {
};
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: 15 },
visible: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 80, damping: 15 } }
};
export default function AdminToolsPage() {
const [integrity, setIntegrity] = useState<IntegrityData | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -88,218 +100,145 @@ export default function AdminToolsPage() {
useEffect(() => {
async function checkAccess() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const [userRes] = await Promise.all([
supabase.from('users').select('role').eq('id', user.id).single(),
loadStatus(true)
]);
if (!userRes.data || userRes.data.role === 'verwaltung') {
router.push('/admin');
return;
}
} catch (err) {
console.error("Fehler beim Laden von Admin Tools:", err);
} finally {
setLoading(false);
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
return;
}
setLoading(false);
loadStatus();
}
checkAccess();
}, [router]);
if (loading) {
return <div className="min-h-screen bg-[#020617] text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
}
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12 relative overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full -z-10 opacity-30" />
{/* Loading Overlay */}
{isLoading && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4">
<Loader2 className="w-12 h-12 text-primary animate-spin" />
<p className="text-slate-200 text-lg font-medium">
{activeAction === "repair"
? "Schema wird repariert..."
: activeAction === "optimize"
? "Datenbank wird indexiert..."
: "Verbindung zur Datenbank wird aufgebaut..."}
</p>
</div>
)}
<div className="p-6 max-w-7xl mx-auto text-slate-900 dark:text-white space-y-6">
{/* Header */}
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1">
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Database className="w-8 h-8 text-primary" />
Datenbank Cockpit & Tools
</h1>
<p className="text-slate-400 text-xs">
Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes.
</p>
</motion.div>
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
</Button>
</Link>
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Database className="w-8 h-8 text-primary" />
Admin Datenbank-Cockpit
</h1>
<p className="text-slate-400 text-sm mt-1">
Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes.
</p>
</div>
</div>
{/* Action Controls */}
<div className="grid md:grid-cols-3 gap-6">
<Card className="glass-dark border-white/10 flex flex-col justify-between">
<CardHeader>
<CardTitle className="text-white text-lg flex items-center gap-2">
<RefreshCw className="w-5 h-5 text-blue-400" />
Integritätsscan
</CardTitle>
<CardDescription className="text-slate-400">
{/* Bento Grid layout */}
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Bento Card 1: Integritätsscan */}
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-blue-500/30 transition-all duration-300 shadow-md">
<div className="space-y-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
<RefreshCw className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-base text-white">Integritätsscan</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
Prüft die physische Existenz der Tabellen und zählt deren Zeilenanzahl.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Button
onClick={() => loadStatus()}
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
>
Status aktualisieren
</Button>
</CardContent>
</Card>
<Card className="glass-dark border-white/10 flex flex-col justify-between">
<CardHeader>
<CardTitle className="text-white text-lg flex items-center gap-2">
<Wrench className="w-5 h-5 text-emerald-400" />
Schema reparieren
</CardTitle>
<CardDescription className="text-slate-400">
Erstellt fehlende Kerntabellen automatisch neu und konfiguriert die RLS-Rechte.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Button
onClick={handleRepair}
disabled={isLoading}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
>
Schema reparieren
</Button>
</CardContent>
</Card>
<Card className="glass-dark border-white/10 flex flex-col justify-between">
<CardHeader>
<CardTitle className="text-white text-lg flex items-center gap-2">
<Database className="w-5 h-5 text-amber-400" />
Index optimieren
</CardTitle>
<CardDescription className="text-slate-400">
Baut korrupte Suchindizes im Schema public mittels REINDEX neu auf.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Button
onClick={handleOptimize}
disabled={isLoading}
className="w-full bg-amber-600 hover:bg-amber-700 text-white"
>
Index optimieren
</Button>
</CardContent>
</Card>
</div>
{/* Tabellenstatus */}
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-white text-xl">Tabellen-Status & Diagnose</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid sm:grid-cols-2 md:grid-cols-4 gap-4">
{integrity?.tables ? (
Object.entries(integrity.tables).map(([name, status]) => (
<div
key={name}
className="p-4 rounded-xl border bg-white/5 border-white/10 flex flex-col justify-between gap-2"
>
<span className="font-semibold text-slate-300 text-sm capitalize">
{name.replace("_", " ")}
</span>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">Zeilen: {status.count}</span>
{status.exists ? (
<Badge className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 gap-1">
<CheckCircle2 className="w-3.5 h-3.5" /> OK
</Badge>
) : (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
<XCircle className="w-3.5 h-3.5" /> Fehlt
</Badge>
)}
</div>
</div>
))
) : (
<div className="col-span-4 py-8 text-center text-slate-500 text-sm">
Lade Diagnosedaten...
</div>
)}
</p>
</div>
</div>
<Button onClick={() => loadStatus()} disabled={isLoading} className="w-full bg-blue-600 hover:bg-blue-500 text-white rounded-xl h-10 text-xs font-bold">
{isLoading && activeAction === null ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Prüft...</> : 'Status Scannen'}
</Button>
</motion.div>
{/* Foreign Key Errors */}
{integrity && integrity.errors.foreign_keys > 0 && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex gap-3 items-center">
<AlertTriangle className="w-6 h-6 text-red-400 shrink-0" />
<div>
<p className="font-bold text-red-200">Fremdschlüssel-Fehler gefunden!</p>
<p className="text-red-300/80 text-sm">
Es wurden {integrity.errors.foreign_keys} verwaiste Lizenzen ohne zugeordneten Endkunden in der Tabelle `licenses` detektiert.
</p>
{/* Bento Card 2: Schema Reparieren */}
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-emerald-500/30 transition-all duration-300 shadow-md">
<div className="space-y-3">
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center">
<Wrench className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-base text-white">Schema Reparieren</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
Erstellt fehlende Kerntabellen automatisch neu und konfiguriert RLS-Rechte.
</p>
</div>
</div>
<Button onClick={handleRepair} disabled={isLoading} className="w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl h-10 text-xs font-bold">
{isLoading && activeAction === 'repair' ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Repariert...</> : 'Schema Reparieren'}
</Button>
</motion.div>
{/* Bento Card 3: Index Optimieren */}
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-amber-500/30 transition-all duration-300 shadow-md">
<div className="space-y-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
<Database className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-base text-white">Index Optimieren</h3>
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
Baut Suchindizes im Schema public mittels REINDEX neu auf für maximale Performance.
</p>
</div>
</div>
<Button onClick={handleOptimize} disabled={isLoading} className="w-full bg-amber-600 hover:bg-amber-500 text-white rounded-xl h-10 text-xs font-bold">
{isLoading && activeAction === 'optimize' ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Indexiert...</> : 'Index Optimieren'}
</Button>
</motion.div>
{/* Bento Card 4: Tabellen-Diagnose (2 Spalten) */}
<motion.div variants={itemVariants} className="md:col-span-2 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md">
<h3 className="font-bold text-base text-white border-b border-slate-800 pb-3">Tabellen-Status & Diagnose</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{integrity?.tables ? (
Object.entries(integrity.tables).map(([name, status]) => (
<div key={name} className="p-3 rounded-xl border bg-slate-950/60 border-slate-800 flex items-center justify-between">
<div>
<span className="font-bold text-xs text-slate-200 capitalize block">{name.replace("_", " ")}</span>
<span className="text-[10px] text-slate-400">Zeilen: {status.count}</span>
</div>
{status.exists ? (
<Badge className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 text-[10px] py-0 px-1.5">OK</Badge>
) : (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 text-[10px] py-0 px-1.5">Fehlt</Badge>
)}
</div>
</div>
))
) : (
<p className="text-xs text-slate-500 col-span-3">Keine Diagnose-Daten geladen.</p>
)}
</CardContent>
</Card>
</div>
</motion.div>
{/* Console / Output logs */}
<Card className="glass-dark border-white/10">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white text-lg flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
Diagnose-Konsole
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setLogs([])}
className="text-slate-400 hover:text-white text-xs"
>
Konsole leeren
</Button>
</CardHeader>
<CardContent className="pt-0">
<div className="font-mono text-xs bg-black/50 p-4 rounded-xl border border-white/5 h-64 overflow-y-auto space-y-1.5 scrollbar-thin">
{/* Bento Card 5: Live Konsole / Logs (1 Spalte) */}
<motion.div variants={itemVariants} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-3 shadow-md flex flex-col justify-between">
<div className="space-y-2">
<h3 className="font-bold text-base text-white flex items-center gap-2 border-b border-slate-800 pb-3">
<Terminal className="w-4 h-4 text-sky-400" /> System-Konsole
</h3>
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-[11px] text-slate-300 h-44 overflow-y-auto space-y-1 subpixel-antialiased">
{logs.length === 0 ? (
<span className="text-slate-600 italic">Konsole bereit. Führen Sie einen Scan aus.</span>
<span className="text-slate-600 italic">Warte auf Aktionen...</span>
) : (
logs.map((log, index) => (
<div key={index} className={log.includes("FEHLER") ? "text-red-400" : log.includes("Erfolgreich") ? "text-emerald-400" : "text-slate-300"}>
{log}
</div>
))
logs.map((log, i) => <div key={i} className="leading-tight">{log}</div>)
)}
</div>
</CardContent>
</Card>
</div>
</div>
</motion.div>
</motion.div>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export default async function AdminUsersPage() {
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<Users className="w-8 h-8 text-primary" />
Benutzerverwaltung
</h2>

View File

@@ -10,7 +10,7 @@ export default async function WysiwygAdminPage() {
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<Edit className="w-8 h-8 text-primary" />
WYSIWYG Live-Editor
</h2>

View File

@@ -402,6 +402,30 @@ export function WysiwygAdminClient({
</div>
</div>
{/* Verknüpfte monatliche Gebühr */}
<div className="space-y-1 mt-2">
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Verknüpfte Gebühr:</span>
<select
value={prod.linked_fee_product_id || ''}
onChange={async (e) => {
const val = e.target.value || null
setProducts(products.map(p => p.id === prod.id ? { ...p, linked_fee_product_id: val } : p))
await updateProduct(prod.id, { linked_fee_product_id: val }, prod.modules || [])
}}
className="w-full bg-slate-950 border border-white/10 text-[10px] text-slate-300 focus:ring-0 cursor-pointer rounded px-2 py-1 outline-none"
>
<option value="">Keine Gebühr verknüpft</option>
{products
.filter(p => p.billing_interval === 'monthly' && p.id !== prod.id)
.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.base_price)} / mtl.)
</option>
))
}
</select>
</div>
{/* Modul hinzufügen Popover */}
<div className="mt-3 pt-2 border-t border-white/5">
<ModulePopover

View File

@@ -67,7 +67,8 @@ export async function GET() {
'orders',
'profiles',
'users',
'settings'
'settings',
'branding_settings'
];
const backupData: Record<string, any[]> = {};

View File

@@ -82,11 +82,9 @@ export async function GET(
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
dateStr: formattedDate,
order: order,
orderSnapshot: order.order_data,
customer: order.customer_data,
orderData: order.order_data,
totalPrice: Number(order.total_price),
})
);

View File

@@ -43,7 +43,20 @@ export async function POST(request: Request) {
const { to, subject, text, html } = await request.json();
try {
const info = await sendMail({ to, subject, text, html });
const { data: brandData } = await supabase.from('settings').select('primary_color').eq('id', 'branding').maybeSingle();
const primaryColor = brandData?.primary_color || '#2563eb';
const testHtml = html || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: ${primaryColor}; margin-bottom: 16px;">Test-E-Mail</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${text || 'Dies ist eine automatische Test-E-Mail.'}</p>
<div style="margin-top: 24px; padding: 12px; background-color: #f8fafc; border-left: 4px solid ${primaryColor}; font-size: 14px; color: #334155;">
SMTP-Konfiguration & Branding-Farbe erfolgreich verifiziert.
</div>
</div>
`;
const info = await sendMail({ to, subject, text, html: testHtml });
return NextResponse.json({ message: 'Email sent', info });
} catch (err) {
console.error('Mail error', err);

View File

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

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { syncCompaniesFromLicServer } from '@/lib/actions/companies'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
// Check authorization header
const authHeader = req.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
// Secure the cron endpoint in production
if (process.env.NODE_ENV === 'production' && cronSecret) {
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const res = await syncCompaniesFromLicServer()
return NextResponse.json({ success: true, count: res.count })
} catch (err: any) {
console.error('[cron] Sync failed:', err)
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { syncCustomersFromLicServer } from '@/lib/actions/companies'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
// Check authorization header
const authHeader = req.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
// Secure the cron endpoint in production
if (process.env.NODE_ENV === 'production' && cronSecret) {
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const res = await syncCustomersFromLicServer()
return NextResponse.json({ success: true, count: res.count })
} catch (err: any) {
console.error('[cron] Customer sync failed:', err)
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { CustomerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
/**
* GET /api/licserver/partners/[id]/customers
*
* Proxy to LicServer GET /api-v1/partners/{id}/customers
* [id] must be a valid UUID.
* Query params forwarded: search, page, pageSize
*
* Secured: requires a valid Supabase session.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// ── Auth guard ─────────────────────────────────────────────────────────
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
const { id } = await params
// Basic UUID format guard
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
}
// ── Forward query params ───────────────────────────────────────────────
const { searchParams } = req.nextUrl
const upstream = new URL(`${cfg.base_url}/api-v1/partners/${id}/customers`)
const search = searchParams.get('search')
const page = searchParams.get('page')
const pageSize = searchParams.get('pageSize')
if (search) upstream.searchParams.set('search', search)
if (page) upstream.searchParams.set('page', page)
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(
upstream.toString(),
{
method: 'GET',
headers: {
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5_000),
}
)
if (!res.ok) {
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
return NextResponse.json(problem, { status: res.status })
}
const data: CustomerDtoPagedResult = await res.json()
return NextResponse.json(data)
} catch (err) {
console.error('[licserver] partner/:id/customers fetch error:', err)
return NextResponse.json(
{ error: 'LicServer nicht erreichbar' },
{ status: 502 }
)
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { PartnerDto, LicServerProblemDetails } from '@/lib/types/licserver'
/**
* GET /api/licserver/partners/[id]
*
* Proxy to LicServer GET /api-v1/partners/{id}
* [id] must be a valid UUID.
*
* Secured: requires a valid Supabase session.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// ── Auth guard ─────────────────────────────────────────────────────────
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
const { id } = await params
// Basic UUID format guard
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
}
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(
`${cfg.base_url}/api-v1/partners/${id}`,
{
method: 'GET',
headers: {
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5_000),
}
)
if (!res.ok) {
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
return NextResponse.json(problem, { status: res.status })
}
const data: PartnerDto = await res.json()
return NextResponse.json(data)
} catch (err) {
console.error('[licserver] partner/:id fetch error:', err)
return NextResponse.json(
{ error: 'LicServer nicht erreichbar' },
{ status: 502 }
)
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { PartnerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
/**
* GET /api/licserver/partners
*
* Proxy to LicServer GET /api-v1/partners
* Query params forwarded: search, page, pageSize
*
* Secured: requires a valid Supabase session.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(req: NextRequest) {
// ── Auth guard ─────────────────────────────────────────────────────────
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
// ── Forward query params ───────────────────────────────────────────────
const { searchParams } = req.nextUrl
const upstream = new URL(`${cfg.base_url}/api-v1/partners`)
const search = searchParams.get('search')
const page = searchParams.get('page')
const pageSize = searchParams.get('pageSize')
if (search) upstream.searchParams.set('search', search)
if (page) upstream.searchParams.set('page', page)
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(upstream.toString(), {
method: 'GET',
headers: {
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
// Internal container network — short timeout
signal: AbortSignal.timeout(5_000),
})
if (!res.ok) {
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
return NextResponse.json(problem, { status: res.status })
}
const data: PartnerDtoPagedResult = await res.json()
return NextResponse.json(data)
} catch (err) {
console.error('[licserver] partners fetch error:', err)
return NextResponse.json(
{ error: 'LicServer nicht erreichbar' },
{ status: 502 }
)
}
}

View File

@@ -82,11 +82,9 @@ export async function GET(
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
dateStr: formattedDate,
order: order,
orderSnapshot: order.order_data,
customer: order.customer_data,
orderData: order.order_data,
totalPrice: Number(order.total_price),
})
);

View File

@@ -7,6 +7,9 @@ import { createHash } from 'crypto';
import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf';
import { generateOrderEmailHtml, generateOrderEmailSubject } from '@/lib/actions/email-templates';
import { validateWizardSelections } from '@/lib/actions/validation';
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
const year = new Date().getFullYear();
@@ -33,13 +36,13 @@ export async function POST(request: Request) {
.eq('id', user.id)
.single();
const isAdminUser = dbUser?.role === 'admin';
const isAdminUser = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung';
if (!isAdminUser && !dbUser?.company_id) {
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
}
const body = await request.json();
const { items, customerProfile, endCustomerId, endCustomer } = body;
const { items, customerProfile, endCustomerId, endCustomer, lastLicenseDate } = body;
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: 'Warenkorb leer' }, { status: 400 });
@@ -48,6 +51,13 @@ export async function POST(request: Request) {
const products = await getProducts();
const categories = await getCategories();
// --- Backend-Validierung: alle Kategorie-Flags für jede Kasse prüfen ---
for (const item of items) {
if (item.selections) {
validateWizardSelections(item.selections, products, categories);
}
}
// Split items into purchase and subscription
const purchaseItems = items.filter(
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
@@ -67,6 +77,7 @@ export async function POST(request: Request) {
const orderItemsList: any[] = [];
let total = 0;
const feeProductIds = new Set<string>();
for (const item of groupItems) {
const itemSnapshot = buildOrderSnapshot(
@@ -74,12 +85,63 @@ export async function POST(request: Request) {
products,
categories,
type === 'purchase' ? 'one_time' : 'monthly',
item.moduleQuantities
item.moduleQuantities,
type === 'purchase' ? lastLicenseDate : null
);
orderItemsList.push(...itemSnapshot.items);
const itemsWithDevice = itemSnapshot.items.map(i => ({
...i,
device_name: item.deviceName,
license_number: item.licenseNumber
}));
orderItemsList.push(...itemsWithDevice);
total += itemSnapshot.total;
// Echte linked fee products ermitteln
if (item.selections) {
for (const catId in item.selections) {
const sel = item.selections[catId];
if (sel.productId) {
const p = products.find((prod: any) => prod.id === sel.productId);
if (p && p.linked_fee_product_id) {
feeProductIds.add(p.linked_fee_product_id);
}
}
sel.productIds?.forEach((pId: string) => {
const p = products.find((prod: any) => prod.id === pId);
if (p && p.linked_fee_product_id) {
feeProductIds.add(p.linked_fee_product_id);
}
});
}
}
}
feeProductIds.forEach(feeProdId => {
const feeProduct = products.find((p: any) => p.id === feeProdId);
if (feeProduct) {
const isIntervalMatch =
(type === 'purchase' && feeProduct.billing_interval === 'one_time') ||
(type === 'subscription' && feeProduct.billing_interval === 'monthly');
if (isIntervalMatch) {
orderItemsList.push({
category_id: 'dienste-gebuehren',
category_name: 'Dienste & Gebühren',
product_id: feeProduct.id,
product_name: feeProduct.name,
base_price: Number(feeProduct.base_price),
billing_interval: feeProduct.billing_interval,
selected_modules: [],
item_total: Number(feeProduct.base_price),
device_name: 'Zusatzleistung'
});
total += Number(feeProduct.base_price);
}
}
});
const orderSnapshot = {
schema_version: 1,
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
@@ -87,7 +149,8 @@ export async function POST(request: Request) {
total: total,
tax_rate: 19, // default
tax_amount: Math.round(total * 0.19 * 100) / 100,
subtotal: Math.round((total / 1.19) * 100) / 100
subtotal: Math.round((total / 1.19) * 100) / 100,
last_license_date: type === 'purchase' ? lastLicenseDate : null
};
const orderHash = hashOrderSnapshot(orderSnapshot);
@@ -113,6 +176,8 @@ export async function POST(request: Request) {
if (orderError) throw orderError;
const fileName = `ab_${order.id}.pdf`;
// Trigger PDF generation & storage upload
try {
const buffer = await renderToBuffer(
@@ -123,7 +188,6 @@ export async function POST(request: Request) {
})
);
const fileName = `ab_${order.id}.pdf`;
const { error: uploadError } = await supabase
.storage
.from('invoices')
@@ -140,11 +204,129 @@ export async function POST(request: Request) {
// Trigger E-Mail notifications
if (user.email) {
try {
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
const taxRate = orderSnapshot.tax_rate ?? 19;
const items = orderSnapshot.items ?? [];
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
let totalDetailsText = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
} else if (oneTimeNet > 0) {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
} else {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
}
let totalDetailsHtml = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsHtml = `
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
} else if (oneTimeNet > 0) {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
`;
} else {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
}
const emailTemplate = generateOrderEmailHtml({
orderNumber: order.order_number,
status: 'pending',
formattedDate,
customerCompanyName: customerSnapshot.company_name,
items,
taxRate,
oneTimeNet,
monthlyNet,
});
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
const { data: bufferData, error: downloadError } = await supabase
.storage
.from('invoices')
.download(fileName);
const attachments: any[] = [];
if (!downloadError && bufferData) {
const buffer = Buffer.from(await bufferData.arrayBuffer());
attachments.push({
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
content: buffer,
contentType: 'application/pdf',
});
}
await sendMail({
to: user.email,
subject: `Bestellbestätigung ${order.order_number}`,
text: `Vielen Dank für Ihre Bestellung ${order.order_number}. Typ: ${type}.`,
html: `<p>Vielen Dank für Ihre Bestellung <strong>${order.order_number}</strong>.</p><p>Typ: ${type}</p>`
subject: mailSubject,
text: emailTemplate.text,
html: emailTemplate.html,
attachments
});
} catch (mailErr) {
console.error('Mail error for order ' + order.id, mailErr);

View File

@@ -16,7 +16,7 @@ export default function Datenschutz() {
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
<Lock className="w-6 h-6" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Datenschutzerklärung
</h1>
<p className="text-slate-400">Informationen über die Verarbeitung Ihrer personenbezogenen Daten.</p>

View File

@@ -2,110 +2,221 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Light Mode: Maximierter Kontrast (Reines Weißer Hintergrund + Tiefschwarzer Text) */
--background: 0 0% 100%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 224 71% 4%;
--popover: 0 0% 100%;
--popover-foreground: 224 71% 4%;
html {
color-scheme: dark;
}
/* Primary: Cyan/Blau lesbarer gemacht */
--primary: 199 100% 35%;
--primary-foreground: 0 0% 100%;
:root {
/* OKLCH Zentral-Farbsystem */
--oklch-background: 0.145 0.01 148;
--oklch-surface: 0.20 0.01 148;
--oklch-surface-hover: 0.25 0.012 148;
--oklch-primary: 0.68 0.18 255;
--oklch-primary-hover: 0.75 0.18 255;
--oklch-secondary: 0.76 0.15 155;
--oklch-text-main: 0.98 0 0;
--oklch-text-muted: 0.70 0.01 148;
--oklch-border: 0.31 0.012 148;
/* Muted & Accent: Deutlich dunklerer Text für Barrierefreiheit */
--secondary: 220 14% 90%;
--secondary-foreground: 224 71% 4%;
--muted: 220 14% 92%;
--muted-foreground: 220 20% 28%;
/* Vorher 40% Helligkeit, jetzt 28% (viel dunkler) */
--accent: 220 14% 90%;
--accent-foreground: 224 71% 4%;
/* Dynamische Branding Variablen */
--primary-custom: #2563eb;
--accent-custom: #38bdf8;
--success-custom: #10b981;
--warning-custom: #f59e0b;
--destructive-custom: #ef4444;
--bg-glow-1: #3b82f6;
--bg-glow-2: #1d4ed8;
--gradient-from: #2563eb;
--gradient-to: #1e40af;
--destructive: 0 84% 50%;
--destructive-foreground: 0 0% 100%;
--border: 220 14% 75%;
/* Grenzen sichtbarer gemacht */
--input: 220 14% 75%;
--ring: 199 100% 35%;
--radius: 0.75rem;
/* Classic HSL Mappings */
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 217 91% 60%;
--radius: 0.75rem;
}
/* 100% Dynamische Farbbindung für das gesamte Webshop Design-System */
.bg-blue-600, .bg-blue-500, .bg-violet-600, .bg-purple-600, .bg-sky-500, .bg-cyan-600, .bg-indigo-600 {
background-color: var(--primary-custom) !important;
}
.hover\:bg-blue-500:hover, .hover\:bg-violet-500:hover, .hover\:bg-blue-600:hover {
background-color: var(--primary-custom) !important;
filter: brightness(1.1);
}
.text-blue-500, .text-blue-400, .text-blue-600, .text-violet-400, .text-sky-400, .text-sky-300, .text-indigo-400 {
color: var(--primary-custom) !important;
}
.border-blue-500, .border-blue-600, .border-violet-500, .border-sky-500 {
border-color: var(--primary-custom) !important;
}
/* Dynamische Transparenz-Schichten & Badges mit color-mix */
[class*="bg-blue-500/"], [class*="bg-violet-500/"], [class*="bg-purple-500/"], [class*="bg-sky-500/"] {
background-color: color-mix(in srgb, var(--primary-custom) 15%, transparent) !important;
}
[class*="border-blue-500/"], [class*="border-violet-500/"], [class*="border-purple-500/"], [class*="border-sky-500/"] {
border-color: color-mix(in srgb, var(--primary-custom) 35%, transparent) !important;
}
/* Dynamische Gradients */
.from-blue-600, .from-blue-500, .from-violet-600 {
--tw-gradient-from: var(--gradient-from, var(--primary-custom)) !important;
--tw-gradient-to: rgb(255 255 255 / 0) !important;
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
}
.to-indigo-600, .to-indigo-500, .to-sky-500, .to-sky-400, .to-violet-500 {
--tw-gradient-to: var(--gradient-to, var(--accent-custom)) !important;
}
/* Dynamische Signalfarben (Success, Warning, Destructive) */
.bg-emerald-500, .bg-green-500, .bg-emerald-600, .bg-green-600 {
background-color: var(--success-custom) !important;
}
.text-emerald-400, .text-green-400, .text-emerald-500, .text-green-500, .text-green-600 {
color: var(--success-custom) !important;
}
.border-emerald-500, .border-green-500 {
border-color: var(--success-custom) !important;
}
[class*="bg-emerald-500/"], [class*="bg-green-500/"] {
background-color: color-mix(in srgb, var(--success-custom) 15%, transparent) !important;
}
[class*="border-emerald-500/"], [class*="border-green-500/"] {
border-color: color-mix(in srgb, var(--success-custom) 35%, transparent) !important;
}
.bg-amber-500, .bg-amber-600 {
background-color: var(--warning-custom) !important;
}
.text-amber-400, .text-amber-500, .text-amber-600 {
color: var(--warning-custom) !important;
}
.border-amber-500, .border-amber-600 {
border-color: var(--warning-custom) !important;
}
[class*="bg-amber-500/"] {
background-color: color-mix(in srgb, var(--warning-custom) 15%, transparent) !important;
}
[class*="border-amber-500/"] {
border-color: color-mix(in srgb, var(--warning-custom) 35%, transparent) !important;
}
.bg-red-500, .bg-red-600, .bg-rose-500, .bg-rose-600 {
background-color: var(--destructive-custom) !important;
}
.text-red-500, .text-red-400, .text-rose-400, .text-rose-500 {
color: var(--destructive-custom) !important;
}
.border-red-500, .border-rose-500 {
border-color: var(--destructive-custom) !important;
}
[class*="bg-red-500/"], [class*="bg-rose-500/"] {
background-color: color-mix(in srgb, var(--destructive-custom) 15%, transparent) !important;
}
[class*="border-red-500/"], [class*="border-rose-500/"] {
border-color: color-mix(in srgb, var(--destructive-custom) 35%, transparent) !important;
}
/* Background Glowing Orbs & Dynamic Surfaces */
.blur-\[120px\] {
background-color: var(--bg-glow-1) !important;
}
.bg-\[\#020617\] {
background-color: oklch(var(--oklch-background)) !important;
}
/* Dynamische Buttons & Badges über globale CSS Variablen */
.btn-primary-theme, .bg-primary-theme {
background-color: var(--button-bg, var(--primary-custom)) !important;
color: #ffffff !important;
}
.text-highlight-theme {
color: var(--text-highlight, var(--accent-custom)) !important;
}
.border-glow-theme {
border-color: var(--card-border-glow, color-mix(in srgb, var(--accent-custom) 35%, transparent)) !important;
}
.ring-theme {
--tw-ring-color: var(--ring-color, var(--primary-custom)) !important;
}
input, select, textarea, option {
background-color: oklch(var(--oklch-surface)) !important;
color: oklch(var(--oklch-text-main)) !important;
border: 1px solid oklch(var(--oklch-border) / 0.4) !important;
}
input:focus, select:focus, textarea:focus {
border-color: var(--primary-custom) !important;
outline: none !important;
box-shadow: 0 0 0 2px var(--primary-custom) !important;
}
option {
color: oklch(var(--oklch-text-main)) !important;
background-color: oklch(var(--oklch-surface)) !important;
}
/* Eye-Catcher & Rüttel-Animation für fehlende Pflichtkategorien */
@keyframes shake {
0%, 100% { transform: translateX(0); }
15%, 55% { transform: translateX(-4px); }
35%, 75% { transform: translateX(4px); }
}
@keyframes eye-catcher-glow {
0%, 100% {
box-shadow: 0 0 15px rgba(244, 63, 94, 0.6), inset 0 0 8px rgba(244, 63, 94, 0.3);
border-color: rgba(244, 63, 94, 0.9);
}
.dark {
/* Dark Mode: Reines Tiefschwarz erhöht den Kontrast zu weißem Text drastisch */
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
/* Muted Text: Helligkeit massiv erhöht, damit Beschreibungen lesbar sind */
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 75%;
/* Vorher 56.9% Helligkeit, jetzt 75% (viel heller) */
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 72% 51%;
/* Helleres Rot für dunklen Hintergrund */
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 25%;
/* Grenzen im Dunkeln besser sichtbar */
--input: 240 3.7% 25%;
--ring: 240 4.9% 83.9%;
50% {
box-shadow: 0 0 28px rgba(239, 68, 68, 0.9), inset 0 0 14px rgba(239, 68, 68, 0.5);
border-color: rgba(239, 68, 68, 1);
}
}
/* Automatischer High-Contrast-Modus für Betriebssystem-Vorgaben */
@media (prefers-contrast: more) {
:root {
--foreground: 0 0% 0%;
--muted-foreground: 0 0% 0%;
--border: 0 0% 0%;
}
.dark {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--muted-foreground: 0 0% 100%;
--border: 0 0% 100%;
}
.animate-shake {
animation: shake 0.6s ease-in-out infinite;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@layer utilities {
/* Glassmorphism Kontraste geschärft (höhere Deckkraft) */
.glass {
@apply bg-white/20 backdrop-blur-md border border-white/40;
}
.glass-dark {
@apply bg-black/40 backdrop-blur-md border border-white/20;
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-sky-500 to-blue-600 dark:from-primary dark:to-blue-400;
}
.animate-eye-catcher {
animation: eye-catcher-glow 1.2s ease-in-out infinite, shake 0.8s ease-in-out infinite !important;
}

View File

@@ -16,7 +16,7 @@ export default function Impressum() {
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
<Shield className="w-6 h-6" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
<h1 className="text-4xl font-extrabold tracking-tight text-white">
Impressum
</h1>
<p className="text-slate-400">Angaben gemäß § 5 TMG.</p>

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { ThemeProvider } from "@/components/ThemeProvider";
import { InactivityTracker } from "@/components/inactivity-tracker";
import { HeaderWrapper } from "@/components/HeaderWrapper";
import { Navbar } from "@/components/Navbar";

View File

@@ -104,6 +104,13 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
Kunden-ID: <span className="font-mono">{customer.id.slice(0, 8)}</span>
</p>
</div>
{!customer.is_anonymized && (
<Link href={`/order?customer_id=${customer.id}`} className="ml-auto">
<Button size="sm" className="bg-primary hover:bg-primary/90 text-white font-bold gap-2 shadow-[0_0_15px_rgba(59,130,246,0.3)] text-xs">
<ShoppingBag className="w-4 h-4" /> Kunde updaten / bestellen
</Button>
</Link>
)}
{customer.is_anonymized && (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1 ml-auto">
<AlertTriangle className="w-3 h-3" /> Anonymisiert
@@ -186,8 +193,9 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
</Button>
) : (
<div className="space-y-3 p-4 rounded-lg bg-red-500/10 border border-red-500/30">
<p className="text-sm text-red-300 font-semibold">
Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben.
<p className="text-sm text-red-300 font-semibold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-red-400" />
<span>Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben.</span>
</p>
<div className="flex gap-3">
<Button

View File

@@ -1,41 +1,27 @@
export const dynamic = 'force-dynamic';
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { getEndCustomers } from '@/lib/actions/end-customers'
import type { EndCustomer } from '@/lib/types'
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
import { getPartnerCustomersWithDevices } from '@/lib/actions/queries'
import type { EndCustomerWithDevices } from '@/lib/types'
import { ArrowLeft, Building2, Plus, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge';
import { createClient } from '@/lib/supabase/server';
import { createClient } from '@/lib/supabase/server'
import { CustomerAccordionList } from '@/components/customer-accordion-list'
export default async function MyCustomersPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
let customers: EndCustomer[] = []
let customers: EndCustomerWithDevices[] = []
let fetchError: string | null = null
try {
customers = await getEndCustomers()
customers = await getPartnerCustomersWithDevices()
} catch (err: any) {
console.error("Error loading end customers:", err)
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Endkunden."
console.error("Error loading partner customers with orders:", err)
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Kunden und Kassen."
}
// Anzahl Bestellungen pro Endkunde
const { data: orderCounts, error: orderError } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null);
if (orderError) {
console.error("Error loading order counts:", orderError)
}
const countMap: Record<string, number> = {}
;(orderCounts ?? []).forEach(o => {
if (o.end_customer_id) {
countMap[o.end_customer_id] = (countMap[o.end_customer_id] ?? 0) + 1
}
})
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
<div className="max-w-5xl mx-auto space-y-8">
@@ -50,10 +36,10 @@ export default async function MyCustomersPage() {
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<Building2 className="w-8 h-8 text-primary" />
Meine Kunden
Kunden & Kassen
</h1>
<p className="text-slate-400 text-sm mt-1">
Verwalten Sie Ihre Endkunden deren Daten werden für Bestellungen verwendet.
Verwalten Sie Ihre Kunden und deren zugewiesene Kassen / Abonnements.
</p>
</div>
</div>
@@ -70,86 +56,8 @@ export default async function MyCustomersPage() {
</div>
)}
{/* Keine Kunden */}
{customers.length === 0 && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
<Building2 className="w-12 h-12 text-slate-600" />
<p className="text-slate-400 text-lg">Noch keine Endkunden angelegt.</p>
<Link href="/my-customers/new">
<Button><Plus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen</Button>
</Link>
</CardContent>
</Card>
)}
{/* Kundenliste */}
{customers.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/10 text-left">
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Unternehmen</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden sm:table-cell">Adresse</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden md:table-cell">Bestellungen</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Status</th>
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{customers.map(customer => (
<tr key={customer.id} className="hover:bg-white/3 transition-colors">
<td className="py-4 pr-4">
<p className="font-semibold text-white">{customer.company_name}</p>
{(customer.first_name || customer.last_name) && (
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
</p>
)}
{customer.vat_id && (
<p className="text-xs text-slate-500">{customer.vat_id}</p>
)}
</td>
<td className="py-4 pr-4 hidden sm:table-cell">
<p className="text-sm text-slate-300">
{customer.street ?? ''}
</p>
<p className="text-sm text-slate-400">
{[customer.zip, customer.city].filter(Boolean).join(' ') || ''}
</p>
</td>
<td className="py-4 pr-4 hidden md:table-cell">
<Link href={`/my-orders?customer=${customer.id}`} className="group">
<span className="text-primary font-bold group-hover:underline">
{countMap[customer.id] ?? 0}
</span>
<span className="text-slate-500 text-xs ml-1">Bestellung(en)</span>
</Link>
</td>
<td className="py-4 pr-4">
{customer.is_anonymized ? (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
<AlertTriangle className="w-3 h-3" /> Anonymisiert
</Badge>
) : (
<Badge className="bg-green-500/10 text-green-400 border-green-500/20">Aktiv</Badge>
)}
</td>
<td className="py-4 text-right">
{!customer.is_anonymized && (
<Link href={`/my-customers/${customer.id}`}>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white gap-1">
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
</Button>
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Akkordeon-Ansicht */}
<CustomerAccordionList customers={customers} />
{/* DSGVO-Hinweis */}
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 text-xs text-amber-300/80 space-y-1">

View File

@@ -0,0 +1,386 @@
'use client'
import React, { useState, useMemo } from 'react'
import Link from 'next/link'
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package, Search, ChevronLeft, ChevronRight, Building2, Monitor, RefreshCw, FileText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import type { Order } from '@/lib/types'
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
const statusClass: Record<string, string> = {
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
active: 'bg-green-500/20 text-green-400 border-green-500/30',
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
}
interface MyOrdersClientProps {
orders: Order[]
error: string | null
}
export function MyOrdersClient({ orders, error }: MyOrdersClientProps) {
const [searchTerm, setSearchTerm] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 10
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
// Volltextsuche filtern
const filteredOrders = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return orders
return orders.filter(o => {
// Anfragenummer
if (o.order_number?.toString().toLowerCase().includes(term)) return true
// Status
const label = statusLabel[o.status] || o.status
if (label.toLowerCase().includes(term) || o.status.toLowerCase().includes(term)) return true
// Datum
const dateStr = new Date(o.created_at).toLocaleDateString('de-DE')
if (dateStr.includes(term)) return true
// Endkunde (Firma / Name)
const customer = (o as any).end_customer_data || o.order_data?.end_customer
if (customer) {
if (customer.company_name?.toLowerCase().includes(term)) return true
if (customer.first_name?.toLowerCase().includes(term)) return true
if (customer.last_name?.toLowerCase().includes(term)) return true
if (customer.customer_number?.toLowerCase().includes(term)) return true
}
// Notizen
if (o.notes?.toLowerCase().includes(term)) return true
// Kassen & Module
const items = o.order_data?.items ?? []
for (const item of items) {
if (item.device_name?.toLowerCase().includes(term)) return true
if (item.product_name?.toLowerCase().includes(term)) return true
if (item.license_number?.toLowerCase().includes(term)) return true
for (const mod of item.selected_modules ?? []) {
if (mod.module_name?.toLowerCase().includes(term)) return true
}
}
return false
})
}, [orders, searchTerm])
// Paginierung berechnen
const totalPages = Math.ceil(filteredOrders.length / pageSize) || 1
const paginatedOrders = useMemo(() => {
const start = (currentPage - 1) * pageSize
return filteredOrders.slice(start, start + pageSize)
}, [filteredOrders, currentPage, pageSize])
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
setCurrentPage(1)
}
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
</Button>
</Link>
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<ShoppingBag className="w-8 h-8 text-primary" />
Meine Anfragen
</h1>
<p className="text-slate-400 text-sm mt-1">
Alle Ihre Bestellungen und Konfigurationen auf einen Blick.
</p>
</div>
</div>
<Link href="/order">
<Button className="bg-primary hover:bg-primary/90 text-white font-bold gap-2 shadow-[0_0_20px_rgba(59,130,246,0.3)]">
+ Neue Anfrage erstellen
</Button>
</Link>
</div>
{/* Suche & Statistik */}
<div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
type="text"
placeholder="Suchen nach Anfragenr., Kunde, Kasse, Produkt, Lizenz..."
value={searchTerm}
onChange={handleSearchChange}
className="pl-10 bg-slate-900/80 border-white/10 text-white placeholder:text-slate-500 rounded-xl h-11 text-sm focus:border-primary"
/>
</div>
<div className="text-xs text-slate-400 bg-slate-900/50 px-4 py-2.5 rounded-xl border border-white/10 shrink-0 text-center flex items-center justify-center gap-2">
<span>Anfragen:</span>
<span className="text-white font-bold text-sm">{filteredOrders.length}</span>
{searchTerm && <span className="text-slate-500">(gefiltert aus {orders.length})</span>}
</div>
</div>
{/* Fehler */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
Fehler beim Laden der Anfragen: {error}
</div>
)}
{/* Keine Anfragen (aus DB) */}
{!error && orders.length === 0 && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
<Package className="w-12 h-12 text-slate-600" />
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
<Link href="/order">
<Button>Jetzt konfigurieren</Button>
</Link>
</CardContent>
</Card>
)}
{/* Keine Treffer nach Suche */}
{!error && orders.length > 0 && filteredOrders.length === 0 && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Search className="w-10 h-10 text-slate-600" />
<p className="text-slate-300 font-medium">Keine Anfragen für &quot;{searchTerm}&quot; gefunden.</p>
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
Filter zurücksetzen
</Button>
</CardContent>
</Card>
)}
{/* Anfragenliste */}
{paginatedOrders.length > 0 && (
<div className="space-y-4">
{paginatedOrders.map((o) => {
const items = o.order_data?.items ?? []
const customer = (o as any).end_customer_data || o.order_data?.end_customer
return (
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-all duration-300 rounded-2xl overflow-hidden shadow-xl">
<CardContent className="p-6 space-y-5">
{/* Header-Zeile der Karte */}
<div className="flex items-start justify-between gap-4 flex-wrap pb-4 border-b border-white/5">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Anfragenummer</span>
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
{statusLabel[o.status] ?? o.status}
</Badge>
</div>
<p className="font-mono font-extrabold text-primary text-xl">#{o.order_number}</p>
</div>
{/* Endkunde Details (falls vorhanden) */}
{customer && (
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 px-3 flex items-center gap-3">
<Building2 className="w-4 h-4 text-sky-400 shrink-0" />
<div className="text-xs">
<p className="text-white font-bold">{customer.company_name || `${customer.first_name} ${customer.last_name}`}</p>
{(customer.first_name || customer.city) && (
<p className="text-slate-400 text-[11px]">
{[customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : null, customer.city].filter(Boolean).join(' · ')}
</p>
)}
</div>
</div>
)}
<div className="space-y-1 text-right ml-auto">
<p className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Datum & Preis</p>
<p className="text-slate-300 text-xs">
{new Date(o.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})}
</p>
<p className="font-extrabold text-white text-lg">
{fmt(o.total_price)}
</p>
</div>
</div>
{/* Kassen & Produkte Detail-Übersicht (Angelehnt an Kundenliste DeviceCard) */}
{items.length > 0 && (
<div className="space-y-3 pt-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<Monitor className="w-3.5 h-3.5 text-primary" />
Kassenaufstellung ({items.length} {items.length === 1 ? 'Kasse' : 'Kassen'}):
</p>
<div className="space-y-2.5">
{items.map((item, idx) => {
const devName = item.device_name || `Kasse ${idx + 1}`
const licNum = item.license_number
const modules = item.selected_modules || []
// Chips für Hauptprodukt und Module
const chips: string[] = []
if (item.product_name) chips.push(item.product_name)
for (const mod of modules) {
if (mod.module_name) chips.push(mod.module_name)
}
const itemPrice = (item as any).price || item.base_price || 0
const modulesPrice = modules.reduce((sum: number, m: any) => sum + ((m.price || 0) * (m.quantity || 1)), 0)
const deviceTotal = itemPrice + modulesPrice
return (
<div
key={idx}
className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 hover:border-slate-700 transition-all flex flex-col md:flex-row md:items-start justify-between gap-4 relative overflow-hidden"
>
{/* Kassen-Info links */}
<div className="space-y-2 min-w-[200px] flex-1">
{/* Kassen-Name + Lizenznummer daneben + Intervall */}
<div className="flex items-center gap-2 flex-wrap border-b border-white/5 pb-2">
<span className="px-2 py-0.5 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
{devName}
</span>
{licNum && (
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
{licNum}
</span>
)}
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${item.billing_interval === 'one_time' ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'} font-semibold uppercase`}>
{item.billing_interval === 'one_time' ? 'Kauf' : 'Abo'}
</span>
</div>
{/* Produkt + Modul Chips */}
{chips.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{chips.map((chip, cIdx) => (
<span
key={cIdx}
className="text-xs bg-white/5 border border-white/10 rounded-md px-2 py-0.5 text-slate-300"
>
{chip}
</span>
))}
</div>
)}
</div>
{/* Preis rechts */}
{deviceTotal > 0 && (
<div className="text-left md:text-right shrink-0 border-t md:border-t-0 pt-2 md:pt-0 border-white/5">
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Kassenwert</p>
<p className="font-bold text-white text-sm">
{fmt(deviceTotal)}
{item.billing_interval === 'monthly' && <span className="text-xs font-normal text-slate-400"> / mtl.</span>}
</p>
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Notizen (falls vorhanden) */}
{o.notes && (
<div className="bg-slate-900/40 p-2.5 rounded-xl border border-white/5 text-xs text-slate-400 flex items-start gap-2">
<FileText className="w-3.5 h-3.5 text-slate-500 shrink-0 mt-0.5" />
<p><span className="font-semibold text-slate-300">Anmerkungen:</span> {o.notes}</p>
</div>
)}
{/* Aktionsleiste */}
<div className="flex gap-2 flex-wrap pt-2 border-t border-white/5 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<Link href={`/order/success?id=${o.id}`}>
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs font-bold rounded-xl h-8">
Details ansehen
</Button>
</Link>
{o.status !== 'completed' && (
<Link href={`/order?id=${o.id}`}>
<Button variant="outline" size="sm" className="border-amber-500/30 hover:bg-amber-500/10 hover:border-amber-500/50 text-amber-400 text-xs font-bold gap-1 rounded-xl h-8">
<RefreshCw className="w-3 h-3" /> Konfiguration bearbeiten
</Button>
</Link>
)}
</div>
{o.pdf_url && (
<div className="flex gap-1">
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Ansehen
</a>
</Button>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
<a href={`/api/orders/${o.id}/download`} download>
<Download className="w-3.5 h-3.5 mr-1" /> Download
</a>
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* Paginierung (10 pro Seite) */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-white/10">
<span className="text-xs text-slate-400">
Seite <span className="text-white font-bold">{currentPage}</span> von <span className="text-white font-bold">{totalPages}</span>
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="border-white/10 text-xs gap-1 rounded-xl"
>
<ChevronLeft className="w-4 h-4" /> Vorherige
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="border-white/10 text-xs gap-1 rounded-xl"
>
Nächste <ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,27 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import type { Order } from '@/lib/types'
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
const statusClass: Record<string, string> = {
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
active: 'bg-green-500/20 text-green-400 border-green-500/30',
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
}
import { MyOrdersClient } from './MyOrdersClient'
export default async function MyOrdersPage() {
const supabase = await createClient()
@@ -47,134 +27,9 @@ export default async function MyOrdersPage() {
const { data: orders, error } = await query.order('created_at', { ascending: false })
return (
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
</Button>
</Link>
<div>
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
<ShoppingBag className="w-8 h-8 text-primary" />
Meine Anfragen
</h1>
<p className="text-slate-400 text-sm mt-1">
Alle Ihre Anfragen auf einen Blick inkl. aktuellem Status.
</p>
</div>
</div>
{/* Fehler */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
Fehler beim Laden der Anfragen: {error.message}
</div>
)}
{/* Keine Anfragen */}
{!error && (!orders || orders.length === 0) && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
<Package className="w-12 h-12 text-slate-600" />
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
<Link href="/order">
<Button>Jetzt konfigurieren</Button>
</Link>
</CardContent>
</Card>
)}
{/* Anfragenliste */}
<div className="space-y-4">
{(orders ?? []).map((order) => {
const o = order as Order
const items = o.order_data?.items ?? []
return (
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-colors">
<CardContent className="p-6 space-y-4">
{/* Kopfzeile */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<p className="text-xs text-slate-500 uppercase tracking-wider">Anfragenummer</p>
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
</div>
<div className="space-y-1 text-right">
<p className="text-xs text-slate-500 uppercase tracking-wider">Datum</p>
<p className="text-white text-sm">
{new Date(o.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-slate-500 uppercase tracking-wider">Status</p>
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
{statusLabel[o.status] ?? o.status}
</Badge>
</div>
<div className="space-y-1 text-right">
<p className="text-xs text-slate-500 uppercase tracking-wider">Gesamt</p>
<p className="font-bold text-white text-lg">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
</p>
</div>
</div>
{/* Produkte kurz */}
{items.length > 0 && (
<div className="flex flex-wrap gap-2">
{items.map((item, i) => (
<span
key={item.product_id}
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
>
{item.product_name}
{item.selected_modules.length > 0 && (
<span className="text-slate-500 ml-1">+{item.selected_modules.length} Module</span>
)}
</span>
))}
</div>
)}
{/* Aktionen */}
<div className="flex gap-2 flex-wrap pt-1">
<Link href={`/order/success?id=${o.id}`}>
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs">
Details ansehen
</Button>
</Link>
{o.status !== 'completed' && (
<Link href={`/order?id=${o.id}`}>
<Button variant="outline" size="sm" className="border-amber-500/20 hover:bg-amber-500/10 hover:border-amber-500/40 text-amber-400 text-xs">
Bearbeiten
</Button>
</Link>
)}
{o.pdf_url && (
<>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3 h-3 mr-1" /> Anfrage ansehen
</a>
</Button>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
<a href={`/api/orders/${o.id}/download`} download>
<Download className="w-3 h-3 mr-1" /> PDF
</a>
</Button>
</>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</div>
<MyOrdersClient
orders={(orders as Order[]) || []}
error={error ? error.message : null}
/>
)
}

90
shop/app/not-found.tsx Normal file
View File

@@ -0,0 +1,90 @@
'use client'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
import { ArrowLeft, Home } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function NotFound() {
return (
<div className="min-h-screen bg-transparent text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
<AsciiShaderBackground />
<div className="relative z-10 flex flex-col items-center justify-center min-h-screen px-4">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
className="text-center space-y-8 max-w-lg"
>
{/* Große 404 */}
<motion.h1
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.1, type: 'spring', stiffness: 100 }}
className="text-[10rem] md:text-[14rem] font-black leading-none tracking-tighter bg-gradient-to-b from-white via-slate-300 to-slate-600 bg-clip-text text-transparent select-none"
>
404
</motion.h1>
{/* Beschreibung */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="space-y-3"
>
<h2 className="text-2xl md:text-3xl font-bold text-white">
Seite nicht gefunden
</h2>
<p className="text-slate-400 text-sm md:text-base leading-relaxed max-w-md mx-auto">
Die angeforderte Seite existiert nicht oder wurde verschoben.
</p>
</motion.div>
{/* Buttons */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="flex flex-col sm:flex-row items-center justify-center gap-3 pt-4"
>
<Button
asChild
size="lg"
className="h-12 px-8 text-base gap-2 bg-gradient-to-r from-blue-600 to-sky-500 hover:from-blue-500 hover:to-sky-400 border-0 shadow-[0_0_20px_rgba(56,189,248,0.25)] hover:shadow-[0_0_30px_rgba(56,189,248,0.4)] transition-all"
>
<Link href="/">
<Home className="w-4 h-4" />
Zur Startseite
</Link>
</Button>
<Button
asChild
variant="ghost"
size="lg"
className="h-12 px-8 text-base gap-2 text-slate-400 hover:text-white border border-white/10 hover:border-white/20 hover:bg-white/5"
onClick={() => typeof window !== 'undefined' && window.history.back()}
>
<button type="button">
<ArrowLeft className="w-4 h-4" />
Zurück
</button>
</Button>
</motion.div>
{/* Subtle info */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.7 }}
className="text-xs text-slate-600 pt-8"
>
Fehlercode 404 · CASPOS Shop
</motion.p>
</motion.div>
</div>
</div>
)
}

View File

@@ -8,32 +8,41 @@ import { redirect } from 'next/navigation'
import { Suspense } from 'react'
interface PageProps {
searchParams: Promise<{ id?: string }>
searchParams: Promise<{ id?: string; orderId?: string; mode?: string; customer_id?: string; device_id?: string }>
}
export default async function OrderPage({ searchParams }: PageProps) {
const params = await searchParams
const orderId = params.orderId || params.id
return (
<div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30">
<div className="container mx-auto py-10">
<div className="text-center mb-12">
<h1 className="text-5xl font-extrabold tracking-tight mb-4 text-gradient">
Konfigurieren Sie Ihre Lösung
</h1>
<p className="text-slate-400 text-lg max-w-2xl mx-auto">
Wählen Sie das passende Paket und die benötigten Module für Ihr Business.
</p>
</div>
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
<OrderDataWrapper orderId={params.id} />
<OrderDataWrapper
orderId={orderId}
mode={params.mode}
customerId={params.customer_id}
deviceId={params.device_id}
/>
</Suspense>
</div>
</div>
)
}
async function OrderDataWrapper({ orderId }: { orderId?: string }) {
async function OrderDataWrapper({
orderId,
mode,
customerId,
deviceId,
}: {
orderId?: string
mode?: string
customerId?: string
deviceId?: string
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
@@ -48,7 +57,7 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
.eq('id', user.id)
.single()
const isAdmin = userData?.role === 'admin'
const isAdmin = userData?.role === 'admin' || userData?.role === 'verwaltung'
const hasCompany = !!userData?.company_id
if (!isAdmin && !hasCompany) {
@@ -76,14 +85,14 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
redirect('/order')
}
const isAdmin = userData?.role === 'admin'
const isAdmin = userData?.role === 'admin' || userData?.role === 'verwaltung'
const isOwner = orderData.user_id === user.id
const isCompanyMember = userData?.company_id && orderData.company_id === userData.company_id
if (!isAdmin && !isOwner && !isCompanyMember) {
redirect('/order')
}
if (orderData.status === 'completed' && !isAdmin) {
if (orderData.status === 'completed' && !isAdmin && mode !== 'extension' && mode !== 'upgrade') {
redirect('/order')
}
@@ -95,7 +104,7 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
// Admin sees all end customers across partners
let endCustomers = []
if (userData?.role === 'admin') {
if (userData?.role === 'admin' || userData?.role === 'verwaltung') {
try {
const adminClient = createAdminClient()
const { data } = await adminClient
@@ -125,10 +134,14 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
// Fetch companies list for admin
let companies: any[] = []
if (userData?.role === 'admin') {
if (userData?.role === 'admin' || userData?.role === 'verwaltung') {
companies = await getCompanies().catch(() => [])
}
const initialLastLicenseDate = initialOrder
? (initialOrder.order_data?.last_license_date || (initialOrder.created_at ? new Date(initialOrder.created_at).toISOString().split('T')[0] : ''))
: ''
return (
<OrderWizard
products={products}
@@ -136,8 +149,12 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
initialProfile={profile}
initialEndCustomers={endCustomers}
initialOrder={initialOrder}
isAdmin={userData?.role === 'admin'}
isAdmin={userData?.role === 'admin' || userData?.role === 'verwaltung'}
companies={companies}
upgradeMode={mode === 'upgrade'}
initialEndCustomerId={customerId || null}
lockedDeviceId={deviceId ? decodeURIComponent(deviceId) : null}
initialLastLicenseDate={initialLastLicenseDate}
/>
)
}

View File

@@ -39,7 +39,7 @@ export default async function OrderSuccessPage({
if (error || !order) redirect('/')
// Berechtigung prüfen
const isAdmin = dbUser?.role === 'admin'
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
const isOwner = order.user_id === user.id
const isCompanyMember = dbUser?.company_id && order.company_id === dbUser.company_id
@@ -49,6 +49,14 @@ export default async function OrderSuccessPage({
const o = order as Order
const items = o.order_data?.items ?? []
const groupedItems: Record<string, typeof items> = {}
items.forEach(item => {
const devName = item.device_name || 'Kasse 1'
if (!groupedItems[devName]) {
groupedItems[devName] = []
}
groupedItems[devName].push(item)
})
const taxRate = o.order_data?.tax_rate ?? 19
const oneTimeItems = items.filter((item) => item.billing_interval === 'one_time')
@@ -105,23 +113,30 @@ export default async function OrderSuccessPage({
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{/* Produkte */}
<div className="space-y-3">
{items.map((item) => (
<div key={item.product_id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-semibold text-white">{item.product_name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
<span className="text-slate-400 text-xs ml-1">
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
</span>
</span>
{/* Produkte gruppiert nach Kasse */}
<div className="space-y-4">
{Object.entries(groupedItems).map(([deviceName, devItems]) => (
<div key={deviceName} className="space-y-2 border-b border-white/5 pb-3 last:border-0 last:pb-0">
<div className="bg-white/5 px-2 py-1 rounded text-xs text-primary font-bold">
{deviceName === 'Zusatzleistung' ? 'Backoffice' : `Kasse: ${deviceName}`}
</div>
{item.selected_modules.map((mod) => (
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
<span>+ {mod.module_name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
{devItems.map((item) => (
<div key={item.product_id} className="space-y-1 pl-2">
<div className="flex justify-between text-sm">
<span className="font-semibold text-white">{item.product_name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
<span className="text-slate-400 text-xs ml-1">
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
</span>
</span>
</div>
{item.selected_modules.map((mod) => (
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
<span>+ {mod.module_name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
</div>
))}
</div>
))}
</div>
@@ -144,7 +159,7 @@ export default async function OrderSuccessPage({
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto Gesamtbetrag:</span>
<span className={monthlyNet > 0 ? "text-white" : "text-gradient"}>
<span className={monthlyNet > 0 ? "text-white" : "text-primary"}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
</span>
</div>
@@ -168,7 +183,7 @@ export default async function OrderSuccessPage({
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto Gesamtbetrag:</span>
<span className="text-gradient">
<span className="text-primary">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / Monat
</span>
</div>

16
shop/app/wizard/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { redirect } from 'next/navigation'
interface PageProps {
searchParams: Promise<{ id?: string; orderId?: string; mode?: string }>
}
export default async function WizardPage({ searchParams }: PageProps) {
const params = await searchParams
const id = params.orderId || params.id
const mode = params.mode
const query = new URLSearchParams()
if (id) query.set('orderId', id)
if (mode) query.set('mode', mode)
redirect(`/order?${query.toString()}`)
}

View File

@@ -0,0 +1,180 @@
'use client'
import React, { useEffect, useRef } from 'react'
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let animationFrameId: number
// Minimalistisches, ruhiges ASCII-Array
const chars = ['•', '∘', '+', '▪', '▫', ' ']
const fontSize = 18
let cols = 0
let rows = 0
const mouse = { x: 0, y: 0, targetX: 0, targetY: 0 }
let scrollProgress = 0
let targetScrollProgress = 0
const getDynamicHue = (): number => {
try {
const hex = getComputedStyle(document.documentElement).getPropertyValue('--primary-custom').trim() || '#2563eb'
let c = hex.replace('#', '')
if (c.length === 3) c = c.split('').map(x => x + x).join('')
const r = parseInt(c.substring(0, 2), 16) / 255
const g = parseInt(c.substring(2, 4), 16) / 255
const b = parseInt(c.substring(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
if (max !== min) {
const d = max - min
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return Math.round(h * 360)
} catch (e) {
return 217
}
}
const resize = () => {
const width = window.innerWidth
const height = window.innerHeight
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
cols = Math.ceil(width / fontSize)
rows = Math.ceil(height / fontSize)
if (mouse.x === 0 && mouse.y === 0) {
mouse.x = width / 2
mouse.y = height / 2
mouse.targetX = width / 2
mouse.targetY = height / 2
}
}
const onPointerMove = (e: PointerEvent) => {
mouse.targetX = e.clientX
mouse.targetY = e.clientY
}
const onScroll = () => {
const maxScroll = Math.max(
document.body.scrollHeight - window.innerHeight,
1
)
targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1)
}
resize()
window.addEventListener('resize', resize)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('scroll', onScroll, { passive: true })
const render = (time: number) => {
mouse.x += (mouse.targetX - mouse.x) * 0.05
mouse.y += (mouse.targetY - mouse.y) * 0.05
scrollProgress += (targetScrollProgress - scrollProgress) * 0.05
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Beim Scrollen sofort ausblenden (vollständig weg ab 150px Scroll-Tiefe)
const scrollFade = Math.max(1 - (window.scrollY / 150), 0)
if (scrollFade <= 0) {
animationFrameId = requestAnimationFrame(render)
return
}
ctx.font = `${fontSize}px monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const timeSec = time * 0.001
const activeHue = getDynamicHue()
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * fontSize + fontSize / 2
const y = r * fontSize + fontSize / 2
const dx = x - mouse.x
const dy = y - mouse.y
const dist = Math.sqrt(dx * dx + dy * dy)
const mouseInfluence = Math.exp(-dist / 250)
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
const normalizedFlow = (flowValue + 1) * 0.5
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
const char = chars[charIndex]
if (char === ' ') continue
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
const alpha = (0.35 + blendFactor * 0.6) * scrollFade
const lightness = 40 + Math.round(blendFactor * 50)
ctx.fillStyle = `hsl(${activeHue} 80% ${lightness}% / ${alpha.toFixed(2)})`
ctx.fillText(char, x, y)
}
}
animationFrameId = requestAnimationFrame(render)
}
animationFrameId = requestAnimationFrame(render)
return () => {
window.removeEventListener('resize', resize)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('scroll', onScroll)
cancelAnimationFrame(animationFrameId)
}
}, [])
return (
<div
className={`fixed inset-0 pointer-events-none z-0 overflow-hidden ${className}`}
style={{
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
}}
>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full block will-change-transform pointer-events-none"
/>
<div
className="absolute inset-0 w-full h-full pointer-events-none opacity-60"
style={{
backgroundSize: '40px 40px',
backgroundImage: `
linear-gradient(to right, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px),
linear-gradient(to bottom, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px)
`
}}
/>
</div>
)
}

View File

@@ -3,6 +3,8 @@
import { usePathname } from "next/navigation";
import { DemoWrapper } from "./DemoWrapper";
import { AlertTriangle } from "lucide-react";
interface HeaderWrapperProps {
navbar: React.ReactNode;
children: React.ReactNode;
@@ -17,21 +19,24 @@ export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
return <>{children}</>;
}
const isWizard = pathname === "/order" || pathname === "/wizard";
return (
<div className="flex flex-col min-h-screen bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white">
<div className={`flex flex-col ${isWizard ? "h-screen overflow-hidden" : "min-h-screen"} bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white`}>
{/* Demo Banner */}
<DemoWrapper>
<div
id="demo-banner"
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50"
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50 flex items-center justify-center gap-1.5"
>
Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.</span>
</div>
</DemoWrapper>
{navbar}
<main className="flex-1">
<main className={`flex-1 ${isWizard ? "overflow-hidden" : ""}`}>
{children}
</main>
</div>

View File

@@ -18,17 +18,34 @@ import {
Zap,
ArrowUpRight,
ShieldCheck,
Calendar,
Sparkles,
Activity
} from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
import { ProcessSteps } from '@/components/ProcessSteps'
import { ScrollIndicator } from '@/components/ScrollIndicator'
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
import { useTheme } from '@/components/ThemeProvider'
interface HomeClientProps {
initialUser: User | null
}
export function HomeClient({ initialUser }: HomeClientProps) {
const { branding } = useTheme()
const [user, setUser] = useState<User | null>(initialUser)
const [mounted, setMounted] = useState(false)
const [calendarOpen, setCalendarOpen] = useState(false)
useEffect(() => {
setMounted(true)
@@ -68,39 +85,10 @@ export function HomeClient({ initialUser }: HomeClientProps) {
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
<div className="min-h-screen bg-transparent text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
<AsciiShaderBackground />
{/* Background Decorative Animated Blobs */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
<motion.div
animate={{
scale: [1, 1.15, 0.95, 1],
rotate: [0, 90, 180, 270, 360],
x: [0, 40, -30, 0],
y: [0, -20, 50, 0]
}}
transition={{
duration: 20,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
/>
<motion.div
animate={{
scale: [1, 0.9, 1.1, 1],
rotate: [0, -90, -180, -270, -360],
x: [0, -30, 40, 0],
y: [0, 50, -30, 0]
}}
transition={{
duration: 25,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
/>
</div>
{/* Hero Section */}
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
@@ -110,27 +98,14 @@ export function HomeClient({ initialUser }: HomeClientProps) {
variants={containerVariants}
className="container max-w-6xl mx-auto flex flex-col items-center text-center space-y-8"
>
{/* Badge */}
<motion.div
variants={itemVariants}
className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-xs sm:text-sm text-blue-400 font-semibold tracking-wide backdrop-blur-md shadow-[0_0_15px_rgba(59,130,246,0.1)]"
>
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
>
<Zap className="w-3.5 h-3.5 text-blue-400 fill-blue-400/20" />
</motion.div>
CASPOS Lizenz-Portal
</motion.div>
{/* Headline */}
<div className="space-y-4 max-w-4xl">
<motion.h1
variants={itemVariants}
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight bg-clip-text text-transparent bg-gradient-to-b from-white via-slate-100 to-slate-400"
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight text-white"
>
CASPOS Software <span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-500 via-indigo-400 to-purple-500 font-black">Lizenzen & Module</span>
CASPOS Software <span className="text-blue-500 font-black">Lizenzen & Module</span>
</motion.h1>
<motion.p
variants={itemVariants}
@@ -146,7 +121,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
className="flex flex-col sm:flex-row items-center justify-center gap-4 w-full max-w-md pt-4"
>
<Link href="/order" className="w-full sm:w-auto">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-bold transition-all duration-300 hover:scale-[1.02] bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-[0_0_25px_rgba(37,99,235,0.25)] hover:shadow-[0_0_35px_rgba(37,99,235,0.45)] border-none rounded-xl">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-semibold bg-blue-600 hover:bg-blue-500 text-white border border-blue-700/50 rounded-xl transition-all duration-200">
Lizenz anfragen <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
@@ -163,51 +138,71 @@ export function HomeClient({ initialUser }: HomeClientProps) {
variants={itemVariants}
className="w-full max-w-3xl pt-12"
>
<div className="relative p-6 rounded-2xl border border-slate-900/80 bg-slate-950/40 backdrop-blur-xl shadow-2xl">
{/* Card border glow decoration */}
<div className="absolute inset-0 rounded-2xl border border-blue-500/10 pointer-events-none" />
<p className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
<div className="relative p-6 rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur-md shadow-lg">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
Mein Dashboard Schnellzugriff
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Link href="/my-customers" className="group">
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200 h-full"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
<Users className="w-5.5 h-5.5" />
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors shrink-0">
<Users className="w-5 h-5" />
</div>
<div className="text-left">
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Kundenliste</span>
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Kunden & Lizenzen verwalten</span>
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Verwalten</span>
</div>
</div>
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
</motion.div>
</Link>
<Link href="/my-orders" className="group">
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200 h-full"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
<ClipboardList className="w-5.5 h-5.5" />
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors shrink-0">
<ClipboardList className="w-5 h-5" />
</div>
<div className="text-left">
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Bestellungen</span>
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Aufträge & Status einsehen</span>
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Status einsehen</span>
</div>
</div>
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
</motion.div>
</Link>
<button type="button" onClick={() => setCalendarOpen(true)} className="group text-left w-full">
<motion.div
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200 h-full"
>
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors shrink-0">
<Calendar className="w-5 h-5" />
</div>
<div className="text-left">
<div className="flex items-center gap-1.5">
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Termine</span>
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-md">Geplant</span>
</div>
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Terminkalender</span>
</div>
</div>
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
</motion.div>
</button>
</div>
</div>
</motion.div>
@@ -216,141 +211,65 @@ export function HomeClient({ initialUser }: HomeClientProps) {
</motion.div>
</section>
{/* Grid of modules */}
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50 relative">
<div className="container max-w-6xl mx-auto px-4 relative">
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 border border-indigo-500/20 px-3 py-1 rounded-full">
<Sparkles className="w-3 h-3" />CASPOS Sites
{/* Dialog / Popup für Termin-Kalender (Geplant) */}
<Dialog open={calendarOpen} onOpenChange={setCalendarOpen}>
<DialogContent className="bg-slate-900 border-slate-800 text-slate-100 max-w-md">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20">
<Calendar className="w-6 h-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
Termin-Kalender
<span className="px-2 py-0.5 text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-full">
In Planung
</span>
</DialogTitle>
</div>
</div>
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
Workspace-Zentrale
</h2>
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
Die zentralen Plattformen für unsere tägliche Zusammenarbeit, Dokumentation und den Support.
</p>
<DialogDescription className="text-slate-400 text-sm leading-relaxed pt-2">
Der Termin-Kalender bietet in Kürze eine Übersicht für anstehende Schulungen, Online- & Vor-Ort-Workshops sowie Termine für Blauer-Mittwoch-Partner zur direkten Anmeldung.
</DialogDescription>
</DialogHeader>
<div className="p-4 my-2 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-400 space-y-2">
<div className="flex items-center justify-between text-slate-300 font-medium">
<span>Geplante Features & Angebote:</span>
</div>
<ul className="list-disc list-inside space-y-1 text-slate-400">
<li>Anmeldung zu Online- & Vor-Ort-Workshops</li>
<li>Anstehende Schulungstermine auf einen Blick</li>
<li>Spezielle Schulungen für Blauer-Mittwoch-Partner</li>
<li>Kalender-Export (.ics / Google / Outlook)</li>
</ul>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Card 1 */}
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
<DialogFooter>
<Button
variant="outline"
onClick={() => setCalendarOpen(false)}
className="w-full sm:w-auto border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700 hover:text-white"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
<MessageSquare className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Zulip</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Treten Sie unserem Partner-Chat bei! tauschen Sie sich mit anderen CASPOS Partnern aus.
</p>
</div>
</motion.div></a>
Schließen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Card 2 */}
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
<BookOpen className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Wiki</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Ihr Wissens-Portal für die CASPOS Produktfamilie.
</p>
</div>
</motion.div></a>
{/* Card 3 */}
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
<LifeBuoy className="h-6 w-6" />
</div>
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Support</h3>
<p className="text-slate-400 text-sm leading-relaxed">
Sie haben ein technisches Problem in der CASPOS? Erstellen Sie jetzt ein Ticket!
</p>
</div>
</motion.div></a>
</div>
</div>
</section>
{/* Grid of modules / Workspace-Zentrale */}
<WorkspaceZentrale />
{/* Stepper info */}
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
<div className="container max-w-5xl mx-auto px-4">
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
<Activity className="w-3 h-3" /> Ablauf
</div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
Wie funktioniert die Bereitstellung?
</h2>
</div>
<div className="relative">
{/* Connection line for Desktop */}
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
<div className="grid gap-8 md:grid-cols-3 relative z-10">
{/* Step 1 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
1
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Kunde wählen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Gewünschten Kunden auswählen, der die Lizenz erhalten soll.
</p>
</div>
{/* Step 2 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
2
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Lizenz wählen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Gewünschte Lizenzen für den Kunden auswählen und den Bestellung abschließen.
</p>
</div>
{/* Step 3 */}
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
3
</div>
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Live gehen</h4>
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
Nach dem Erhalt der Lizenz Live gehen.
</p>
</div>
</div>
</div>
</div>
</section>
<ProcessSteps />
{/* Footer */}
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
<p className="text-xs text-slate-500">
{branding?.developerFooter || (branding?.companyName ? `© ${new Date().getFullYear()} ${branding.companyName}. Alle Rechte vorbehalten.` : `© ${new Date().getFullYear()} B2B Shop. Alle Rechte vorbehalten.`)}
</p>
<nav className="flex gap-6">
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>

View File

@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { signOut } from "@/lib/actions/auth";
import { resolveSupabaseUrl } from "@/lib/utils";
import { DemoWrapper } from "./DemoWrapper";
import { useTheme } from "@/components/ThemeProvider";
interface NavbarClientProps {
user: User | null;
@@ -18,6 +19,7 @@ interface NavbarClientProps {
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
const router = useRouter();
const { branding } = useTheme();
const [currentUser, setCurrentUser] = useState<User | null>(user);
const [userRole, setUserRole] = useState<string>(role);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -98,7 +100,15 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
<>
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
<Link className="flex items-center justify-center gap-2 group" href="/">
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
{branding?.logoUrl ? (
<img src={branding.logoUrl} alt={branding.companyName || "Logo"} className="h-8 max-w-[180px] object-contain" />
) : branding?.companyName ? (
<span className="font-extrabold text-base tracking-tight text-white group-hover:text-primary transition">
{branding.companyName}
</span>
) : (
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
)}
<DemoWrapper>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
@@ -110,7 +120,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
<nav className="hidden md:flex gap-6 items-center">
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://wiki.caspos.de"
href="https://support.teamzone.softengine.de"
target="_blank"
rel="noopener noreferrer"
>
@@ -136,17 +146,20 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
<div className="h-4 w-px bg-white/10 mx-2" />
{currentUser ? (
<div className="relative">
<div
className="relative"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<Button
variant="outline"
onClick={() => setOpen(!open)}
className="border-white/10 hover:bg-white/5 gap-2 text-white"
>
<UserIcon className="w-4 h-4 text-primary" />
<span className="max-w-[120px] truncate">{currentUser.email}</span>
</Button>
{open && (
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10">
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10 before:absolute before:-top-3 before:left-0 before:w-full before:h-3">
<div className="px-3 py-1.5 text-[11px] truncate border-b text-slate-400 border-slate-100 dark:border-white/5 mb-1">
{currentUser.email}
</div>
@@ -197,7 +210,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
</div>
) : (
<Button asChild className="bg-primary hover:bg-primary/95 text-white dark:text-blue-500">
<Button asChild className="bg-primary hover:bg-primary/95 text-white">
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
</Button>
)}
@@ -287,7 +300,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
<LogOut className="w-4 h-4" /> Abmelden
</Button>
) : (
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white dark:text-blue-500" onClick={() => setIsMobileMenuOpen(false)}>
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white" onClick={() => setIsMobileMenuOpen(false)}>
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
</Button>
)}

View File

@@ -0,0 +1,155 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
interface Step {
number: string
title: string
description: string
}
const steps: Step[] = [
{
number: '1',
title: 'Kunde wählen',
description: 'Gewünschten Kunden auswählen, der die Lizenz erhalten soll.'
},
{
number: '2',
title: 'Lizenz wählen',
description: 'Gewünschte Lizenzen für den Kunden auswählen und die Bestellung abschließen.'
},
{
number: '3',
title: 'Live gehen',
description: 'Nach dem Erhalt der Lizenz direkt live gehen und durchstarten.'
}
]
const containerVariants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.2,
staggerChildren: 0.35
}
}
}
const stepVariants = {
hidden: {
opacity: 0,
y: 20
},
visible: {
opacity: 1,
y: 0,
transition: {
type: 'spring' as const,
stiffness: 70,
damping: 16
}
}
}
export function ProcessSteps() {
return (
<section className="w-full py-16 px-4 relative z-10">
<div className="container max-w-5xl mx-auto flex flex-col items-center">
{/* Section Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ type: 'spring' as const, stiffness: 60, damping: 15 }}
className="text-center space-y-3 mb-16 max-w-2xl"
>
<h2 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
Wie funktioniert die Bereitstellung?
</h2>
<p className="text-slate-400 text-base sm:text-lg leading-relaxed">
In nur drei einfachen Schritten richten Sie CASPOS Lizenzen für Ihre Kunden ein.
</p>
</motion.div>
{/* Horizontale Timeline */}
<div className="relative w-full">
{/* Basis-Linie exakt zwischen Schritt 1 und 3 */}
<div className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-white/10 z-0 rounded-full" />
{/* Animierte, aufgeladene Linie (sky-500) */}
<motion.div
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true, margin: '-50px' }}
transition={{
duration: 1.5,
ease: 'easeInOut',
delay: 0.2
}}
className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-sky-500 origin-left z-0 rounded-full shadow-[0_0_12px_rgba(14,165,233,0.8)]"
/>
{/* 3 Schritte Nebeneinander */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-50px' }}
className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6 w-full relative z-10"
>
{steps.map((step, index) => {
const isLiveStep = index === 2
return (
<motion.div
key={step.number}
variants={stepVariants}
className="group flex flex-col items-center text-center space-y-4 cursor-pointer relative"
>
{/* Nummer im runden Kreis auf der Linie */}
<div className="relative">
<motion.div
whileHover={{ scale: 1.15 }}
transition={{ type: 'spring' as const, stiffness: 300, damping: 20 }}
className="w-12 h-12 rounded-full bg-slate-950 border-2 border-slate-700 text-slate-300 font-extrabold text-lg flex items-center justify-center group-hover:border-sky-400 group-hover:text-sky-400 group-hover:bg-slate-900 group-hover:shadow-[0_0_20px_rgba(56,189,248,0.4)] transition-all duration-300 z-10 relative"
>
{step.number}
</motion.div>
{/* Pulsierender Ziel-Ring bei Schritt 3 */}
{isLiveStep && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: 1.6 }}
className="absolute inset-0 rounded-full border-2 border-emerald-400/60 pointer-events-none animate-ping"
/>
)}
</div>
{/* Text-Inhalt zentriert ohne Boxen-Hintergrund */}
<div className="space-y-2 max-w-[280px]">
<h3 className="text-xl font-bold text-white group-hover:text-sky-400 transition-colors duration-300 flex items-center justify-center gap-2">
{step.title}
{isLiveStep && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
LIVE
</span>
)}
</h3>
<p className="text-slate-400 text-sm leading-relaxed">
{step.description}
</p>
</div>
</motion.div>
)
})}
</motion.div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import React from 'react'
import { motion, useScroll, useSpring } from 'framer-motion'
export function ScrollIndicator() {
const { scrollYProgress } = useScroll()
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
})
return (
<motion.div
className="fixed top-0 left-0 right-0 h-[3px] origin-left z-50 pointer-events-none"
style={{
scaleX,
background: 'linear-gradient(90deg, var(--primary-custom, #2563eb) 0%, var(--accent-custom, #38bdf8) 100%)',
boxShadow: '0 0 12px var(--primary-custom, rgba(37,99,235,0.8))'
}}
/>
)
}

View File

@@ -3,30 +3,47 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User } from 'lucide-react'
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User, MapPin, Receipt, Palette, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { createClient } from '@/lib/supabase/client'
import { completeSetup } from '@/lib/actions/setup'
import { completeSetup, testSmtpConfig } from '@/lib/actions/setup'
import { ColorThemePicker } from '@/components/admin/ColorThemePicker'
export function SetupWizard() {
const router = useRouter()
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const [testSmtpLoading, setTestSmtpLoading] = useState(false)
const [testSmtpResult, setTestSmtpResult] = useState<{ success: boolean; message: string } | null>(null)
// Step 2 Form: Admin account
// Step 2: Admin Account
const [adminForm, setAdminForm] = useState({
email: '',
password: '',
confirmPassword: '',
companyName: '',
firstName: '',
lastName: '',
})
// Step 3 Form: SMTP Config
// Step 3: Firmendaten & Rechnungsadresse
const [brandingForm, setBrandingForm] = useState({
companyName: '',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
})
// Step 5: SMTP Config
const [smtpForm, setSmtpForm] = useState({
host: '',
port: '587',
@@ -35,36 +52,67 @@ export function SetupWizard() {
pass: '',
})
// Validation checks for Admin form
// Validations
const isAdminFormValid =
adminForm.email.includes('@') &&
adminForm.password.length >= 6 &&
adminForm.password === adminForm.confirmPassword &&
adminForm.companyName.trim().length > 0 &&
adminForm.firstName.trim().length > 0 &&
adminForm.lastName.trim().length > 0
// Validation checks for SMTP form
const isSmtpFormValid =
smtpForm.host.trim().length > 0 &&
!isNaN(Number(smtpForm.port)) &&
smtpForm.user.trim().length > 0
const isCompanyFormValid =
brandingForm.companyName.trim().length > 0 &&
brandingForm.street.trim().length > 0 &&
brandingForm.zip.trim().length > 0 &&
brandingForm.city.trim().length > 0 &&
(brandingForm.sameBillingAddress ||
(brandingForm.billingStreet.trim().length > 0 &&
brandingForm.billingZip.trim().length > 0 &&
brandingForm.billingCity.trim().length > 0))
const handleTestSmtp = async () => {
setTestSmtpLoading(true)
setTestSmtpResult(null)
const res = await testSmtpConfig(
{
host: smtpForm.host,
port: Number(smtpForm.port),
secure: smtpForm.secure,
user: smtpForm.user,
pass: smtpForm.pass,
},
adminForm.email
)
setTestSmtpResult(res)
setTestSmtpLoading(false)
}
const handleFinishSetup = async () => {
if (!isAdminFormValid || !isSmtpFormValid) return
if (!isAdminFormValid || !isCompanyFormValid) return
setLoading(true)
setErrorMsg('')
try {
// 1. Submit details via server action
const res = await completeSetup(
{
email: adminForm.email,
password: adminForm.password,
companyName: adminForm.companyName,
companyName: brandingForm.companyName,
firstName: adminForm.firstName,
lastName: adminForm.lastName,
},
{
street: brandingForm.street,
zip: brandingForm.zip,
city: brandingForm.city,
billingStreet: brandingForm.billingStreet,
billingZip: brandingForm.billingZip,
billingCity: brandingForm.billingCity,
sameBillingAddress: brandingForm.sameBillingAddress,
colorScheme: brandingForm.colorScheme,
primaryColor: brandingForm.primaryColor,
accentColor: brandingForm.accentColor,
},
{
host: smtpForm.host,
port: Number(smtpForm.port),
@@ -80,20 +128,8 @@ export function SetupWizard() {
return
}
// 2. Automatischer Login für flüssiges Erlebnis
const supabase = createClient()
const { error: loginError } = await supabase.auth.signInWithPassword({
email: adminForm.email,
password: adminForm.password,
})
if (loginError) {
console.error('Auto login failed:', loginError)
// Redirect anyway since setup is done
}
// 3. Weiterleitung
router.push('/')
// Setup erfolgreich Weiterleitung zur Login-Seite
router.push('/auth/login?setup=success')
router.refresh()
} catch (e: any) {
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
@@ -103,19 +139,19 @@ export function SetupWizard() {
return (
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
{/* Dynamic Background Glow */}
{/* Background Glow */}
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/10 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-500/10 blur-[120px]" />
<div className="w-full max-w-xl relative z-10">
<div className="w-full max-w-2xl relative z-10 my-8">
{/* Step Indicator Header */}
<div className="flex items-center justify-between mb-8 px-2">
<div className="flex items-center justify-between mb-6 px-2">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight text-gradient bg-gradient-to-r from-blue-400 to-indigo-400">CASPOS</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">Initialisierung</span>
<span className="text-xl font-bold tracking-tight text-blue-400">B2B Shop</span>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">made by hephex</span>
</div>
<div className="flex gap-1.5">
{[1, 2, 3].map((s) => (
{[1, 2, 3, 4, 5].map((s) => (
<div
key={s}
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
@@ -126,13 +162,13 @@ export function SetupWizard() {
</div>
<AnimatePresence mode="wait">
{/* STEP 1: Willkommen */}
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400 mx-auto shadow-inner">
@@ -140,9 +176,9 @@ export function SetupWizard() {
</div>
<div className="text-center space-y-2">
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
<p className="text-slate-400 leading-relaxed">
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
<h1 className="text-3xl font-extrabold tracking-tight">B2B Shop made by hephex</h1>
<p className="text-slate-400 leading-relaxed text-sm">
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop ein. Wir konfigurieren Administrator-Zugang, Firmendaten, Rechnungsadresse und Ihr persönliches Farbschema.
</p>
</div>
@@ -153,11 +189,19 @@ export function SetupWizard() {
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Automatische Schema-Updates auf dem neuesten Stand</span>
<span>9 vordefinierte Farbpaletten + Custom Farbwähler</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>SMTP Mailversand für direkte Anfragebestätigungen</span>
<span>Lizenzserver-Anbindung</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Stammdatenverwaltung</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-400 shrink-0" />
<span>Automatische Adress- & Rechnungsverwaltung</span>
</div>
</div>
@@ -171,13 +215,13 @@ export function SetupWizard() {
</motion.div>
)}
{/* STEP 2: Admin-Konto */}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
@@ -186,7 +230,7 @@ export function SetupWizard() {
Admin-Konto anlegen
</h2>
<p className="text-slate-400 text-sm">
Erstellen Sie den ersten Administrator-Benutzer. Dieser erhält vollen Zugriff auf das System.
Erstellen Sie das erste Administrator-Konto für vollen Systemzugriff.
</p>
</div>
@@ -218,19 +262,6 @@ export function SetupWizard() {
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Unternehmen / Partnername *</Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={adminForm.companyName}
onChange={(e) => setAdminForm({ ...adminForm, companyName: e.target.value })}
placeholder="Name Ihrer Firma"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
<div className="relative">
@@ -275,7 +306,7 @@ export function SetupWizard() {
</div>
</div>
<div className="flex gap-3">
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
Zurück
</Button>
@@ -283,6 +314,203 @@ export function SetupWizard() {
onClick={() => setStep(3)}
disabled={!isAdminFormValid}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zu Firmendaten
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</motion.div>
)}
{/* STEP 3: Firmendaten & Rechnungsadresse */}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Building className="w-6 h-6 text-blue-400" />
Firmendaten & Adressen
</h2>
<p className="text-slate-400 text-sm">
Tragen Sie Ihren Firmennamen, die Anschrift und die Rechnungsadresse ein.
</p>
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Firmenname *</Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
value={brandingForm.companyName}
onChange={(e) => setBrandingForm({ ...brandingForm, companyName: e.target.value })}
placeholder="z. B. CASPOS Software GmbH"
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
/>
</div>
</div>
{/* Anschrift */}
<div className="space-y-3 pt-1 border-t border-slate-800">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Firmenanschrift
</span>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Straße & Hausnummer *</Label>
<Input
value={brandingForm.street}
onChange={(e) => setBrandingForm({ ...brandingForm, street: e.target.value })}
placeholder="Musterstraße 12"
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600 text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">PLZ *</Label>
<Input
value={brandingForm.zip}
onChange={(e) => setBrandingForm({ ...brandingForm, zip: e.target.value })}
placeholder="12345"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">Ort *</Label>
<Input
value={brandingForm.city}
onChange={(e) => setBrandingForm({ ...brandingForm, city: e.target.value })}
placeholder="Musterstadt"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
</div>
</div>
{/* Rechnungsadresse */}
<div className="space-y-3 pt-2 border-t border-slate-800">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
</span>
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
<input
type="checkbox"
checked={brandingForm.sameBillingAddress}
onChange={(e) =>
setBrandingForm({ ...brandingForm, sameBillingAddress: e.target.checked })
}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500"
/>
Gleiche wie Anschrift
</label>
</div>
{!brandingForm.sameBillingAddress && (
<div className="space-y-3 p-3 rounded-xl bg-white/5 border border-white/10">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Rechnungsstraße & Nr. *</Label>
<Input
value={brandingForm.billingStreet}
onChange={(e) =>
setBrandingForm({ ...brandingForm, billingStreet: e.target.value })
}
placeholder="Rechnungsstraße 45"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">PLZ *</Label>
<Input
value={brandingForm.billingZip}
onChange={(e) =>
setBrandingForm({ ...brandingForm, billingZip: e.target.value })
}
placeholder="54321"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">Ort *</Label>
<Input
value={brandingForm.billingCity}
onChange={(e) =>
setBrandingForm({ ...brandingForm, billingCity: e.target.value })
}
placeholder="Rechnungsstadt"
className="bg-white/5 border-white/10 text-white text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={() => setStep(2)} className="text-white hover:bg-white/5">
Zurück
</Button>
<Button
onClick={() => setStep(4)}
disabled={!isCompanyFormValid}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zum Farbschema
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</motion.div>
)}
{/* STEP 4: Farbschema & Live-Vorschau */}
{step === 4 && (
<motion.div
key="step4"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Palette className="w-6 h-6 text-blue-400" />
Farbschema & Webshop Styling
</h2>
<p className="text-slate-400 text-sm">
Wählen Sie aus 9 vorgegebenen Farbpaletten oder erstellen Sie eine eigene Farbkombination mit Live-Vorschau.
</p>
</div>
<ColorThemePicker
colorScheme={brandingForm.colorScheme}
primaryColor={brandingForm.primaryColor}
accentColor={brandingForm.accentColor}
companyName={brandingForm.companyName}
onChange={(scheme, primary, accent) =>
setBrandingForm({
...brandingForm,
colorScheme: scheme,
primaryColor: primary,
accentColor: accent,
})
}
/>
<div className="flex gap-3 pt-2">
<Button variant="ghost" onClick={() => setStep(3)} className="text-white hover:bg-white/5">
Zurück
</Button>
<Button
onClick={() => setStep(5)}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
>
Weiter zu SMTP
<ArrowRight className="w-4 h-4 ml-2" />
@@ -291,22 +519,27 @@ export function SetupWizard() {
</motion.div>
)}
{step === 3 && (
{/* STEP 5: SMTP Mailserver */}
{step === 5 && (
<motion.div
key="step3"
key="step5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.3 }}
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
>
<div className="space-y-1">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Mail className="w-6 h-6 text-blue-400" />
SMTP-Mailserver konfigurieren
</h2>
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Mail className="w-6 h-6 text-blue-400" />
SMTP-Mailserver
</h2>
<span className="text-[10px] uppercase font-bold tracking-wider px-2.5 py-1 rounded-full bg-slate-800 text-amber-400 border border-amber-500/20">
Optional
</span>
</div>
<p className="text-slate-400 text-sm">
Tragen Sie Ihre Mailserver-Daten ein, um automatisierte E-Mails an Partner und Kunden senden zu können.
Tragen Sie Ihre Mailserver-Daten ein oder überspringen Sie diesen Schritt. Sie können die Einstellungen jederzeit im Admin-Bereich anpassen.
</p>
</div>
@@ -319,7 +552,7 @@ export function SetupWizard() {
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Host *</Label>
<Label className="text-slate-300 text-xs">SMTP Host</Label>
<Input
value={smtpForm.host}
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
@@ -328,7 +561,7 @@ export function SetupWizard() {
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
<Label className="text-slate-300 text-xs">SMTP Port</Label>
<Input
value={smtpForm.port}
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
@@ -339,7 +572,7 @@ export function SetupWizard() {
</div>
<div className="space-y-1.5">
<Label className="text-slate-300 text-xs">Benutzername *</Label>
<Label className="text-slate-300 text-xs">Benutzername</Label>
<Input
value={smtpForm.user}
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
@@ -359,32 +592,73 @@ export function SetupWizard() {
/>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="smtp_secure"
checked={smtpForm.secure}
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
Sichere Verbindung (SSL/TLS anstelle STARTTLS)
</Label>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="smtp_secure"
checked={smtpForm.secure}
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
/>
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
Sichere Verbindung (SSL/TLS)
</Label>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleTestSmtp}
disabled={testSmtpLoading || !smtpForm.host || !smtpForm.user}
className="bg-white/10 hover:bg-white/20 text-white text-xs border border-white/10 flex items-center gap-1.5"
>
{testSmtpLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Testen...
</>
) : (
<>
<Send className="w-3.5 h-3.5 text-blue-400" /> Test-E-Mail senden
</>
)}
</Button>
</div>
{testSmtpResult && (
<div
className={`p-3 rounded-lg border text-xs font-medium ${
testSmtpResult.success
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
: 'bg-red-500/10 border-red-500/30 text-red-400'
}`}
>
{testSmtpResult.message}
</div>
)}
</div>
<div className="flex gap-3">
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button
variant="ghost"
onClick={() => setStep(2)}
onClick={() => setStep(4)}
disabled={loading}
className="text-white hover:bg-white/5"
>
Zurück
</Button>
<Button
variant="outline"
onClick={handleFinishSetup}
disabled={!isSmtpFormValid || loading}
disabled={loading}
className="border-slate-700 text-slate-300 hover:text-white"
>
Überspringen
</Button>
<Button
onClick={handleFinishSetup}
disabled={loading}
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
>
{loading ? (

View File

@@ -0,0 +1,111 @@
'use client'
import { useEffect, useState, createContext, useContext } from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { getBrandingSettings } from '@/lib/actions/branding'
import { PRESET_COLOR_SCHEMES, BrandingSettings } from '@/lib/constants/branding'
function hexToHsl(hex: string): string {
try {
let c = hex.replace('#', '')
if (c.length === 3) c = c.split('').map(x => x + x).join('')
const r = parseInt(c.substring(0, 2), 16) / 255
const g = parseInt(c.substring(2, 4), 16) / 255
const b = parseInt(c.substring(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0, s = 0, l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break
case g: h = (b - r) / d + 2; break
case b: h = (r - g) / d + 4; break
}
h /= 6
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
} catch (e) {
return '217 91% 60%'
}
}
const ThemeContext = createContext<{
branding: BrandingSettings | null
refreshBranding: () => Promise<void>
}>({
branding: null,
refreshBranding: async () => {},
})
export function ThemeProvider({
children,
...props
}: {
children: React.ReactNode
[key: string]: any
}) {
const [branding, setBranding] = useState<BrandingSettings | null>(null)
const loadTheme = async () => {
try {
const settings = await getBrandingSettings()
setBranding(settings)
// Preset Ermittlung
const preset = PRESET_COLOR_SCHEMES.find(p => p.id === settings.colorScheme) || PRESET_COLOR_SCHEMES[3]
const isCustom = settings.colorScheme === 'custom'
const primary = isCustom ? (settings.primaryColor || '#2563eb') : preset.primary
const accent = isCustom ? (settings.accentColor || '#38bdf8') : preset.accent
const primaryHsl = hexToHsl(primary)
const accentHsl = hexToHsl(accent)
const root = document.documentElement
// 10 Color CSS Tokens applied globally onto :root
root.style.setProperty('--primary', primaryHsl)
root.style.setProperty('--ring', primaryHsl)
root.style.setProperty('--accent', accentHsl)
root.style.setProperty('--primary-custom', primary)
root.style.setProperty('--accent-custom', accent)
root.style.setProperty('--success-custom', settings.successColor || preset.success)
root.style.setProperty('--warning-custom', settings.warningColor || preset.warning)
root.style.setProperty('--destructive-custom', settings.destructiveColor || preset.destructive)
root.style.setProperty('--bg-glow-1', isCustom ? primary : preset.bgGlow1)
root.style.setProperty('--bg-glow-2', isCustom ? accent : preset.bgGlow2)
root.style.setProperty('--gradient-from', isCustom ? primary : preset.gradientFrom)
root.style.setProperty('--gradient-to', isCustom ? accent : preset.gradientTo)
root.style.setProperty('--card-border-glow', isCustom ? `${accent}40` : preset.cardBorder)
root.style.setProperty('--text-highlight', isCustom ? accent : preset.textHighlight)
root.style.setProperty('--button-bg', isCustom ? primary : preset.buttonBg)
root.style.setProperty('--ring-color', isCustom ? primary : preset.ringColor)
} catch (e) {
console.error('Failed to load branding theme:', e)
}
}
useEffect(() => {
loadTheme()
}, [])
return (
<NextThemesProvider {...props}>
<ThemeContext.Provider value={{ branding, refreshBranding: loadTheme }}>
{children}
</ThemeContext.Provider>
</NextThemesProvider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}

View File

@@ -0,0 +1,132 @@
'use client'
import React, { useState } from 'react'
import { motion } from 'framer-motion'
import { MessageSquare, BookOpen, LifeBuoy, ExternalLink } from 'lucide-react'
interface WorkspaceItem {
id: string
title: string
subtitle: string
hoverText: string
icon: React.ElementType
link: string
}
const workspaceItems: WorkspaceItem[] = [
{
id: 'zulip',
title: 'Zulip Chat',
subtitle: 'Treten Sie unserem Partner-Chat bei!',
hoverText: 'Tauschen Sie sich direkt und unkompliziert mit anderen CASPOS Partnern aus.',
icon: MessageSquare,
link: 'https://caspos.zulipchat.com'
},
{
id: 'wiki',
title: 'CASPOS Wiki',
subtitle: 'Ihr Wissens-Portal...',
hoverText: '...für die gesamte CASPOS Produktfamilie. Hier finden Sie alle technischen Dokumentationen.',
icon: BookOpen,
link: 'https://wiki.caspos.de'
},
{
id: 'support',
title: 'Support Portal',
subtitle: 'Sie haben ein technisches Problem?',
hoverText: 'Erstellen Sie jetzt ein Ticket in der CASPOS, damit sich unsere Experten darum kümmern.',
icon: LifeBuoy,
link: 'https://support.caspos.de'
}
]
export function WorkspaceZentrale() {
const [hoveredId, setHoveredId] = useState<string | null>(null)
return (
<section className="w-full py-12 px-4 relative z-10">
<div className="container max-w-6xl mx-auto space-y-6">
<div className="text-center space-y-2">
<h2 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
Workspace Zentrale
</h2>
</div>
{/* 3-Spalten Grid mit fester Kartenhöhe */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{workspaceItems.map((item) => {
const Icon = item.icon
const isHovered = hoveredId === item.id
return (
<motion.a
key={item.id}
href={item.link}
target="_blank"
rel="noopener noreferrer"
whileHover={{ y: -4 }}
onMouseEnter={() => setHoveredId(item.id)}
onMouseLeave={() => setHoveredId(null)}
className={`group relative p-6 rounded-2xl border transition-all duration-300 backdrop-blur-md flex flex-col justify-between h-[210px] overflow-hidden cursor-pointer ${
isHovered
? 'border-sky-500 bg-slate-900/90 shadow-[0_0_25px_rgba(14,165,233,0.2)]'
: 'border-slate-800 bg-slate-900/60'
}`}
>
<div className="space-y-3">
{/* Header: Icon & Link */}
<div className="flex items-center justify-between">
<div
className={`p-3 rounded-xl border transition-all duration-300 ${
isHovered
? 'bg-sky-500/20 border-sky-500/50 text-sky-300'
: 'bg-slate-800/60 border-slate-700/50 text-slate-400'
}`}
>
<Icon className="w-6 h-6" />
</div>
<ExternalLink
className={`w-4 h-4 transition-all duration-300 ${
isHovered
? 'text-sky-400 translate-x-0.5 -translate-y-0.5'
: 'text-slate-600'
}`}
/>
</div>
{/* Title */}
<h3 className="text-lg font-bold text-white group-hover:text-sky-300 transition-colors">
{item.title}
</h3>
{/* Subtitle / Hover-Text Überblendung ohne Höhenänderung */}
<div className="relative h-[48px] overflow-hidden">
<p
className={`text-sm text-slate-300 font-medium transition-all duration-300 absolute inset-0 ${
isHovered ? 'opacity-0 translate-y-[-8px]' : 'opacity-100 translate-y-0'
}`}
>
{item.subtitle}
</p>
<p
className={`text-xs text-slate-400 leading-relaxed transition-all duration-300 absolute inset-0 ${
isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-[8px]'
}`}
>
{item.hoverText}
</p>
</div>
</div>
{/* Subtile Hover-Linie unten */}
<div className={`w-full h-0.5 rounded-full transition-all duration-300 ${
isHovered ? 'bg-sky-500/80 shadow-[0_0_8px_rgba(14,165,233,0.8)]' : 'bg-transparent'
}`} />
</motion.a>
)
})}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import { useState } from 'react'
import { PRESET_COLOR_SCHEMES, ColorPreset } from '@/lib/constants/branding'
import { Check, Palette, Sparkles, Building2, ShoppingCart, Tag, Layers } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface ColorThemePickerProps {
colorScheme: string
primaryColor: string
accentColor: string
companyName?: string
onChange: (scheme: string, primary: string, accent: string) => void
}
export function ColorThemePicker({
colorScheme,
primaryColor,
accentColor,
companyName = 'CASPOS Shop',
onChange,
}: ColorThemePickerProps) {
const [customPrimary, setCustomPrimary] = useState(primaryColor || '#2563eb')
const [customAccent, setCustomAccent] = useState(accentColor || '#38bdf8')
const isCustom = colorScheme === 'custom'
const activePreset = PRESET_COLOR_SCHEMES.find(p => p.id === colorScheme) || PRESET_COLOR_SCHEMES[3]
const handleSelectPreset = (preset: ColorPreset) => {
onChange(preset.id, preset.primary, preset.accent)
}
const handleCustomPrimaryChange = (val: string) => {
setCustomPrimary(val)
onChange('custom', val, customAccent)
}
const handleCustomAccentChange = (val: string) => {
setCustomAccent(val)
onChange('custom', customPrimary, val)
}
return (
<div className="space-y-6">
{/* Grid of Presets */}
<div>
<div className="flex items-center justify-between mb-3">
<Label className="text-sm font-bold text-slate-200 flex items-center gap-2">
<Palette className="w-4 h-4 text-blue-400" />
Vorgegebene Farbschemata (9 Stile mit je 10 Farbtokens)
</Label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{PRESET_COLOR_SCHEMES.map((preset) => {
const isSelected = colorScheme === preset.id
const tokenSwatches = [
{ name: 'Primary', color: preset.primary, desc: 'Helle/edle Hauptfarbe für Navigation/Texte' },
{ name: 'Accent', color: preset.accent, desc: 'Scharfe Highlight-Farbe für Badges/Meldungen' },
{ name: 'bgGlow1', color: preset.bgGlow1, desc: 'Atmosphärischer Lichtnebel von oben' },
{ name: 'bgGlow2', color: preset.bgGlow2, desc: 'Subtiles Gegenlicht von unten' },
{ name: 'gradientFrom', color: preset.gradientFrom, desc: 'Startfarbe für Core-Verläufe' },
{ name: 'gradientTo', color: preset.gradientTo, desc: 'Endfarbe für Core-Verläufe' },
{ name: 'textHighlight', color: preset.textHighlight, desc: 'Soft-Pastell Text-Empfehlung' },
{ name: 'buttonBg', color: preset.buttonBg, desc: 'Satte Basisfarbe Hauptbuttons' },
{ name: 'ringColor', color: preset.ringColor, desc: 'Präzise Fokus-Rand-Farbe' },
{ name: 'cardBorder', color: preset.cardBorder, desc: 'Subtiler Rahmen-Ton Karten' },
]
return (
<button
key={preset.id}
type="button"
onClick={() => handleSelectPreset(preset)}
className={`relative p-3.5 rounded-2xl border text-left transition-all duration-200 group flex flex-col justify-between space-y-3 ${
isSelected
? 'bg-slate-900/90 border-blue-500 shadow-lg shadow-blue-500/10 ring-2 ring-blue-500/30'
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-900/40'
}`}
>
<div className="flex items-center justify-between">
<span className="font-semibold text-xs text-white truncate pr-2">
{preset.name}
</span>
{isSelected && (
<span className="w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center shrink-0">
<Check className="w-3.5 h-3.5" />
</span>
)}
</div>
<p className="text-[11px] text-slate-400 line-clamp-1">
{preset.description}
</p>
{/* 10 Color Token Swatches Bar */}
<div className="space-y-1 pt-1 border-t border-slate-800/60">
<div className="flex items-center gap-1 justify-between">
{tokenSwatches.map((item, i) => (
<span
key={i}
className="w-3 h-3 rounded-full border border-white/10 shadow-sm shrink-0 transition-transform hover:scale-125"
style={{ backgroundColor: item.color }}
title={`${item.name}: ${item.desc} (${item.color})`}
/>
))}
</div>
</div>
</button>
)
})}
</div>
</div>
{/* Custom Color Picker */}
<div
className={`p-4 rounded-2xl border transition-all duration-200 space-y-4 ${
isCustom
? 'bg-slate-900/90 border-blue-500 ring-2 ring-blue-500/30'
: 'bg-slate-950/40 border-slate-800'
}`}
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => onChange('custom', customPrimary, customAccent)}
className="flex items-center gap-2.5 font-bold text-xs text-white hover:text-blue-400 transition"
>
<span className={`w-4 h-4 rounded-full border flex items-center justify-center ${
isCustom ? 'border-blue-500 bg-blue-500/20 text-blue-400' : 'border-slate-700'
}`}>
{isCustom && <Check className="w-3 h-3" />}
</span>
<span>Benutzerdefiniertes Farbschema (Custom Hex Picker)</span>
</button>
</div>
{isCustom && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
{/* Primary Color Picker */}
<div className="space-y-1.5">
<Label className="text-xs text-slate-300">Hauptfarbe (Primary)</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={customPrimary}
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
/>
<Input
type="text"
value={customPrimary}
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
placeholder="#2563eb"
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
/>
</div>
</div>
{/* Accent Color Picker */}
<div className="space-y-1.5">
<Label className="text-xs text-slate-300">Akzentfarbe (Accent)</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={customAccent}
onChange={(e) => handleCustomAccentChange(e.target.value)}
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
/>
<Input
type="text"
value={customAccent}
onChange={(e) => handleCustomAccentChange(e.target.value)}
placeholder="#38bdf8"
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
/>
</div>
</div>
</div>
)}
</div>
{/* Live Micro-Preview Card with Animated Background Orbs */}
<div className="p-5 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 relative overflow-hidden">
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5 relative z-10">
<span className="text-xs font-bold text-slate-400 flex items-center gap-1.5 uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
Live Vorschau & Animierter Background Glow
</span>
<span className="text-[10px] font-mono text-slate-500">
{primaryColor} / {accentColor}
</span>
</div>
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800/80 space-y-4 relative overflow-hidden z-10 backdrop-blur-md">
{/* Animated Background Orbs Preview */}
<div
className="absolute -top-10 -left-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
style={{ backgroundColor: 'var(--bg-glow-1, ' + (activePreset.bgGlow1 || primaryColor) + ')' }}
/>
<div
className="absolute -bottom-10 -right-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
style={{ backgroundColor: 'var(--bg-glow-2, ' + (activePreset.bgGlow2 || accentColor) + ')' }}
/>
{/* Header Preview */}
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-950/80 border border-slate-800 relative z-10">
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-bold shadow-md"
style={{ backgroundColor: 'var(--primary-custom, ' + primaryColor + ')' }}
>
<Building2 className="w-3.5 h-3.5" />
</div>
<span className="font-bold text-xs text-white">
{companyName || 'Ihr Unternehmen'}
</span>
</div>
<span
className="text-[10px] font-semibold px-2 py-0.5 rounded-full text-slate-950 font-bold"
style={{ backgroundColor: 'var(--accent-custom, ' + accentColor + ')' }}
>
Aktiv
</span>
</div>
{/* Buttons & Badges Preview */}
<div className="flex flex-wrap items-center gap-2.5 relative z-10">
<button
type="button"
className="px-4 py-2 rounded-lg text-xs font-bold text-white shadow-md flex items-center gap-1.5 transition-all"
style={{
background: `linear-gradient(135deg, var(--gradient-from, ${primaryColor}) 0%, var(--gradient-to, ${activePreset.gradientTo || primaryColor}) 100%)`,
}}
>
<ShoppingCart className="w-3.5 h-3.5" />
In den Warenkorb
</button>
<button
type="button"
className="px-3.5 py-2 rounded-lg text-xs font-semibold bg-slate-800/90 text-slate-200 border"
style={{ borderColor: 'var(--accent-custom, ' + accentColor + ')' }}
>
Details anzeigen
</button>
<span
className="text-[10px] font-bold px-2.5 py-1 rounded-full border flex items-center gap-1"
style={{
borderColor: 'var(--card-border-glow, ' + accentColor + '40)',
backgroundColor: 'color-mix(in srgb, var(--accent-custom, ' + accentColor + ') 15%, transparent)',
color: 'var(--text-highlight, ' + (activePreset.textHighlight || accentColor) + ')',
}}
>
<Tag className="w-3 h-3" />
Empfohlen
</span>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -5,15 +5,42 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, ExternalLink, Search, Loader2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
Download,
ExternalLink,
Search,
Loader2,
Eye,
ArrowUpDown,
Building2,
User,
Mail,
MapPin,
Package,
FileText,
Calendar,
Layers,
Sparkles,
XCircle,
CheckCircle,
Edit3
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { updateOrderStatus } from "@/lib/actions/orders";
import { resolveSupabaseUrl } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { updateOrderStatus, rejectOrder, updateOrderPayload } from "@/lib/actions/orders";
interface OrdersTableProps {
initialOrders: any[];
@@ -21,7 +48,10 @@ interface OrdersTableProps {
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
pending_approval: 'Wartet auf Freigabe',
in_review: 'In Prüfung',
approved: 'Freigegeben',
active: 'Aktiviert',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
@@ -29,27 +59,147 @@ const statusLabel: Record<string, string> = {
const statusClass: Record<string, string> = {
pending: 'bg-amber-500/20 text-amber-400 border border-amber-500/30',
pending_approval: 'bg-amber-600/20 text-amber-300 border border-amber-500/40',
in_review: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
approved: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
rejected: 'bg-rose-500/20 text-rose-400 border border-rose-500/30',
cancelled: 'bg-slate-500/20 text-slate-400 border border-slate-500/30',
rejected: 'bg-red-500/20 text-red-400 border border-red-500/30',
};
type SortField = 'date_desc' | 'date_asc' | 'price_desc' | 'price_asc' | 'order_num';
export function OrdersTable({ initialOrders }: OrdersTableProps) {
const [orders, setOrders] = useState(initialOrders);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortField>("date_desc");
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [selectedOrder, setSelectedOrder] = useState<any | null>(null);
// Rejection Dialog State
const [rejectingOrder, setRejectingOrder] = useState<any | null>(null);
const [rejectionReason, setRejectionReason] = useState("");
const [isSubmittingReject, setIsSubmittingReject] = useState(false);
// Preiskorrektur States
const [isEditingSnapshot, setIsEditingSnapshot] = useState(false);
const [editedSnapshot, setEditedSnapshot] = useState<any | null>(null);
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
// Initialize editedSnapshot when selectedOrder changes
useEffect(() => {
if (selectedOrder) {
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
setIsEditingSnapshot(false);
} else {
setEditedSnapshot(null);
setIsEditingSnapshot(false);
}
}, [selectedOrder]);
const updateItemBasePrice = (itemIdx: number, newPrice: number) => {
if (!editedSnapshot) return;
const updatedItems = [...editedSnapshot.items];
const item = { ...updatedItems[itemIdx] };
item.base_price = newPrice;
const moduleTotal = (item.selected_modules || []).reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
item.item_total = newPrice + moduleTotal;
updatedItems[itemIdx] = item;
recalculateSnapshot(updatedItems);
};
const updateModulePriceOrQty = (itemIdx: number, modIdx: number, field: 'price' | 'quantity', value: number) => {
if (!editedSnapshot) return;
const updatedItems = [...editedSnapshot.items];
const item = { ...updatedItems[itemIdx] };
const updatedModules = [...(item.selected_modules || [])];
const mod = { ...updatedModules[modIdx] };
if (field === 'price') {
mod.price = value;
} else {
mod.quantity = value;
}
mod.total_price = mod.price * (mod.quantity || 1);
updatedModules[modIdx] = mod;
item.selected_modules = updatedModules;
const moduleTotal = updatedModules.reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
item.item_total = (item.base_price || 0) + moduleTotal;
updatedItems[itemIdx] = item;
recalculateSnapshot(updatedItems);
};
const recalculateSnapshot = (updatedItems: any[]) => {
if (!editedSnapshot) return;
const taxRate = editedSnapshot.tax_rate ?? 19;
const subtotal = updatedItems.reduce((acc: number, item: any) => acc + (item.item_total || 0), 0);
const taxAmount = Math.round(subtotal * (taxRate / 100) * 100) / 100;
const total = Math.round((subtotal + taxAmount) * 100) / 100;
setEditedSnapshot({
...editedSnapshot,
items: updatedItems,
subtotal,
tax_amount: taxAmount,
total
});
};
const handleSaveSnapshot = async () => {
if (!editedSnapshot || !selectedOrder) return;
setIsSavingSnapshot(true);
try {
const updated = await updateOrderPayload(selectedOrder.id, {
orderSnapshot: editedSnapshot
});
setOrders(prev => prev.map(o => o.id === selectedOrder.id ? { ...o, order_data: editedSnapshot, total_price: editedSnapshot.total } : o));
setSelectedOrder({ ...selectedOrder, order_data: editedSnapshot, total_price: editedSnapshot.total });
setIsEditingSnapshot(false);
} catch (error: any) {
console.error(error);
alert("Fehler beim Speichern der Änderungen: " + error.message);
} finally {
setIsSavingSnapshot(false);
}
};
// Badge-Counter berechnen
const countAll = orders.length;
const countOffen = orders.filter(o => ["pending", "pending_approval", "in_review"].includes(o.status)).length;
const countApproved = orders.filter(o => o.status === "approved").length;
const countActive = orders.filter(o => ["active", "completed"].includes(o.status)).length;
const countRejected = orders.filter(o => ["rejected", "cancelled"].includes(o.status)).length;
const displaySnapshot = editedSnapshot || selectedOrder?.order_data || {};
const displayItems = displaySnapshot.items || [];
useEffect(() => {
setOrders(initialOrders);
}, [initialOrders]);
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
if (newStatus === 'rejected') {
const target = orders.find(o => o.id === orderId);
if (target) {
setRejectingOrder(target);
setRejectionReason("");
return;
}
}
setUpdatingId(orderId);
try {
await updateOrderStatus(orderId, newStatus);
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: newStatus } : o));
if (selectedOrder?.id === orderId) {
setSelectedOrder((prev: any) => prev ? { ...prev, status: newStatus } : null);
}
} catch (error) {
console.error(error);
alert("Fehler beim Ändern des Status.");
@@ -58,83 +208,142 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
}
};
const filteredOrders = orders.filter((order) => {
const orderNum = (order.order_number || "").toLowerCase();
const company = (order.customer_data?.company_name || "").toLowerCase();
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
const matchesSearch =
orderNum.includes(search.toLowerCase()) ||
company.includes(search.toLowerCase()) ||
contact.includes(search.toLowerCase());
const handleConfirmReject = async () => {
if (!rejectingOrder) return;
setIsSubmittingReject(true);
try {
await rejectOrder(rejectingOrder.id, rejectionReason || "Vom Administrator abgelehnt.");
setOrders(prev => prev.map(o => o.id === rejectingOrder.id ? { ...o, status: 'rejected' } : o));
if (selectedOrder?.id === rejectingOrder.id) {
setSelectedOrder((prev: any) => prev ? { ...prev, status: 'rejected' } : null);
}
setRejectingOrder(null);
setRejectionReason("");
} catch (error) {
console.error(error);
alert("Fehler beim Ablehnen der Anfrage.");
} finally {
setIsSubmittingReject(false);
}
};
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
const filteredAndSortedOrders = orders
.filter((order) => {
const orderNum = (order.order_number || "").toLowerCase();
const company = (order.customer_data?.company_name || "").toLowerCase();
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
const matchesSearch =
orderNum.includes(search.toLowerCase()) ||
company.includes(search.toLowerCase()) ||
contact.includes(search.toLowerCase());
return matchesSearch && matchesStatus;
});
let matchesStatus = false;
if (statusFilter === "all") matchesStatus = true;
else if (statusFilter === "offen") matchesStatus = ["pending", "pending_approval", "in_review"].includes(order.status);
else if (statusFilter === "approved") matchesStatus = order.status === "approved";
else if (statusFilter === "active") matchesStatus = ["active", "completed"].includes(order.status);
else if (statusFilter === "rejected") matchesStatus = ["rejected", "cancelled"].includes(order.status);
return matchesSearch && matchesStatus;
})
.sort((a, b) => {
if (sortBy === 'date_desc') return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
if (sortBy === 'date_asc') return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
if (sortBy === 'price_desc') return (b.total_price || 0) - (a.total_price || 0);
if (sortBy === 'price_asc') return (a.total_price || 0) - (b.total_price || 0);
if (sortBy === 'order_num') return (a.order_number || a.id).localeCompare(b.order_number || b.id);
return 0;
});
return (
<div className="space-y-4">
{/* Filterleiste */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
<div className="relative w-full md:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Bestellung, Firma oder Name suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
/>
{/* Filter- & Sortierleiste */}
<div className="flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center bg-white/5 p-4 rounded-xl border border-white/5">
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<div className="relative w-full sm:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Anfragenr., Firma oder Name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="border-white/10 bg-slate-950/40 text-slate-200 gap-2 shrink-0">
<ArrowUpDown className="w-3.5 h-3.5 text-primary" />
<span>
{sortBy === 'date_desc' && 'Datum (Neueste zuerst)'}
{sortBy === 'date_asc' && 'Datum (Älteste zuerst)'}
{sortBy === 'price_desc' && 'Betrag (Höchster zuerst)'}
{sortBy === 'price_asc' && 'Betrag (Niedrigster zuerst)'}
{sortBy === 'order_num' && 'Anfragenr.'}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white">
<DropdownMenuItem onClick={() => setSortBy('date_desc')} className="text-xs cursor-pointer">
Datum (Neueste zuerst)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('date_asc')} className="text-xs cursor-pointer">
Datum (Älteste zuerst)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('price_desc')} className="text-xs cursor-pointer">
Betrag (Höchster zuerst)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('price_asc')} className="text-xs cursor-pointer">
Betrag (Niedrigster zuerst)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('order_num')} className="text-xs cursor-pointer">
Anfragenr.
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("all")}
className="text-xs"
className="text-xs gap-1.5"
>
Alle
Alle <Badge variant="secondary" className="bg-white/10 text-white border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countAll}</Badge>
</Button>
<Button
variant={statusFilter === "pending" ? "default" : "outline"}
variant={statusFilter === "offen" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("pending")}
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
onClick={() => setStatusFilter("offen")}
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400 gap-1.5"
>
Eingegangen
Offen / in_review <Badge variant="secondary" className="bg-amber-500/20 text-amber-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countOffen}</Badge>
</Button>
<Button
variant={statusFilter === "approved" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("approved")}
className="text-xs border-emerald-500/20 hover:bg-emerald-500/10 text-emerald-400 gap-1.5"
>
Genehmigt <Badge variant="secondary" className="bg-emerald-500/20 text-emerald-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countApproved}</Badge>
</Button>
<Button
variant={statusFilter === "active" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("active")}
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400 gap-1.5"
>
In Bearbeitung
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("completed")}
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
>
Abgeschlossen
</Button>
<Button
variant={statusFilter === "cancelled" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("cancelled")}
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
>
Storniert
Aktiv <Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countActive}</Badge>
</Button>
<Button
variant={statusFilter === "rejected" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("rejected")}
className="text-xs border-rose-500/20 hover:bg-rose-500/10 text-rose-400"
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400 gap-1.5"
>
Abgelehnt
Abgelehnt <Badge variant="secondary" className="bg-red-500/20 text-red-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countRejected}</Badge>
</Button>
</div>
</div>
@@ -144,31 +353,31 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
<Table>
<TableHeader>
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
<TableHead className="text-slate-200">Datum</TableHead>
<TableHead className="text-slate-200">Kunde</TableHead>
<TableHead className="text-slate-200">Auswahl</TableHead>
<TableHead className="text-slate-200">Betrag</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
<TableHead className="w-[140px] text-slate-200 font-bold">Anfragenr.</TableHead>
<TableHead className="text-slate-200 font-bold">Datum</TableHead>
<TableHead className="text-slate-200 font-bold">Kunde / Firma</TableHead>
<TableHead className="text-slate-200 font-bold">Auswahl / Produkte</TableHead>
<TableHead className="text-slate-200 font-bold">Gesamtbetrag</TableHead>
<TableHead className="text-slate-200 font-bold">Status</TableHead>
<TableHead className="text-right text-slate-200 font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders.length === 0 ? (
{filteredAndSortedOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
Keine Bestellungen gefunden.
Keine Anfragen gefunden.
</TableCell>
</TableRow>
) : (
filteredOrders.map((order) => {
filteredAndSortedOrders.map((order) => {
const items = order.order_data?.items || [];
return (
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-mono text-xs text-primary font-semibold">
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors group">
<TableCell className="font-mono text-xs text-primary font-bold">
#{order.order_number || order.id.slice(0, 8)}
</TableCell>
<TableCell className="text-slate-300 text-sm">
<TableCell className="text-slate-300 text-sm whitespace-nowrap">
{new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
})}
@@ -177,33 +386,43 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
<div className="font-semibold text-white text-sm">
{order.customer_data?.company_name || 'Privatkunde'}
</div>
<div className="text-xs text-slate-400">
<div className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<User className="w-3 h-3 text-slate-500" />
{order.customer_data?.first_name} {order.customer_data?.last_name}
</div>
</TableCell>
<TableCell className="max-w-[200px]">
<div className="flex flex-wrap gap-1">
{items.map((item: any) => (
<span
key={item.product_id}
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
<TableCell className="max-w-[240px]">
<div className="flex flex-col gap-1.5">
{items.slice(0, 2).map((item: any, idx: number) => (
<div
key={idx}
className="text-[11px] text-slate-300 leading-tight"
>
{item.product_name}
<span className="font-semibold text-white block truncate">
{item.device_name || 'Kasse'}: {item.product_name}
</span>
{item.selected_modules?.length > 0 && (
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
<span className="text-slate-500 block text-[10px] truncate">
+ {item.selected_modules.map((m: any) => m.module_name || m.name).join(', ')}
</span>
)}
</span>
</div>
))}
{items.length > 2 && (
<span className="text-[10px] text-primary font-semibold">
+ {items.length - 2} weitere Kassen
</span>
)}
</div>
</TableCell>
<TableCell className="font-bold text-white text-sm">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
<TableCell className="font-bold text-white text-sm whitespace-nowrap">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price || 0)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={updatingId === order.id}>
<button className="focus:outline-none focus:ring-0 active:scale-95 disabled:pointer-events-none transition-transform duration-100">
<Badge className={`text-xs px-2 py-0.5 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
<Badge className={`text-xs px-2.5 py-1 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
{updatingId === order.id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : null}
@@ -212,7 +431,7 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
</Badge>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[140px]">
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[170px]">
{Object.entries(statusLabel).map(([key, label]) => (
<DropdownMenuItem
key={key}
@@ -228,9 +447,17 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs">
<a href={`/order?id=${order.id}`}>
Bearbeiten
<Button
variant="outline"
size="sm"
onClick={() => setSelectedOrder(order)}
className="h-8 border-white/10 hover:bg-white/10 text-slate-200 text-xs gap-1"
>
<Eye className="w-3.5 h-3.5 text-primary" /> Details
</Button>
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
<a href={`/order?mode=admin_edit&orderId=${order.id}`}>
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
</a>
</Button>
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
@@ -238,11 +465,6 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF
</a>
</Button>
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
<a href={`/api/admin/orders/${order.id}/download`}>
<Download className="w-3.5 h-3.5" />
</a>
</Button>
</div>
</TableCell>
</TableRow>
@@ -252,6 +474,345 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
</TableBody>
</Table>
</div>
{/* Detail-Modal für vollständige Anfragenübersicht & Freigabe-Workflow */}
{selectedOrder && (
<Dialog open={!!selectedOrder} onOpenChange={(open) => !open && setSelectedOrder(null)}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto bg-slate-950 border-white/10 text-white p-6 space-y-6">
<DialogHeader className="border-b border-white/10 pb-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
Anfragenr. #{selectedOrder.order_number || selectedOrder.id.slice(0, 8)}
</DialogTitle>
<Badge className={`text-xs px-2.5 py-0.5 rounded ${statusClass[selectedOrder.status]}`}>
{statusLabel[selectedOrder.status] ?? selectedOrder.status}
</Badge>
</div>
<DialogDescription className="text-xs text-slate-400 mt-1 flex items-center gap-2">
<Calendar className="w-3.5 h-3.5 text-slate-500" />
Erstellt am {new Date(selectedOrder.created_at).toLocaleString('de-DE')}
</DialogDescription>
</div>
<div className="text-right">
<div className="text-xs text-slate-400 uppercase tracking-wider font-semibold">
{isEditingSnapshot ? "Vorschau Summe" : "Gesamtsumme"}
</div>
<div className="text-2xl font-extrabold text-primary">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
isEditingSnapshot ? (editedSnapshot?.total || 0) : (selectedOrder.total_price || 0)
)}
</div>
</div>
</div>
</DialogHeader>
{/* Kunden & Stammdaten */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-slate-900/80 border border-white/5 space-y-2">
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5 border-b border-white/5 pb-2">
<Building2 className="w-4 h-4 text-primary" />
Kunden- & Firmendaten
</div>
<div className="text-sm font-semibold text-white">
{selectedOrder.customer_data?.company_name || 'Keine Firma angegeben'}
</div>
<div className="text-xs text-slate-300 flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-slate-500" />
{selectedOrder.customer_data?.first_name} {selectedOrder.customer_data?.last_name}
</div>
{selectedOrder.customer_data?.email && (
<div className="text-xs text-slate-300 flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-slate-500" />
{selectedOrder.customer_data.email}
</div>
)}
</div>
<div className="p-4 rounded-xl bg-slate-900/80 border border-white/5 space-y-2">
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5 border-b border-white/5 pb-2">
<MapPin className="w-4 h-4 text-primary" />
Adresse & Standort
</div>
<div className="text-xs text-slate-300 space-y-1">
<div>{selectedOrder.customer_data?.street || 'Keine Straße'}</div>
<div>
{selectedOrder.customer_data?.zip} {selectedOrder.customer_data?.city}
</div>
</div>
</div>
</div>
{/* Geräte & Produktauswahl (Nach Kassen / Boxen gruppiert) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
<Package className="w-4 h-4 text-primary" />
Konfigurierte Kassen ({displayItems.length})
</div>
{selectedOrder.status === 'in_review' && !isEditingSnapshot && (
<Button
size="sm"
variant="outline"
onClick={() => setIsEditingSnapshot(true)}
className="h-7 border-primary/30 text-primary hover:bg-primary/10 text-xs font-bold gap-1 rounded-md"
>
<Edit3 className="w-3 h-3" /> Preise anpassen
</Button>
)}
</div>
{isEditingSnapshot && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between bg-amber-500/10 border border-amber-500/20 p-3 rounded-xl gap-4 animate-pulse">
<span className="text-xs text-amber-400 font-semibold">Sie befinden sich im Preiskorrektur-Modus.</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
setIsEditingSnapshot(false);
}}
className="h-7 text-xs text-slate-300 hover:text-white"
>
Abbrechen
</Button>
<Button
size="sm"
onClick={handleSaveSnapshot}
disabled={isSavingSnapshot}
className="h-7 text-xs bg-amber-600 hover:bg-amber-500 text-white font-bold"
>
{isSavingSnapshot ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
Änderungen speichern
</Button>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-3">
{displayItems.map((item: any, idx: number) => {
const devName = item.device_name || `Kasse #${idx + 1}`
const licNum = item.license_number
const modules = item.selected_modules || []
return (
<div key={idx} className="p-4 rounded-xl bg-slate-900/90 border border-white/10 space-y-3 relative overflow-hidden">
{/* Box Header für die Kasse */}
<div className="flex items-center justify-between border-b border-white/5 pb-2.5 flex-wrap gap-2">
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
{devName}
</span>
{licNum && (
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
{licNum}
</span>
)}
</div>
<span className={`text-[10px] px-2 py-0.5 rounded border ${item.billing_interval === 'one_time' ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'} font-semibold uppercase`}>
{item.billing_interval === 'one_time' ? 'Kauf' : 'Abo'}
</span>
</div>
{/* Inhalt der Kasse: Hauptprodukt + Preis */}
<div className="flex items-center justify-between">
<span className="font-bold text-sm text-white">{item.product_name}</span>
{isEditingSnapshot ? (
<div className="flex items-center gap-2">
<Label htmlFor={`price-${idx}`} className="text-[10px] text-slate-400 font-sans">Preis ():</Label>
<Input
id={`price-${idx}`}
type="number"
value={item.base_price || 0}
onChange={(e) => updateItemBasePrice(idx, parseFloat(e.target.value) || 0)}
className="w-20 h-7 bg-slate-950 border-white/10 text-white text-xs px-2 font-mono"
/>
</div>
) : (
(item.price || item.base_price) && (
<span className="text-xs font-bold text-slate-200 font-mono">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.price || item.base_price)}
</span>
)
)}
</div>
{/* Module dieser Kasse */}
{modules.length > 0 && (
<div className="space-y-1.5 pt-2 border-t border-white/5">
<div className="text-[11px] font-semibold text-slate-400 flex items-center gap-1">
<Layers className="w-3 h-3 text-slate-500" />
Enthaltene Module ({modules.length}):
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{modules.map((mod: any, mIdx: number) => (
<div key={mIdx} className="p-2 rounded-lg bg-slate-950/60 border border-white/5 text-xs flex items-center justify-between gap-3">
<span className="text-slate-300 font-medium truncate">
+ {mod.module_name || mod.name} {!isEditingSnapshot && mod.quantity > 1 ? `(${mod.quantity}x)` : ''}
</span>
{isEditingSnapshot ? (
<div className="flex items-center gap-1.5 shrink-0">
<Input
type="number"
title="Einzelpreis"
value={mod.price || 0}
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'price', parseFloat(e.target.value) || 0)}
className="w-14 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
/>
<span className="text-[10px] text-slate-500 font-sans">x</span>
<Input
type="number"
title="Menge"
value={mod.quantity || 1}
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'quantity', parseInt(e.target.value) || 1)}
className="w-10 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
/>
</div>
) : (
mod.price && (
<span className="text-slate-400 font-mono text-[11px] shrink-0">
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * (mod.quantity || 1))}
</span>
)
)}
</div>
))}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
{/* Workflow Freigabe & Aktionen */}
<div className="p-4 rounded-xl bg-slate-900/90 border border-white/10 space-y-3">
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-amber-400" />
Freigabe-Workflow & Verwalter-Aktionen
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => handleStatusChange(selectedOrder.id, 'in_review')}
disabled={updatingId === selectedOrder.id}
className="bg-purple-600 hover:bg-purple-500 text-white text-xs gap-1.5"
>
In Prüfung versetzen
</Button>
<Button
size="sm"
onClick={() => handleStatusChange(selectedOrder.id, 'approved')}
disabled={updatingId === selectedOrder.id}
className="bg-emerald-600 hover:bg-emerald-500 text-white text-xs gap-1.5 font-bold"
>
<CheckCircle className="w-3.5 h-3.5" /> Anfrage Freigeben
</Button>
<Button
size="sm"
onClick={() => handleStatusChange(selectedOrder.id, 'active')}
disabled={updatingId === selectedOrder.id}
className="bg-blue-600 hover:bg-blue-500 text-white text-xs gap-1.5 font-bold"
>
Sofort Aktivieren
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleStatusChange(selectedOrder.id, 'rejected')}
disabled={updatingId === selectedOrder.id}
className="text-xs gap-1.5"
>
<XCircle className="w-3.5 h-3.5" /> Ablehnen
</Button>
</div>
</div>
<DialogFooter className="border-t border-white/10 pt-4 flex flex-col sm:flex-row justify-between items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedOrder(null)}
className="text-slate-400 hover:text-white text-xs"
>
Schließen
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
<a href={`/order?mode=admin_edit&orderId=${selectedOrder.id}`}>
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
</a>
</Button>
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
<a href={`/api/admin/orders/${selectedOrder.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Vorschau
</a>
</Button>
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
<a href={`/api/admin/orders/${selectedOrder.id}/download`}>
<Download className="w-3.5 h-3.5 mr-1" /> PDF Download
</a>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Dialog für Ablehnungsbegründung */}
{rejectingOrder && (
<Dialog open={!!rejectingOrder} onOpenChange={(open) => !open && setRejectingOrder(null)}>
<DialogContent className="max-w-md bg-slate-950 border-white/10 text-white p-6 space-y-4">
<DialogHeader>
<DialogTitle className="text-lg font-bold text-red-400 flex items-center gap-2">
<XCircle className="w-5 h-5" />
Anfrage Ablehnen
</DialogTitle>
<DialogDescription className="text-xs text-slate-400">
Bitte geben Sie einen Grund für die Ablehnung der Anfrage #{rejectingOrder.order_number || rejectingOrder.id.slice(0, 8)} an.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label className="text-xs text-slate-300">Begründung für den Partner</Label>
<Input
placeholder="z.B. Ungültiges Kundenprofil / Modul-Konflikt..."
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
className="bg-slate-900 border-white/10 text-white text-xs"
/>
</div>
<DialogFooter className="gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setRejectingOrder(null)}
disabled={isSubmittingReject}
className="text-xs text-slate-400"
>
Abbrechen
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleConfirmReject}
disabled={isSubmittingReject}
className="text-xs gap-1 font-bold"
>
{isSubmittingReject ? <Loader2 className="w-3 h-3 animate-spin" /> : null}
Anfrage definitiv ablehnen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}

View File

@@ -38,7 +38,7 @@ import { Label } from "@/components/ui/label"
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
const [products, setProducts] = useState(initialProducts)
const [isGroupedByCategory, setIsGroupedByCategory] = useState(false)
const [isGroupedByCategory, setIsGroupedByCategory] = useState(true)
const [showKauf, setShowKauf] = useState(true)
const [showAbo, setShowAbo] = useState(true)
const [copySourceProduct, setCopySourceProduct] = useState<Product | null>(null)
@@ -202,7 +202,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
size="sm"
onClick={toggleGroupByCategory}
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
isGroupedByCategory ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 hover:text-white dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700' : ''
}`}
>
<ArrowUpDown className="w-4 h-4 mr-2" />

View File

@@ -0,0 +1,469 @@
'use client'
import React, { useState, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link'
import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types'
import {
ChevronDown,
ChevronUp,
Building2,
Edit2,
Sparkles,
Download,
ExternalLink,
Plus,
Monitor,
AlertTriangle,
Search,
Package,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
in_review: 'In Prüfung',
active: 'Aktiv',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
const statusClass: Record<string, string> = {
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
in_review: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
active: 'bg-green-500/20 text-green-400 border-green-500/30',
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
cancelled: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
}
interface CustomerAccordionListProps {
customers: EndCustomerWithDevices[]
}
// ── Einzelne Kassen-Karte ─────────────────────────────────────────────────────
function DeviceCard({
device,
customerId,
}: {
device: FlattenedDevice
customerId: string
}) {
// Alle Produkt-Chips + Modul-Chips aus den Items
const chips: string[] = []
for (const item of device.items) {
chips.push(item.product_name)
for (const mod of item.selected_modules || []) {
chips.push(mod.module_name)
}
}
const upgradeUrl =
`/order?mode=upgrade` +
`&orderId=${device.orderId}` +
`&customer_id=${customerId}` +
`&device_id=${encodeURIComponent(device.deviceId)}`
return (
<div className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 hover:border-slate-700 transition-all flex flex-col md:flex-row md:items-start justify-between gap-4 relative overflow-hidden">
{/* Kassen-Info links */}
<div className="space-y-2.5 min-w-[200px] flex-1">
{/* Kassen-Name + Lizenznummer daneben + Status */}
<div className="flex items-center gap-2 flex-wrap border-b border-white/5 pb-2">
<span className="px-2 py-0.5 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
{device.deviceName}
</span>
{device.items[0]?.license_number && (
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
{device.items[0].license_number}
</span>
)}
<Badge
className={`text-xs ${statusClass[device.orderStatus] ?? 'bg-slate-500/20 text-slate-300'}`}
>
{statusLabel[device.orderStatus] ?? device.orderStatus}
</Badge>
</div>
{/* Bestellnummer + Datum */}
<p className="text-xs text-slate-400 font-mono">
#{device.orderNumber} &bull; Erstellt am{' '}
{new Date(device.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</p>
{/* Produkt + Modul Chips */}
{chips.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{chips.map((chip, idx) => (
<span
key={idx}
className="text-xs bg-white/5 border border-white/10 rounded-md px-2 py-0.5 text-slate-300"
>
{chip}
</span>
))}
</div>
)}
</div>
{/* Preis + Aktions-Buttons rechts */}
<div className="flex items-center gap-3 flex-wrap justify-between md:justify-end border-t md:border-t-0 pt-3 md:pt-0 border-white/5 shrink-0">
{/* Preis */}
<div className="text-left md:text-right mr-2">
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Gesamtwert</p>
<p className="font-bold text-white text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(device.totalPrice)}
</p>
</div>
{/* Update / Upgrade Button für Kasse */}
<Link href={upgradeUrl}>
<Button
size="sm"
className="bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 border border-sky-500/40 text-xs font-bold gap-1.5 shadow-[0_0_12px_rgba(56,189,248,0.2)]"
title="Kasse updaten oder upgraden (Erstelldatum wird für den Update-Rabatt genutzt)"
>
<Sparkles className="w-3.5 h-3.5 text-sky-400" />
Update / Upgrade
</Button>
</Link>
{/* PDF Download */}
{device.pdfUrl && (
<Button
variant="outline"
size="sm"
asChild
className="border-white/10 hover:bg-white/10 text-xs text-slate-300"
>
<a
href={`/api/orders/${device.orderId}/download`}
download
title="Bestellbestätigung PDF herunterladen"
>
<Download className="w-3.5 h-3.5 mr-1" />
PDF
</a>
</Button>
)}
{/* Details */}
<Link href={`/order/success?id=${device.orderId}`}>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white text-xs p-2"
title="Details anzeigen"
>
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
)
}
// ── Haupt-Komponente ──────────────────────────────────────────────────────────
export function CustomerAccordionList({ customers }: CustomerAccordionListProps) {
const [openCustomerIds, setOpenCustomerIds] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {}
customers.forEach((c) => {
initial[c.id] = true
})
return initial
})
const [searchTerm, setSearchTerm] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 5
const toggleCustomer = (id: string) => {
setOpenCustomerIds((prev) => ({
...prev,
[id]: !prev[id],
}))
}
const filteredCustomers = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return customers
return customers.filter((c) => {
return (
c.company_name.toLowerCase().includes(term) ||
(c.first_name && c.first_name.toLowerCase().includes(term)) ||
(c.last_name && c.last_name.toLowerCase().includes(term)) ||
(c.city && c.city.toLowerCase().includes(term)) ||
(c.email && c.email.toLowerCase().includes(term))
)
})
}, [customers, searchTerm])
const totalPages = Math.ceil(filteredCustomers.length / pageSize) || 1
const paginatedCustomers = useMemo(() => {
const from = (currentPage - 1) * pageSize
return filteredCustomers.slice(from, from + pageSize)
}, [filteredCustomers, currentPage, pageSize])
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
setCurrentPage(1)
}
if (customers.length === 0) {
return (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
<Building2 className="w-12 h-12 text-slate-600" />
<p className="text-slate-400 text-lg">Noch keine Endkunden angelegt.</p>
<Link href="/my-customers/new">
<Button>
<Plus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Suchfeld & Info */}
<div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
type="text"
placeholder="Kunden suchen (Firma, Name, Stadt)..."
value={searchTerm}
onChange={handleSearchChange}
className="pl-9 bg-slate-900/50 border-white/10 text-white placeholder:text-slate-500 focus:border-primary"
/>
</div>
<div className="text-xs text-slate-400 bg-slate-900/50 px-3.5 py-2 rounded-xl border border-white/10 shrink-0 text-center flex items-center justify-center gap-2">
<span>Kunden:</span>
<span className="text-white font-bold">{filteredCustomers.length}</span>
{searchTerm && <span className="text-slate-500">(gefiltert aus {customers.length})</span>}
</div>
</div>
{/* Keine Suchergebnisse */}
{filteredCustomers.length === 0 && (
<Card className="glass-dark border-white/10">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<Search className="w-10 h-10 text-slate-600" />
<p className="text-slate-300 font-medium">Keine Kunden für &quot;{searchTerm}&quot; gefunden.</p>
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
Filter zurücksetzen
</Button>
</CardContent>
</Card>
)}
{/* Akkordeon-Liste */}
{paginatedCustomers.length > 0 && (
<div className="space-y-4">
{paginatedCustomers.map((customer) => {
const isOpen = !!openCustomerIds[customer.id]
const devices = customer.devices || []
return (
<Card
key={customer.id}
className="glass-dark border-white/10 overflow-hidden transition-all duration-200"
>
{/* Akkordeon-Header */}
<div
onClick={() => toggleCustomer(customer.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggleCustomer(customer.id)
}
}}
tabIndex={0}
role="button"
aria-expanded={isOpen}
className="p-5 flex items-center justify-between cursor-pointer hover:bg-white/[0.02] transition-colors select-none gap-4 flex-wrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
>
<div className="flex items-center gap-4 min-w-[240px]">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
<Building2 className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-lg text-white">{customer.company_name}</h3>
{customer.is_anonymized && (
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 text-xs gap-1">
<AlertTriangle className="w-3 h-3" /> Anonymisiert
</Badge>
)}
</div>
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ') ||
'Kein Ansprechpartner'}
{customer.city ? `${customer.city}` : ''}
{customer.email ? `${customer.email}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-3 ml-auto">
{/* Kassen-Anzahl Badge */}
<Badge variant="outline" className="border-white/10 text-slate-300 bg-white/5">
<Package className="w-3.5 h-3.5 mr-1 text-primary" />
{devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'}
</Badge>
{!customer.is_anonymized && (
<>
<Link
href={`/order?customer_id=${customer.id}`}
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
className="bg-primary/20 hover:bg-primary/30 text-primary border border-primary/40 gap-1.5 text-xs font-bold shadow-[0_0_10px_rgba(59,130,246,0.2)]"
>
<Sparkles className="w-3.5 h-3.5" /> Kunde updaten / bestellen
</Button>
</Link>
<Link
href={`/my-customers/${customer.id}`}
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white hover:bg-white/10 gap-1 text-xs"
>
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
</Button>
</Link>
</>
)}
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white p-1"
>
{isOpen ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</Button>
</div>
</div>
{/* Ausgeklappter Bereich mit Kassen */}
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden border-t border-white/5 bg-slate-950/40"
>
<div className="p-5 space-y-3">
{devices.length === 0 ? (
<div className="text-slate-500 text-sm py-4 text-center">
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
</div>
) : (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
Zugeordnete Kassen ({devices.length})
</p>
{devices.map((device, idx) => (
<DeviceCard
key={`${device.orderId}-${device.deviceId}-${idx}`}
device={device}
customerId={customer.id}
/>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
)
})}
</div>
)}
{/* Paginierung (5 Kunden pro Seite) */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-white/10">
<span className="text-xs text-slate-400">
Seite <span className="text-white font-bold">{currentPage}</span> von{' '}
<span className="text-white font-bold">{totalPages}</span>
</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="h-8 px-2 text-slate-400 hover:text-white"
title="Erste Seite"
>
<ChevronsLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="border-white/10 text-xs gap-1 rounded-xl h-8 px-3"
>
<ChevronLeft className="w-3.5 h-3.5" /> Vorherige
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="border-white/10 text-xs gap-1 rounded-xl h-8 px-3"
>
Nächste <ChevronRight className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="h-8 px-2 text-slate-400 hover:text-white"
title="Letzte Seite"
>
<ChevronsRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,19 +1,40 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState, useRef, useCallback } from "react";
import { createBrowserClient } from "@supabase/ssr";
import { useRouter } from "next/navigation";
import { signOut } from "@/lib/actions/auth";
import { resolveSupabaseUrl } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Clock } from "lucide-react";
// Inaktivitäts-Schwellenwert (10 Minuten = 600.000 ms)
const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
// Countdown-Dauer vor automatischem Logout (10 Sekunden)
const COUNTDOWN_SECONDS = 10;
export function InactivityTracker() {
const router = useRouter();
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [showModal, setShowModal] = useState(false);
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS);
const lastActivityRef = useRef<number>(Date.now());
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Supabase URL & Anon Key auslesen (für Browser-Client)
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Browser Client inline erstellen
const supabase = createBrowserClient(
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
@@ -24,45 +45,216 @@ export function InactivityTracker() {
}
);
// Cookie-Hilfsfunktionen für Aktivitätssynchronisation mit dem Server
const updateLastActivityCookie = useCallback(() => {
const now = Date.now();
lastActivityRef.current = now;
document.cookie = `webshop-last-activity=${now}; path=/; SameSite=Lax`;
}, []);
const getLastActivityFromCookie = useCallback((): number => {
const match = document.cookie.match(/(?:^|; )webshop-last-activity=([^;]*)/);
if (match && match[1]) {
const val = parseInt(match[1], 10);
if (!isNaN(val)) return val;
}
return lastActivityRef.current;
}, []);
const handleLogout = useCallback(async (reason = "inactivity") => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
setShowModal(false);
document.cookie = "webshop-last-activity=; path=/; max-age=0";
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout:", e);
}
await supabase.auth.signOut();
router.push(`/auth/login?message=${reason}`);
router.refresh();
}, [supabase, router]);
// Session-Prüfung & Inaktivitäts-Überwachung
useEffect(() => {
// Regelmäßige Überprüfung der Session-Gültigkeit (z.B. wegen Login auf anderem Browser/Gerät)
// 1. Session prüfen
const checkSession = async () => {
const { data: { session } } = await supabase.auth.getSession();
setIsLoggedIn(!!session);
};
checkSession();
// 2. Regelmäßige Intervall-Prüfung (alle 5s)
const interval = setInterval(async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return; // Nicht eingeloggt, keine Prüfung nötig
if (!session) {
setIsLoggedIn(false);
return;
}
setIsLoggedIn(true);
// Prüfen, ob die Session auf dem Server noch gültig ist (z.B. wegen Login auf anderem Browser)
// Prüfen, ob Session auf Server noch gültig ist
const { data: { user }, error } = await supabase.auth.getUser();
if (error) {
// Nur bei echten Auth-Fehlern (z.B. ungültiges Token / Session gelöscht) abmelden.
// Netzwerkfehler wie "Failed to fetch" haben keinen HTTP-Status (error.status ist undefined).
const isAuthError = error.status === 400 || error.status === 401 || error.status === 403;
if (isAuthError) {
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
}
await supabase.auth.signOut();
router.push("/auth/login?message=concurrent");
router.refresh();
handleLogout("concurrent");
return;
}
} else if (!user) {
// Keine Fehlermeldung, aber auch kein User-Objekt (Sitzung abgelaufen/gelöscht)
try {
await signOut();
} catch (e) {
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
}
await supabase.auth.signOut();
router.push("/auth/login?message=concurrent");
router.refresh();
handleLogout("concurrent");
return;
}
}, 5000); // Prüfung alle 5 Sekunden
// Inaktivitätszeitraum (Client + Cookie-Synchronisation) prüfen
const lastActive = getLastActivityFromCookie();
const now = Date.now();
if (!showModal && now - lastActive >= INACTIVITY_TIMEOUT_MS) {
setShowModal(true);
setCountdown(COUNTDOWN_SECONDS);
}
}, 5000);
return () => {
clearInterval(interval);
};
}, [supabase, router]);
}, [supabase, handleLogout, showModal, getLastActivityFromCookie]);
return null;
// Aktivitätsevents registrieren
useEffect(() => {
if (!isLoggedIn) return;
let lastEventTime = 0;
const handleUserActivity = () => {
const now = Date.now();
// Throttling: max. 1x pro Sekunde aktualisieren
if (now - lastEventTime > 1000) {
lastEventTime = now;
// Wenn Modal bereits offen ist, Aktivität nicht im Hintergrund zurücksetzen
if (!showModal) {
updateLastActivityCookie();
}
}
};
const events = ["mousemove", "keydown", "click", "scroll", "touchstart"];
events.forEach((event) => {
window.addEventListener(event, handleUserActivity, { passive: true });
});
return () => {
events.forEach((event) => {
window.removeEventListener(event, handleUserActivity);
});
};
}, [isLoggedIn, showModal, updateLastActivityCookie]);
// Countdown-Timer verwalten, wenn Modal geöffnet wird
useEffect(() => {
if (!showModal) {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
return;
}
countdownIntervalRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(countdownIntervalRef.current!);
handleLogout("inactivity");
return 0;
}
return prev - 1;
});
}, 1000);
return () => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
};
}, [showModal, handleLogout]);
// Inaktivität zurücksetzen / Modal schließen
const handleStayLoggedIn = () => {
updateLastActivityCookie();
setShowModal(false);
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
};
if (!isLoggedIn) return null;
// Werte für den radialen SVG-Timer (Kreisumfang)
const radius = 40;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (countdown / COUNTDOWN_SECONDS) * circumference;
return (
<Dialog open={showModal} onOpenChange={(open) => { if (!open) handleStayLoggedIn(); }}>
<DialogContent showCloseButton={false} className="sm:max-w-md text-center flex flex-col items-center gap-6 p-8">
<DialogHeader className="items-center text-center">
<div className="w-12 h-12 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center mb-2">
<Clock className="w-6 h-6 animate-pulse" />
</div>
<DialogTitle className="text-xl font-bold text-foreground">
Noch da?
</DialogTitle>
<DialogDescription className="text-muted-foreground text-sm max-w-xs mt-1">
Sie sind seit längerem inaktiv. Aus Sicherheitsgründen werden Sie gleich abgemeldet.
</DialogDescription>
</DialogHeader>
{/* Radialer SVG-Timer */}
<div className="relative flex items-center justify-center my-2">
<svg className="w-32 h-32 transform -rotate-90" viewBox="0 0 100 100">
{/* Hintergrundkreis */}
<circle
cx="50"
cy="50"
r={radius}
className="stroke-muted/20"
strokeWidth="8"
fill="transparent"
/>
{/* Countdown-Fortschrittskreis */}
<circle
cx="50"
cy="50"
r={radius}
className="stroke-amber-500 transition-all duration-1000 ease-linear"
strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
fill="transparent"
/>
</svg>
{/* Numerische Restzeit in Sekunden */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-extrabold text-foreground tracking-tight">
{countdown}s
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold mt-0.5">
Verbleibend
</span>
</div>
</div>
<DialogFooter className="w-full sm:justify-center">
<Button
onClick={handleStayLoggedIn}
className="w-full sm:w-auto px-8 bg-amber-500 hover:bg-amber-600 text-black font-semibold shadow-lg shadow-amber-500/20"
>
Ich bin noch da
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -94,18 +94,67 @@ const styles = StyleSheet.create({
}
});
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
export const stripEmojis = (text?: string | null): string => {
if (!text) return '';
return text
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]/gu, '')
.replace(/\s{2,}/g, ' ')
.trim();
};
export const InvoicePDF = ({
order,
orderSnapshot,
customer,
partnerCompanyName,
partnerUserName,
partnerUserEmail,
}: any) => {
const items = orderSnapshot?.items ?? [];
const taxRate = orderSnapshot?.tax_rate ?? 19;
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
const formattedOrderNumber = stripEmojis((order.order_number || order.id || '').replace(/^BE-/, 'AE-'));
const isUpgrade = !!orderSnapshot?.last_license_date;
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;
let oneTimeNet = 0;
let monthlyNet = 0;
const formattedPrice = (val: number) =>
items.forEach((item: any) => {
if (item.billing_interval === 'monthly') {
monthlyNet += item.base_price || 0;
} else {
oneTimeNet += item.base_price || 0;
}
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
monthlyNet += price;
});
});
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
const formattedPrice = (val: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
const groupedItems: { [key: string]: any[] } = {};
items.forEach((item: any) => {
const devName = stripEmojis(item.device_name || 'Kasse 1');
if (!groupedItems[devName]) {
groupedItems[devName] = [];
}
groupedItems[devName].push(item);
});
const displayPartnerName = stripEmojis(partnerCompanyName || order?.partner_company_name);
const displayUserName = stripEmojis(partnerUserName || order?.partner_user_name);
const displayUserEmail = stripEmojis(partnerUserEmail || order?.partner_user_email);
return (
<Document>
<Page size="A4" style={styles.page}>
@@ -123,83 +172,111 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</View>
<Text style={styles.title}>
{isSubscription ? 'Vertragsbestätigung (Abonnement)' : 'B2B-Rechnung'}
{isUpgrade ? 'Erweiterungsangebot' : 'Anfragebestätigung'}: #{formattedOrderNumber}
</Text>
<View style={styles.section}>
<Text style={styles.label}>Kunde</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
</View>
<View style={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 style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 15 }}>
<View style={{ width: '48%' }}>
<Text style={styles.label}>Endkunde</Text>
<Text style={{ fontWeight: 'bold' }}>{stripEmojis(customer?.company_name)}</Text>
{(customer?.first_name || customer?.last_name) ? <Text>{stripEmojis(`${customer?.first_name || ''} ${customer?.last_name || ''}`)}</Text> : null}
{customer?.address ? <Text>{stripEmojis(customer.address)}</Text> : null}
{(customer?.zip_code || customer?.city) ? <Text>{stripEmojis(`${customer?.zip_code || ''} ${customer?.city || ''}`)}</Text> : null}
{customer?.vat_id ? <Text>USt-IdNr: {stripEmojis(customer.vat_id)}</Text> : null}
</View>
{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 style={{ width: '48%' }}>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: #{formattedOrderNumber}</Text>
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
{(displayPartnerName || displayUserName || displayUserEmail) && (
<View style={{ marginTop: 6, paddingTop: 6, borderTopWidth: 1, borderColor: '#eee' }}>
<Text style={styles.label}>Erstellt durch Partner / User</Text>
{displayPartnerName ? <Text style={{ fontWeight: 'bold' }}>Partner: {displayPartnerName}</Text> : null}
{displayUserName ? <Text>Erstellt von: {displayUserName}</Text> : null}
{displayUserEmail ? <Text>E-Mail: {displayUserEmail}</Text> : null}
</View>
{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 style={{ marginTop: 10, marginBottom: 15 }}>
{Object.entries(groupedItems).map(([deviceName, devItems], groupIdx) => (
<View key={groupIdx} style={{ marginBottom: 12, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 4, padding: 8, backgroundColor: '#f8fafc' }}>
<View style={{ borderBottomWidth: 1, borderBottomColor: '#cbd5e1', paddingBottom: 4, marginBottom: 6 }}>
<Text style={{ fontSize: 10, fontWeight: 'bold', color: '#1e3a8a' }}>
{deviceName === 'Zusatzleistung' ? 'Backoffice' : `${isUpgrade ? 'Erweiterung für Gerät' : 'Kasse'}: ${deviceName}`}
</Text>
</View>
{devItems.map((item: any, idx: number) => (
<View key={idx} style={{ marginBottom: 6 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#344155' }}>
{stripEmojis(item.product_name)} ({stripEmojis(item.category_name)})
</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#0f172a' }}>
{formattedPrice(item.base_price)} {item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</Text>
</View>
);
})}
{item.selected_modules?.map((mod: any, mIdx: number) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
return (
<View key={mIdx} style={{ flexDirection: 'row', justifyContent: 'space-between', paddingLeft: 12, marginBottom: 1 }}>
<Text style={{ fontSize: 8, color: '#64748b' }}>
+ {stripEmojis(mod.module_name)} {qty > 1 ? `(x${qty})` : ''}
</Text>
<Text style={{ fontSize: 8, color: '#64748b' }}>
+{formattedPrice(price)} mtl.
</Text>
</View>
);
})}
</View>
))}
</View>
))}
</View>
<View style={styles.total}>
<View style={{ width: '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 style={{ width: '75%' }}>
{oneTimeNet > 0 && (
<View style={{ marginBottom: 6 }}>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Einmalige Beträge:</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{formattedPrice(oneTimeNet)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{formattedPrice(oneTimeTax)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
<Text>Gesamt einmalig (brutto):</Text>
<Text>{formattedPrice(oneTimeGross)}</Text>
</View>
</View>
)}
{monthlyNet > 0 && (
<View style={{ marginTop: 4, paddingTop: 4, borderTopWidth: oneTimeNet > 0 ? 1 : 0, borderTopColor: '#e2e8f0' }}>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Monatlich wiederkehrende Beträge:</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{formattedPrice(monthlyNet)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{formattedPrice(monthlyTax)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
<Text>Gesamt monatlich (brutto):</Text>
<Text>{formattedPrice(monthlyGross)} / mtl.</Text>
</View>
</View>
)}
</View>
</View>
@@ -207,7 +284,7 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
<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.
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' }}>
@@ -217,9 +294,8 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</View>
) : (
<View style={{ marginTop: 20 }}>
<Text style={{ fontWeight: 'bold' }}>Zahlungsziel</Text>
<Text style={{ color: '#555' }}>
Zahlbar innerhalb von 14 Tagen nach Erhalt dieser Rechnung ohne Abzug.
<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 File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { signIn } from "@/lib/actions/auth";
import { signIn, verifyDevice2FA, resend2FACode } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -14,7 +14,19 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Ban } from "lucide-react";
function getDeviceHash(): string {
const key = "caspos-device-id";
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
export function LoginForm({
className,
@@ -28,32 +40,61 @@ export function LoginForm({
const [errorParam, setErrorParam] = useState<string | null>(null);
const router = useRouter();
// 2FA state
const [show2FA, setShow2FA] = useState(false);
const [otpDigits, setOtpDigits] = useState<string[]>(["", "", "", "", "", ""]);
const [userId, setUserId] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(null);
const [resendCooldown, setResendCooldown] = useState(0);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setMessageParam(params.get('message'));
setErrorParam(params.get('error'));
}, []);
// Resend cooldown timer
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
return () => clearTimeout(timer);
}, [resendCooldown]);
const navigateAfterLogin = useCallback((role: string) => {
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
if (nextParam) {
router.push(nextParam);
} else {
router.push("/");
}
}, [router]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
try {
const res = await signIn(email, password);
const deviceHash = getDeviceHash();
const res = await signIn(email, password, deviceHash);
if (!res.success) {
setError(res.error || "Ein Fehler ist aufgetreten.");
return;
}
if (nextParam) {
router.push(nextParam);
} else if (res.role === "admin") {
router.push("/admin/einstellungen");
} else {
router.push("/my-customers");
if (res.requires2FA) {
setUserId(res.userId || null);
setUserRole(res.role || null);
setShow2FA(true);
setResendCooldown(60);
setOtpDigits(["", "", "", "", "", ""]);
setTimeout(() => inputRefs.current[0]?.focus(), 300);
return;
}
navigateAfterLogin(res.role || "partner");
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
} finally {
@@ -61,64 +102,250 @@ export function LoginForm({
}
};
const handleOtpChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return;
const newDigits = [...otpDigits];
newDigits[index] = value.slice(-1);
setOtpDigits(newDigits);
// Auto-advance
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
// Auto-submit when all filled
if (newDigits.every(d => d !== "") && newDigits.join("").length === 6) {
handleVerify2FA(newDigits.join(""));
}
};
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === "Backspace" && !otpDigits[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handleOtpPaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const paste = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
if (paste.length === 6) {
const newDigits = paste.split("");
setOtpDigits(newDigits);
inputRefs.current[5]?.focus();
handleVerify2FA(paste);
}
};
const isVerifyingRef = useRef(false);
const handleVerify2FA = async (code: string) => {
if (!userId || isVerifyingRef.current) return;
isVerifyingRef.current = true;
setIsLoading(true);
setError(null);
try {
const deviceHash = getDeviceHash();
const res = await verifyDevice2FA(userId, code, deviceHash);
if (!res.success) {
setError(res.error || "Ungültiger Code.");
setOtpDigits(["", "", "", "", "", ""]);
inputRefs.current[0]?.focus();
return;
}
navigateAfterLogin(res.role || userRole || "partner");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Fehler bei der Verifizierung.");
setOtpDigits(["", "", "", "", "", ""]);
inputRefs.current[0]?.focus();
} finally {
setIsLoading(false);
isVerifyingRef.current = false;
}
};
const handleResend = async () => {
if (!userId || resendCooldown > 0) return;
setError(null);
const deviceHash = getDeviceHash();
const res = await resend2FACode(userId, deviceHash);
if (res.success) {
setResendCooldown(60);
} else {
setError(res.error || "Fehler beim erneuten Senden.");
}
};
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Anmelden</CardTitle>
<CardDescription>
Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
placeholder="name@beispiel.de"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Passwort</Label>
<Link
href="/auth/forgot-password"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Passwort vergessen?
</Link>
<Card className="overflow-hidden">
<AnimatePresence mode="wait">
{!show2FA ? (
<motion.div
key="login"
initial={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -40, filter: "blur(4px)" }}
transition={{ duration: 0.35, ease: "easeInOut" }}
>
<CardHeader>
<CardTitle className="text-2xl">Anmelden</CardTitle>
<CardDescription>
Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
placeholder="name@beispiel.de"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Passwort</Label>
<Link
href="/auth/forgot-password"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Passwort vergessen?
</Link>
</div>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{errorParam === "gesperrt" && (
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium flex items-center justify-center gap-2">
<Ban className="w-4 h-4 shrink-0" />
<span>Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.</span>
</p>
)}
{messageParam === "concurrent" && (
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
</p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Wird angemeldet..." : "Anmelden"}
</Button>
</div>
</form>
</CardContent>
</motion.div>
) : (
<motion.div
key="2fa"
initial={{ opacity: 0, x: 40, filter: "blur(4px)" }}
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, x: -40 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
>
<CardHeader className="text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-blue-600 dark:text-blue-400">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{errorParam === "gesperrt" && (
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
</p>
)}
{messageParam === "concurrent" && (
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
</p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Wird angemeldet..." : "Anmelden"}
</Button>
</div>
</form>
</CardContent>
<CardTitle className="text-2xl">Sicherheitscode</CardTitle>
<CardDescription className="text-sm mt-1">
Wir haben einen 6-stelligen Code an<br />
<span className="font-semibold text-foreground">{email}</span><br />
gesendet.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6">
{/* OTP Input */}
<div className="flex justify-center gap-2" onPaste={handleOtpPaste}>
{otpDigits.map((digit, i) => (
<input
key={i}
ref={(el) => { inputRefs.current[i] = el; }}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleOtpChange(i, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(i, e)}
className={cn(
"h-14 w-11 rounded-xl border-2 bg-muted/50 text-center text-xl font-bold transition-all duration-200",
"focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none",
"hover:border-muted-foreground/40",
digit ? "border-blue-400 bg-blue-50 dark:bg-blue-950/30" : "border-muted-foreground/20"
)}
id={`otp-${i}`}
autoComplete="one-time-code"
/>
))}
</div>
{error && (
<motion.p
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-red-500 text-center bg-red-500/10 border border-red-500/20 p-3 rounded-lg font-medium"
>
{error}
</motion.p>
)}
<Button
type="button"
className="w-full"
disabled={isLoading || otpDigits.some(d => !d)}
onClick={() => handleVerify2FA(otpDigits.join(""))}
>
{isLoading ? "Wird überprüft..." : "Bestätigen"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Code nicht erhalten?{" "}
<button
type="button"
onClick={handleResend}
disabled={resendCooldown > 0}
className={cn(
"font-medium underline-offset-4 hover:underline transition-colors",
resendCooldown > 0
? "text-muted-foreground/50 cursor-not-allowed"
: "text-blue-600 hover:text-blue-700 dark:text-blue-400"
)}
>
{resendCooldown > 0 ? `Erneut senden (${resendCooldown}s)` : "Erneut senden"}
</button>
</div>
<button
type="button"
onClick={() => {
setShow2FA(false);
setError(null);
setOtpDigits(["", "", "", "", "", ""]);
}}
className="text-sm text-muted-foreground hover:text-foreground transition-colors text-center"
>
Zurück zur Anmeldung
</button>
</div>
</CardContent>
</motion.div>
)}
</AnimatePresence>
</Card>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -156,7 +156,7 @@ export function FetchDataSteps() {
</TutorialStep>
<TutorialStep title="Build in a weekend and scale to millions!">
<p>You&apos;re ready to launch your product to the world! 🚀</p>
<p>You&apos;re ready to launch your product to the world!</p>
</TutorialStep>
</ol>
);

View File

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

View File

@@ -0,0 +1,145 @@
'use client'
import React from 'react'
import { motion } from 'framer-motion'
import { Check } from 'lucide-react'
interface ProgressStepperProps {
step: number
basketItemsCount: number
orientation?: 'horizontal' | 'vertical'
onStepClick?: (step: number) => void
}
const STEP_LABELS = ['Kunde', 'Modell', 'Software', 'Abschluss']
export function ProgressStepper({ step, basketItemsCount, orientation = 'horizontal', onStepClick }: ProgressStepperProps) {
const progressPercent = ((step - 1) / 3) * 100
if (orientation === 'vertical') {
return (
<div className="relative py-2">
<div className="flex flex-col gap-6 relative">
{/* Static Background Vertical Line */}
<div className="absolute left-[19px] top-[20px] bottom-[20px] w-[2px] bg-slate-800 z-0 rounded-full" />
{/* Animated Active Vertical Line */}
<motion.div
className="absolute left-[19px] top-[20px] w-[2px] bg-gradient-to-b from-blue-600 via-sky-400 to-cyan-400 z-0 rounded-full shadow-[0_0_12px_rgba(56,189,248,0.8)]"
initial={{ height: '0%' }}
animate={{ height: `${progressPercent * 0.75}%` }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
/>
{[1, 2, 3, 4].map(s => {
const isDone = step > s
const isActive = step === s
const isClickable = onStepClick && s < step
return (
<div
key={s}
onClick={() => isClickable && onStepClick(s)}
className={`relative z-10 flex items-center gap-3 ${isClickable ? 'cursor-pointer group' : ''}`}
>
<motion.div
initial={false}
animate={{
scale: isActive ? 1.1 : 1,
}}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all duration-300 shrink-0 ${
isDone
? 'bg-sky-500 text-slate-950 shadow-[0_0_15px_rgba(56,189,248,0.6)] border-2 border-sky-400 group-hover:scale-105'
: isActive
? 'bg-slate-950 border-2 border-sky-400 text-sky-400 shadow-[0_0_20px_rgba(56,189,248,0.7)]'
: 'bg-slate-900 border-2 border-slate-800 text-slate-500'
}`}
>
{isDone ? <Check className="w-5 h-5 stroke-[3]" /> : s}
</motion.div>
<div className="flex flex-col">
<span
className={`text-xs font-bold tracking-wide transition-colors duration-300 flex items-center gap-1.5 ${
isActive || isDone ? 'text-sky-300' : 'text-slate-500'
}`}
>
{STEP_LABELS[s - 1]}
{s === 3 && basketItemsCount > 0 && (
<span className="bg-sky-500 text-slate-950 text-[10px] w-4 h-4 rounded-full flex items-center justify-center font-extrabold shadow-sm">
{basketItemsCount}
</span>
)}
</span>
{isClickable && (
<span className="text-[10px] text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity">
Zurück zu Schritt {s}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// Calculate progress percentage for active line (Step 1: 0%, Step 2: 33.3%, Step 3: 66.6%, Step 4: 100%)
return (
<div className="max-w-xl mx-auto mb-10 px-4">
<div className="flex justify-between relative">
{/* Static Background Line */}
<div className="absolute top-[20px] left-[10%] right-[10%] h-[2px] bg-slate-800 z-0 rounded-full" />
{/* Animated Active Line (bg-sky-500) */}
<motion.div
className="absolute top-[20px] left-[10%] h-[2px] bg-gradient-to-r from-blue-600 via-sky-400 to-cyan-400 z-0 rounded-full shadow-[0_0_12px_rgba(56,189,248,0.8)]"
initial={{ width: '0%' }}
animate={{ width: `${progressPercent * 0.8}%` }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
/>
{[1, 2, 3, 4].map(s => {
const isDone = step > s
const isActive = step === s
return (
<div key={s} className="relative z-10 flex flex-col items-center gap-2">
<motion.div
initial={false}
animate={{
scale: isActive ? 1.15 : 1,
}}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all duration-300 ${
isDone
? 'bg-sky-500 text-slate-950 shadow-[0_0_15px_rgba(56,189,248,0.6)] border-2 border-sky-400'
: isActive
? 'bg-slate-950 border-2 border-sky-400 text-sky-400 shadow-[0_0_20px_rgba(56,189,248,0.7)]'
: 'bg-slate-900 border-2 border-slate-800 text-slate-500'
}`}
>
{isDone ? <Check className="w-5 h-5 stroke-[3]" /> : s}
</motion.div>
<span
className={`text-xs font-semibold tracking-wide transition-colors duration-300 flex items-center gap-1.5 ${
isActive || isDone ? 'text-sky-300' : 'text-slate-500'
}`}
>
{STEP_LABELS[s - 1]}
{s === 3 && basketItemsCount > 0 && (
<span className="bg-sky-500 text-slate-950 text-[10px] w-4 h-4 rounded-full flex items-center justify-center font-extrabold shadow-sm">
{basketItemsCount}
</span>
)}
</span>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,237 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
CreditCard,
Calendar,
Check,
ChevronLeft,
ChevronRight,
RefreshCw,
ShoppingBag,
CheckCircle2,
} from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
interface StepBillingProps {
selectedBillingInterval: 'one_time' | 'monthly'
handleBillingIntervalChange: (val: 'one_time' | 'monthly') => void
customerMode: 'select' | 'create'
selectedEndCustomerId: string | null
lastLicenseDate: string
setLastLicenseDate: (date: string) => void
prevStep: () => void
nextStep: () => void
}
export function StepBilling({
selectedBillingInterval,
handleBillingIntervalChange,
lastLicenseDate,
setLastLicenseDate,
prevStep,
nextStep,
}: StepBillingProps) {
const isOneTime = selectedBillingInterval === 'one_time'
const isMonthly = selectedBillingInterval === 'monthly'
return (
<Card className="glass-dark border-white/10 h-[calc(100vh-8.5rem)] flex flex-col justify-between overflow-hidden relative">
{/* ─── 1. FIXED HEADER (shrink-0) ─── */}
<CardHeader className="py-2.5 px-4 shrink-0 border-b border-white/10 bg-slate-950/40 space-y-0">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-lg font-bold flex items-center gap-2 text-white">
<CreditCard className="w-4 h-4 text-primary" />
Abrechnungsmodell wählen
</CardTitle>
<CardDescription className="text-[11px] text-slate-300 mt-0.5">
Wählen Sie zwischen monatlicher Miete (Abonnement) oder einmaligem Softwarekauf.
</CardDescription>
</div>
<Badge variant="outline" className="border-primary/30 text-primary bg-primary/10 text-xs px-2.5 py-0.5">
Schritt 2 von 4
</Badge>
</div>
</CardHeader>
{/* ─── 2. NO-SCROLL MIDDLE CONTENT (flex-1 flex flex-col justify-center items-center overflow-hidden p-4) ─── */}
<div className="flex-1 min-h-0 flex flex-col justify-center items-center overflow-hidden p-4 w-full">
<div className="max-w-3xl w-full grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
{/* Option 1: Miete / Abo */}
<div
onClick={() => handleBillingIntervalChange('monthly')}
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between select-none ${
isMonthly
? 'border-primary bg-primary/10 shadow-[0_0_20px_rgba(59,130,246,0.2)] ring-1 ring-primary/50'
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/[0.07]'
}`}
>
<div>
<div className="flex items-center justify-between gap-3 mb-3">
<div className={`p-2.5 rounded-lg ${isMonthly ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-white/5 text-slate-400 border border-white/10'}`}>
<RefreshCw className={`w-5 h-5 ${isMonthly ? 'animate-spin-slow' : ''}`} />
</div>
{isMonthly && (
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center text-white shadow-md shadow-primary/30">
<Check className="w-3 h-3" />
</div>
)}
</div>
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-white">Miete / Abo</h3>
<Badge variant="outline" className="text-[9px] px-1.5 py-0 bg-blue-500/10 border-blue-500/30 text-blue-400">
Empfohlen
</Badge>
</div>
<p className="text-[11px] font-medium text-slate-300">
Fortlaufende monatliche Abrechnung
</p>
</div>
<p className="text-slate-400 text-[11px] mt-2 leading-relaxed">
Maximale Flexibilität: Inklusive sämtlicher Software-Updates, Cloud-Dienste und Support. Jederzeit kündbar oder anpassbar.
</p>
</div>
<div className="mt-3 pt-2.5 border-t border-white/10 flex items-center justify-between text-[11px]">
<span className="text-slate-400">Zahlungsintervall:</span>
<span className="text-primary font-semibold">monatlich</span>
</div>
</div>
{/* Option 2: Kauf / Einmalkauf (mit integrierter Inline-Erweiterung) */}
<div
onClick={() => handleBillingIntervalChange('one_time')}
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between select-none ${
isOneTime
? 'border-primary bg-primary/10 shadow-[0_0_20px_rgba(59,130,246,0.2)] ring-1 ring-primary/50'
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/[0.07]'
}`}
>
<div>
<div className="flex items-center justify-between gap-3 mb-3">
<div className={`p-2.5 rounded-lg ${isOneTime ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-white/5 text-slate-400 border border-white/10'}`}>
<ShoppingBag className="w-5 h-5" />
</div>
{isOneTime && (
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center text-white shadow-md shadow-primary/30">
<Check className="w-3 h-3" />
</div>
)}
</div>
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-white">Kauf / Einmalkauf</h3>
<Badge variant="outline" className="text-[9px] px-1.5 py-0 bg-slate-500/10 border-slate-500/30 text-slate-300">
Klassisch
</Badge>
</div>
<p className="text-[11px] font-medium text-slate-300">
Einmalige Lizenzgebühr
</p>
</div>
<p className="text-slate-400 text-[11px] mt-2 leading-relaxed">
Dauerhafte Lizenzrechte für die gekaufte Version inklusive 12 Monate kostenfreier Updates.
</p>
{/* Inline-Erweiterung für Kaufdatum / Stichtag */}
<AnimatePresence>
{isOneTime && (
<motion.div
initial={{ opacity: 0, height: 0, marginTop: 0 }}
animate={{ opacity: 1, height: 'auto', marginTop: 12 }}
exit={{ opacity: 0, height: 0, marginTop: 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-3 rounded-lg bg-slate-950/80 border border-white/10 space-y-2">
<div className="flex items-start gap-2">
<Calendar className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />
<div className="space-y-0.5 flex-1">
<Label htmlFor="last-license-date" className="text-white text-[11px] font-bold block">
Datum letzter CASPOS-Kauf <span className="text-slate-400 font-normal">(optional)</span>
</Label>
<p className="text-[10px] text-slate-400 leading-tight">
Zur automatischen Berechnung der Update-Staffel (15% / 30% / 50%).
</p>
</div>
</div>
<div className="relative">
<Input
id="last-license-date"
type="date"
value={lastLicenseDate}
onChange={(e) => setLastLicenseDate(e.target.value)}
className="bg-slate-900 border-white/10 text-white text-xs h-8 focus:border-primary w-full"
/>
</div>
{lastLicenseDate && (
<div className="text-[10px] text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-emerald-400" /> Stichtag erfasst
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="mt-3 pt-2.5 border-t border-white/10 flex items-center justify-between text-[11px]">
<span className="text-slate-400">Zahlungsintervall:</span>
<span className="text-slate-200 font-semibold">einmalig</span>
</div>
</div>
</div>
</div>
{/* ─── 3. FIXED BOTTOM ACTION BAR (shrink-0) ─── */}
<div className="shrink-0 border-t border-white/10 bg-slate-950/90 backdrop-blur-md px-4 py-2.5 flex flex-col sm:flex-row items-center justify-between gap-2 z-10">
{/* Back Button */}
<Button
variant="outline"
onClick={prevStep}
className="w-full sm:w-auto border-white/10 text-white hover:bg-white/10 h-8 text-xs gap-1.5"
>
<ChevronLeft className="w-3.5 h-3.5" /> Zurück zu Schritt 1
</Button>
{/* Selected Model Status Chip */}
<div className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-white/5 border border-white/10 text-white text-xs">
<span className="text-slate-400">Gewähltes Modell:</span>
<Badge
variant="outline"
className={`text-xs px-2 py-0 ${
isMonthly
? 'border-blue-500/30 text-blue-400 bg-blue-500/10'
: 'border-slate-500/30 text-slate-300 bg-slate-500/10'
}`}
>
{isMonthly ? 'Monatliches Abo' : 'Einmaliger Kauf'}
</Badge>
</div>
{/* Next Button */}
<Button
onClick={nextStep}
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white font-semibold px-5 shadow-md shadow-primary/20 h-8 text-xs gap-1.5"
>
Weiter zu Schritt 3 <ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,877 @@
'use client'
import React, { useState, useMemo, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
User,
Building2,
UserPlus,
Search,
Check,
Loader2,
ChevronRight,
ChevronLeft,
ChevronsLeft,
ChevronsRight,
Users,
ChevronDown,
ChevronUp,
X,
CreditCard,
Mail,
MapPin,
CheckCircle2,
Building,
} from 'lucide-react'
import { EndCustomer } from '@/lib/types'
interface StepCustomerProps {
isAdmin: boolean
companies: any[]
selectedCompanyId: string | null
setSelectedCompanyId: (id: string | null) => void
customerMode: 'select' | 'create'
setCustomerMode: (mode: 'select' | 'create') => void
endCustomers: EndCustomer[]
searchTerm: string
setSearchTerm: (term: string) => void
filteredEndCustomers: EndCustomer[]
selectedEndCustomerId: string | null
setSelectedEndCustomerId: (id: string | null) => void
newCustomerForm: any
setNewCustomerForm: React.Dispatch<React.SetStateAction<any>>
handleCreateCustomer: () => Promise<void>
isCreatingCustomer: boolean
nextStep: () => void
}
export function StepCustomer({
isAdmin,
companies,
selectedCompanyId,
setSelectedCompanyId,
customerMode,
setCustomerMode,
endCustomers,
searchTerm,
setSearchTerm,
selectedEndCustomerId,
setSelectedEndCustomerId,
newCustomerForm,
setNewCustomerForm,
handleCreateCustomer,
isCreatingCustomer,
nextStep,
}: StepCustomerProps) {
// ─── PARTNER SELECTION STATE & PAGINATION (Admins only) ──────────────────────
const [partnerSearchTerm, setPartnerSearchTerm] = useState('')
const [partnerPageSize, setPartnerPageSize] = useState<number>(10)
const [partnerCurrentPage, setPartnerCurrentPage] = useState<number>(1)
// Drawer / Sheet state for customer creation
const [showBankDetails, setShowBankDetails] = useState(false)
// Drawer visibility is mapped to customerMode === 'create'
const isDrawerOpen = customerMode === 'create'
const openDrawer = () => setCustomerMode('create')
const closeDrawer = () => {
setCustomerMode('select')
setShowBankDetails(false)
}
useEffect(() => {
setPartnerCurrentPage(1)
}, [partnerSearchTerm, partnerPageSize])
// Count non-anonymized customers per partner company
const customerCountMap = useMemo(() => {
const counts: Record<string, number> = {}
endCustomers.forEach((c) => {
if (!c.is_anonymized && c.partner_id) {
counts[c.partner_id] = (counts[c.partner_id] || 0) + 1
}
})
return counts
}, [endCustomers])
const filteredCompanies = useMemo(() => {
const term = partnerSearchTerm.toLowerCase().trim()
if (!term) return companies
return companies.filter(
(c: any) => c.name?.toLowerCase().includes(term) || c.id?.toLowerCase().includes(term)
)
}, [companies, partnerSearchTerm])
const totalPartnerCount = filteredCompanies.length
const totalPartnerPages = Math.ceil(totalPartnerCount / partnerPageSize) || 1
const paginatedCompanies = useMemo(() => {
const from = (partnerCurrentPage - 1) * partnerPageSize
return filteredCompanies.slice(from, from + partnerPageSize)
}, [filteredCompanies, partnerCurrentPage, partnerPageSize])
// ─── CUSTOMER SELECTION STATE & PAGINATION ──────────────────────────────────
const [pageSize, setPageSize] = useState<number>(10)
const [currentPage, setCurrentPage] = useState<number>(1)
useEffect(() => {
setCurrentPage(1)
}, [searchTerm, selectedCompanyId, pageSize])
// Filter customers by selected partner (if admin and selectedCompanyId != 'all')
const customersByCompany = useMemo(() => {
let list = endCustomers.filter((c) => !c.is_anonymized)
if (isAdmin && selectedCompanyId && selectedCompanyId !== 'all') {
list = list.filter((c) => c.partner_id === selectedCompanyId)
}
return list
}, [endCustomers, isAdmin, selectedCompanyId])
// Customer search filter
const filteredCustomers = useMemo(() => {
const term = searchTerm.toLowerCase().trim()
if (!term) return customersByCompany
return customersByCompany.filter(
(c) =>
c.company_name?.toLowerCase().includes(term) ||
c.first_name?.toLowerCase().includes(term) ||
c.last_name?.toLowerCase().includes(term) ||
c.city?.toLowerCase().includes(term) ||
c.zip?.toLowerCase().includes(term) ||
c.email?.toLowerCase().includes(term)
)
}, [customersByCompany, searchTerm])
const totalCustomerCount = filteredCustomers.length
const totalCustomerPages = Math.ceil(totalCustomerCount / pageSize) || 1
const paginatedCustomers = useMemo(() => {
const from = (currentPage - 1) * pageSize
return filteredCustomers.slice(from, from + pageSize)
}, [filteredCustomers, currentPage, pageSize])
// Selected customer object for summary chip / info
const activeCustomer = useMemo(() => {
return endCustomers.find((c) => c.id === selectedEndCustomerId) || null
}, [endCustomers, selectedEndCustomerId])
// Click handler for customer selection
const handleSelectCustomer = (customer: EndCustomer) => {
setSelectedEndCustomerId(customer.id)
if (isAdmin && customer.partner_id && (selectedCompanyId === 'all' || !selectedCompanyId)) {
setSelectedCompanyId(customer.partner_id)
}
}
// Handle form submission with enter or button
const onSubmitCustomerForm = async (e: React.FormEvent) => {
e.preventDefault()
if (!newCustomerForm.company_name?.trim()) return
await handleCreateCustomer()
}
return (
<>
<Card className="glass-dark border-white/10 h-[calc(100vh-8.5rem)] flex flex-col overflow-hidden relative">
{/* ─── 1. FIXED HEADER (shrink-0) ─── */}
<CardHeader className="py-2.5 px-4 shrink-0 border-b border-white/10 bg-slate-950/40 space-y-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<CardTitle className="text-lg font-bold flex items-center gap-2 text-white">
<User className="w-4 h-4 text-primary" />
Für wen bestellen Sie?
</CardTitle>
<CardDescription className="text-[11px] text-slate-300 mt-0.5">
Wählen Sie den Endkunden für die Lizenzierung aus oder legen Sie schnell einen neuen Datensatz an.
</CardDescription>
</div>
<Button
onClick={openDrawer}
size="sm"
className="gap-1.5 shrink-0 bg-primary hover:bg-primary/90 text-white font-medium shadow-md shadow-primary/20 h-8 text-xs"
>
<UserPlus className="w-3.5 h-3.5" /> Kunde anlegen
</Button>
</div>
</CardHeader>
{/* ─── 2. SCROLLABLE MIDDLE CONTENT (flex-1 min-h-0 overflow-hidden) ─── */}
<div className="flex-1 min-h-0 overflow-hidden p-3">
<div className={`h-full ${isAdmin ? 'grid grid-cols-1 lg:grid-cols-2 gap-3 items-stretch' : 'flex flex-col'}`}>
{/* ─── 2A. ADMIN PARTNER SELECTION ─── */}
{isAdmin && (
<div className="p-3 rounded-xl bg-white/5 border border-white/10 flex flex-col h-full overflow-hidden justify-between space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap shrink-0">
<div>
<Label className="text-white font-bold text-xs flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-primary" />
Partner-Auswahl (Admin)
</Label>
<p className="text-[10px] text-slate-400">
Filtert die Kundenliste nach Partner
</p>
</div>
<Badge variant="outline" className="border-primary/30 text-primary bg-primary/10 text-[9px] px-1.5 py-0">
Admin Modus
</Badge>
</div>
{/* Partner Search & Page Size */}
<div className="flex gap-2 items-center shrink-0">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
<Input
placeholder="Partner suchen..."
value={partnerSearchTerm}
onChange={(e) => setPartnerSearchTerm(e.target.value)}
className="pl-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<select
value={partnerPageSize}
onChange={(e) => setPartnerPageSize(Number(e.target.value))}
className="h-8 rounded-md border border-white/10 bg-slate-900 px-2 text-[11px] text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary shrink-0"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
{/* Partner Card List (Scrollable) */}
<div className="space-y-1.5 flex-1 min-h-0 overflow-y-auto pr-1 py-0.5 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{/* Reserved Fixed Card: Alle Partner */}
<div
onClick={() => {
setSelectedCompanyId('all')
setSelectedEndCustomerId(null)
}}
className={`flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all select-none ${
selectedCompanyId === 'all' || !selectedCompanyId
? 'border-primary bg-primary/10 shadow-sm shadow-primary/10'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/20 flex items-center justify-center text-primary font-bold shrink-0">
<Users className="w-3 h-3" />
</div>
<div>
<p className="font-semibold text-white text-xs leading-tight">
Alle Partner (Firmenübergreifend)
</p>
<p className="text-[10px] text-slate-400 leading-tight">
{endCustomers.filter((c) => !c.is_anonymized).length} Kunden gesamt
</p>
</div>
</div>
{(selectedCompanyId === 'all' || !selectedCompanyId) && (
<Check className="w-3.5 h-3.5 text-primary shrink-0" />
)}
</div>
{/* Dynamic Partner Cards */}
{paginatedCompanies.map((c: any) => {
const isSelected = selectedCompanyId === c.id
const customerCount = customerCountMap[c.id] || 0
return (
<div
key={c.id}
onClick={() => {
setSelectedCompanyId(c.id)
setSelectedEndCustomerId(null)
}}
className={`flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all select-none ${
isSelected
? 'border-primary bg-primary/10 shadow-sm shadow-primary/10'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-md bg-white/5 border border-white/10 flex items-center justify-center text-slate-300 font-bold shrink-0">
<Building2 className="w-3 h-3 text-slate-400" />
</div>
<div className="min-w-0">
<p className="font-semibold text-white text-xs truncate leading-tight">{c.name}</p>
{(c.street || c.zip || c.city) && (
<p className="text-[10px] text-slate-400 truncate leading-tight">
{[c.street, [c.zip, c.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Badge
variant="outline"
className={`text-[9px] px-1.5 py-0 ${
customerCount > 0
? 'border-green-500/30 text-green-400 bg-green-500/10'
: 'border-white/10 text-slate-500 bg-white/5'
}`}
>
{customerCount}
</Badge>
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
</div>
</div>
)
})}
{filteredCompanies.length === 0 && (
<div className="p-2 text-center text-slate-400 text-xs bg-white/5 rounded-lg border border-white/5 my-auto">
Keine Partner gefunden.
</div>
)}
</div>
{/* Partner Pagination Footer */}
<div className="flex items-center justify-between border-t border-white/10 pt-1.5 text-[10px] text-slate-400 shrink-0">
<div>
{Math.min(partnerCurrentPage * partnerPageSize, totalPartnerCount)} / {totalPartnerCount} Partner
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setPartnerCurrentPage(1)}
disabled={partnerCurrentPage === 1}
className="h-5 px-1 text-slate-400 hover:text-white"
>
<ChevronsLeft className="w-3 h-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPartnerCurrentPage((p) => Math.max(1, p - 1))}
disabled={partnerCurrentPage === 1}
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
>
<ChevronLeft className="w-2.5 h-2.5 mr-0.5" /> Zurück
</Button>
<span className="px-1 text-slate-300">
{partnerCurrentPage} / {totalPartnerPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPartnerCurrentPage((p) => Math.min(totalPartnerPages, p + 1))}
disabled={partnerCurrentPage === totalPartnerPages}
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
>
Vor <ChevronRight className="w-2.5 h-2.5 ml-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setPartnerCurrentPage(totalPartnerPages)}
disabled={partnerCurrentPage === totalPartnerPages}
className="h-5 px-1 text-slate-400 hover:text-white"
>
<ChevronsRight className="w-3 h-3" />
</Button>
</div>
</div>
</div>
)}
{/* ─── 2B. CUSTOMER SELECTION LIST (Scrollable Panel) ─── */}
<div className="p-3 rounded-xl bg-white/5 border border-white/10 flex flex-col h-full overflow-hidden justify-between space-y-2">
<div className="flex items-center justify-between gap-2 shrink-0">
<div className="min-w-0">
<Label className="text-white font-bold text-xs flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-primary" />
Kunden-Auswahl
</Label>
<p className="text-[10px] text-slate-400 truncate">
{isAdmin && selectedCompanyId && selectedCompanyId !== 'all'
? `Partner: "${companies.find((c) => c.id === selectedCompanyId)?.name || selectedCompanyId}"`
: 'Alle Endkunden'}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={openDrawer}
className="border-primary/40 text-primary hover:bg-primary/10 h-7 text-xs gap-1.5 shrink-0"
>
<UserPlus className="w-3 h-3" /> Neuer Kunde
</Button>
</div>
{/* Customer Search */}
<div className="relative shrink-0">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
<Input
placeholder="Kunden suchen nach Name, Ort, PLZ, E-Mail..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500 text-xs h-8"
/>
</div>
{/* Customer Card List (Isolated Scrollable Area) */}
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-1.5 py-0.5 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{customersByCompany.length === 0 ? (
<div className="p-4 rounded-xl bg-white/5 border border-white/10 text-center space-y-2 my-auto">
<Building2 className="w-6 h-6 text-slate-600 mx-auto" />
<p className="text-slate-400 text-xs">
{isAdmin && selectedCompanyId && selectedCompanyId !== 'all'
? 'Keine Endkunden für den ausgewählten Partner vorhanden.'
: 'Noch keine Endkunden vorhanden.'}
</p>
<Button
variant="outline"
size="sm"
onClick={openDrawer}
className="border-white/10 text-white text-xs h-7"
>
<UserPlus className="w-3 h-3 mr-1.5" /> Ersten Kunden anlegen
</Button>
</div>
) : filteredCustomers.length === 0 ? (
<div className="p-3 text-center text-slate-400 text-xs bg-white/5 rounded-lg border border-white/5 my-auto">
Keine passenden Kunden für &quot;{searchTerm}&quot; gefunden.
</div>
) : (
paginatedCustomers.map((customer) => {
const isSelected = selectedEndCustomerId === customer.id
return (
<div
key={customer.id}
onClick={() => handleSelectCustomer(customer)}
className={`flex items-start p-2 rounded-lg border cursor-pointer transition-all select-none ${
isSelected
? 'border-primary bg-primary/10 shadow-md shadow-primary/5'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<input
type="radio"
name="end_customer"
value={customer.id}
checked={isSelected}
onChange={() => handleSelectCustomer(customer)}
className="sr-only"
/>
<div className="flex-1 min-w-0 pr-2">
<div className="flex items-center gap-1.5 flex-wrap">
<p className="font-semibold text-white text-xs truncate leading-tight">{customer.company_name}</p>
{isAdmin && customer.partner_id && (
<Badge
variant="outline"
className="text-[9px] px-1 py-0 border-white/10 text-slate-400 bg-white/5 leading-tight"
>
Partner: {companies.find((co) => co.id === customer.partner_id)?.name || customer.partner_id}
</Badge>
)}
</div>
<p className="text-[10px] text-slate-400 mt-0.5 truncate leading-tight">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
{customer.first_name || customer.last_name ? ' · ' : ''}
{[customer.street, customer.zip, customer.city].filter(Boolean).join(', ')}
</p>
{customer.email && (
<p className="text-[10px] text-slate-500 mt-0.5 flex items-center gap-1 truncate leading-tight">
<Mail className="w-2.5 h-2.5 shrink-0" /> {customer.email}
</p>
)}
</div>
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />}
</div>
)
})
)}
</div>
{/* Customer Pagination */}
{totalCustomerCount > 0 && (
<div className="flex items-center justify-between border-t border-white/10 pt-1.5 text-[10px] text-slate-400 shrink-0 flex-wrap gap-1.5">
<div className="flex items-center gap-1.5">
<span>
{Math.min(currentPage * pageSize, totalCustomerCount)} / {totalCustomerCount} Kunden
</span>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="h-5 rounded border border-white/10 bg-slate-900 px-1 text-[10px] text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="h-5 px-1 text-slate-400 hover:text-white"
>
<ChevronsLeft className="w-3 h-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
>
<ChevronLeft className="w-2.5 h-2.5 mr-0.5" /> Zurück
</Button>
<span className="px-1 text-slate-300">
{currentPage} / {totalCustomerPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalCustomerPages, p + 1))}
disabled={currentPage === totalCustomerPages}
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
>
Vor <ChevronRight className="w-2.5 h-2.5 ml-0.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(totalCustomerPages)}
disabled={currentPage === totalCustomerPages}
className="h-5 px-1 text-slate-400 hover:text-white"
>
<ChevronsRight className="w-3 h-3" />
</Button>
</div>
</div>
)}
</div>
</div>
</div>
{/* ─── 3. FIXED BOTTOM ACTION BAR (shrink-0) ─── */}
<div className="shrink-0 border-t border-white/10 bg-slate-950/90 backdrop-blur-md px-4 py-2.5 flex flex-col sm:flex-row items-center justify-between gap-2 z-10">
{/* Selected Customer Summary Chip */}
<div className="flex items-center gap-2 min-w-0">
{activeCustomer ? (
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-primary/10 border border-primary/30 text-white min-w-0">
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
<div className="text-xs truncate">
<span className="text-slate-400">Ausgewählt:</span>{' '}
<strong className="text-white font-semibold">{activeCustomer.company_name}</strong>
{(activeCustomer.city || activeCustomer.zip) && (
<span className="text-slate-400 ml-1">
({[activeCustomer.zip, activeCustomer.city].filter(Boolean).join(' ')})
</span>
)}
</div>
</div>
) : (
<div className="text-xs text-slate-400 flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-slate-500" />
<span>Bitte wählen Sie einen Kunden aus, um fortzufahren.</span>
</div>
)}
</div>
{/* Next Button */}
<Button
onClick={nextStep}
disabled={!selectedEndCustomerId}
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white font-semibold px-5 shadow-md shadow-primary/20 h-8 text-xs gap-1.5"
>
Weiter zu Schritt 2 <ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</Card>
{/* ─── 4. SLIDE-OVER DRAWER FOR CUSTOMER CREATION ─── */}
<AnimatePresence>
{isDrawerOpen && (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={closeDrawer}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-screen max-w-xl bg-slate-900 border-l border-white/10 shadow-2xl flex flex-col justify-between"
>
{/* Drawer Header */}
<div className="p-5 border-b border-white/10 flex items-center justify-between bg-slate-950/60 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/20 border border-primary/30 flex items-center justify-center text-primary">
<UserPlus className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">Neuen Kunden anlegen</h3>
<p className="text-xs text-slate-400">
{isAdmin && selectedCompanyId && selectedCompanyId !== 'all'
? `Für Partner: ${companies.find((c) => c.id === selectedCompanyId)?.name || selectedCompanyId}`
: 'Erstellen und direkt für die Bestellung auswählen'}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={closeDrawer}
className="text-slate-400 hover:text-white rounded-full h-8 w-8"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Drawer Body / Form */}
<form id="new-customer-form" onSubmit={onSubmitCustomerForm} className="p-5 overflow-y-auto flex-1 space-y-5 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{/* Basic Info Section */}
<div className="space-y-3.5">
<div className="flex items-center gap-2 pb-1 border-b border-white/5">
<Building className="w-3.5 h-3.5 text-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-300">
Stammdaten & Anschrift
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
<div className="md:col-span-2 space-y-1">
<Label className="text-slate-300 text-xs font-semibold">Firmenname *</Label>
<Input
required
value={newCustomerForm.company_name || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, company_name: e.target.value }))
}
placeholder="z. B. Musterfirma GmbH & Co. KG"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs font-semibold">USt-IdNr.</Label>
<Input
value={newCustomerForm.vat_id || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, vat_id: e.target.value }))
}
placeholder="z. B. DE123456789"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs font-semibold">E-Mail</Label>
<Input
type="email"
value={newCustomerForm.email || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, email: e.target.value }))
}
placeholder="buchhaltung@muster.de"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs font-semibold">Vorname</Label>
<Input
value={newCustomerForm.first_name || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, first_name: e.target.value }))
}
placeholder="Max"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs font-semibold">Nachname</Label>
<Input
value={newCustomerForm.last_name || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, last_name: e.target.value }))
}
placeholder="Mustermann"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="md:col-span-2 space-y-1">
<Label className="text-slate-300 text-xs font-semibold">Straße & Hausnummer</Label>
<Input
value={newCustomerForm.street || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, street: e.target.value }))
}
placeholder="Hauptstraße 12"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs font-semibold">PLZ</Label>
<Input
value={newCustomerForm.zip || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, zip: e.target.value }))
}
placeholder="10115"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs font-semibold">Ort</Label>
<Input
value={newCustomerForm.city || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, city: e.target.value }))
}
placeholder="Berlin"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
</div>
</div>
{/* Collapsible Bank Details Section */}
<div className="rounded-xl border border-white/10 bg-slate-950/40 overflow-hidden">
<button
type="button"
onClick={() => setShowBankDetails((prev) => !prev)}
className="w-full flex items-center justify-between p-3 text-left hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<CreditCard className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-xs font-bold text-white">Optionale Zahlungs-/Bankdaten</p>
<p className="text-[10px] text-slate-400">SEPA-Lastschrift, IBAN & BIC hinterlegen</p>
</div>
</div>
{showBankDetails ? (
<ChevronUp className="w-3.5 h-3.5 text-slate-400" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
)}
</button>
<AnimatePresence>
{showBankDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-3.5 border-t border-white/10 space-y-3 bg-white/[0.02]"
>
<div className="space-y-1">
<Label className="text-slate-300 text-xs">Kontoinhaber</Label>
<Input
value={newCustomerForm.bank_owner || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, bank_owner: e.target.value }))
}
placeholder="Max Mustermann"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs">IBAN</Label>
<Input
value={newCustomerForm.bank_iban || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, bank_iban: e.target.value }))
}
placeholder="DE89370400440532013000"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8 font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div className="space-y-1">
<Label className="text-slate-300 text-xs">BIC</Label>
<Input
value={newCustomerForm.bank_bic || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, bank_bic: e.target.value }))
}
placeholder="SOLADE21XXX"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8 font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-slate-300 text-xs">Bankname</Label>
<Input
value={newCustomerForm.bank_name || ''}
onChange={(e) =>
setNewCustomerForm((prev: any) => ({ ...prev, bank_name: e.target.value }))
}
placeholder="Musterbank"
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</form>
{/* Drawer Footer */}
<div className="p-4 border-t border-white/10 bg-slate-950/60 flex items-center justify-end gap-2.5 shrink-0">
<Button
type="button"
variant="ghost"
onClick={closeDrawer}
disabled={isCreatingCustomer}
className="text-slate-300 hover:text-white h-8 text-xs"
>
Abbrechen
</Button>
<Button
type="submit"
form="new-customer-form"
disabled={isCreatingCustomer || !newCustomerForm.company_name?.trim()}
className="gap-2 bg-primary hover:bg-primary/90 text-white font-semibold px-4 h-8 text-xs"
>
{isCreatingCustomer ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Speichere Kunde...
</>
) : (
<>
<UserPlus className="w-3.5 h-3.5" />
Kunden anlegen & auswählen
</>
)}
</Button>
</div>
</motion.div>
</div>
</div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,416 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ShoppingCart, Check, AlertCircle, Lock, X, Info } from 'lucide-react'
import * as Icons from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { Category, Product, CategorySelection } from '@/lib/types'
interface StepSoftwareProps {
visibleCategories: Category[]
products: Product[]
selections: Record<string, CategorySelection>
selectProduct: (catId: string, prodId: string) => void
isProductDisabled: (prod: Product, catId: string) => boolean
isModuleDisabled: (mod: any, selectedModIds: string[]) => boolean
toggleModule: (catId: string, modId: string) => void
moduleQuantities: Record<string, number>
setModuleQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>
selectedBillingInterval: 'one_time' | 'monthly'
billingLabel: (interval: string) => string
billingBadgeClass: (interval: string) => string
/** Modul-IDs, die bereits lizenziert sind (Upgrade-Modus) */
existingModuleIds?: string[]
activeCategoryId: string | null
editingDeviceName?: string | null
onCancelEdit?: () => void
productValidationErrors?: string[]
}
export function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const LucideIcon = icon ? (Icons as any)[icon] : null
if (!LucideIcon) return <Icons.HelpCircle className={className} />
return <LucideIcon className={className} />
}
export function StepSoftware({
visibleCategories,
products,
selections,
selectProduct,
isProductDisabled,
isModuleDisabled,
toggleModule,
moduleQuantities,
setModuleQuantities,
selectedBillingInterval,
billingLabel,
billingBadgeClass,
existingModuleIds = [],
activeCategoryId,
editingDeviceName = null,
onCancelEdit,
productValidationErrors = [],
}: StepSoftwareProps) {
const currentCategory = visibleCategories.find(c => c.id === activeCategoryId) ?? visibleCategories[0] ?? null
if (!currentCategory) return null
const catProducts = products.filter(p => {
if (p.category_id !== currentCategory.id) return false
return selectedBillingInterval === 'one_time'
? p.show_in_kauf !== false
: p.show_in_abo !== false
})
const sel = selections[currentCategory.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
return (
<Card className="glass-dark border-white/10 h-full flex flex-col rounded-2xl overflow-hidden min-h-0">
{/* ─── FIXED CARD HEADER (shrink-0 mb-1) ─── */}
<CardHeader className="border-b border-white/10 py-2.5 px-4 shrink-0 bg-slate-950/40">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-primary/20 text-primary border border-primary/30 shrink-0">
<ShoppingCart className="w-4 h-4" />
</div>
<div>
<CardTitle className="text-sm font-bold text-white leading-tight">
{currentCategory.name}
</CardTitle>
<CardDescription className="text-[11px] text-slate-400 mt-0.5 leading-tight">
{currentCategory.description || 'Passen Sie die Konfiguration für diese Kategorie an.'}
</CardDescription>
</div>
</div>
{editingDeviceName && (
<div className="flex items-center gap-2">
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs font-bold gap-1.5 px-2.5 py-0.5 rounded-lg animate-pulse shrink-0 flex items-center">
<Icons.Pencil className="w-3 h-3 text-amber-400 mr-1" /> Kasse: &quot;{editingDeviceName}&quot;
</Badge>
{onCancelEdit && (
<Button
size="sm"
variant="ghost"
onClick={onCancelEdit}
className="h-6 text-[11px] text-slate-400 hover:text-white hover:bg-white/10 px-2"
>
<X className="w-3 h-3 mr-1" /> Neuer Entwurf
</Button>
)}
</div>
)}
</div>
</CardHeader>
{/* ─── SCROLLABLE CONTENT AREA (flex-1 min-h-0 min-w-0 overflow-y-auto pr-3 pb-12) ─── */}
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-4 pr-3 pb-12 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
<AnimatePresence mode="wait">
<motion.div
key={currentCategory.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="space-y-4 min-w-0"
>
{/* Category selection status header */}
{(() => {
const isMissingRequired = currentCategory.is_required && !sel?.productId && (!sel?.productIds || sel.productIds.length === 0)
return (
<div className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
isMissingRequired
? 'bg-red-500/10 border-red-500/50 text-red-400 font-bold'
: 'bg-white/5 border-white/10'
}`}>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
isMissingRequired ? 'bg-red-500/20 text-red-400' : 'bg-primary/20 text-primary'
}`}>
<CategoryIcon icon={currentCategory.icon} className="w-3.5 h-3.5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-bold text-white text-xs flex items-center gap-2 flex-wrap">
<span>{currentCategory.name}</span>
{isMissingRequired && (
<span className="text-[10px] bg-red-500/20 text-red-400 border border-red-500/40 px-2 py-0.2 rounded-full font-extrabold animate-pulse tracking-wider">
PFLICHTFELD AUSWÄHLEN!
</span>
)}
</h3>
<p className="text-slate-400 text-[10px] mt-0.5">
{currentCategory.allow_multiselect ? 'Mehrfachauswahl möglich' : 'Einzelauswahl'}
</p>
</div>
{sel?.productIds && sel.productIds.length > 0 ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30 text-[11px] rounded-full shrink-0">
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
</Badge>
) : sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30 text-[11px] rounded-full shrink-0">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
) : currentCategory.is_required ? (
<Badge variant="destructive" className="ml-auto opacity-90 text-[11px] rounded-full bg-red-500/30 text-red-400 border border-red-500/50 animate-pulse font-extrabold shrink-0">
<AlertCircle className="w-3 h-3 mr-1 text-red-400" /> Bitte auswählen!
</Badge>
) : (
<Badge variant="outline" className="ml-auto border-white/10 text-slate-400 text-[11px] rounded-full bg-white/5 shrink-0">
Optional
</Badge>
)}
</div>
)
})()}
{/* Products List (grid-cols-1 xl:grid-cols-2 gap-3) */}
{catProducts.length === 0 ? (
<p className="text-slate-500 text-xs italic p-4 bg-white/5 rounded-xl border border-white/5 text-center">
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
</p>
) : currentCategory.allow_multiselect ? (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
{catProducts.map(product => {
const isChecked = sel?.productIds?.includes(product.id) ?? false
const disabled = isProductDisabled(product, currentCategory.id)
return (
<div key={product.id} className="relative min-w-0">
<Label
onClick={() => !disabled && selectProduct(currentCategory.id, product.id)}
className={`flex flex-col justify-between h-full p-3 rounded-xl border transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="min-w-0 w-full">
<div className="flex justify-between items-start gap-2 w-full">
<div className="flex items-start gap-2 min-w-0 flex-1">
<Checkbox
checked={isChecked}
disabled={disabled}
onCheckedChange={() => { }}
className="border-white/20 data-[state=checked]:bg-primary rounded mt-0.5 shrink-0"
/>
<div className="min-w-0 flex-1">
<span className="font-bold text-xs text-white block break-words">{product.name}</span>
<span className={`inline-block text-[9px] px-1.5 py-0.2 mt-1 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
</span>
</div>
</div>
<span className="text-primary font-bold text-xs shrink-0 text-right">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-[10px] text-slate-500 font-normal block">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<p className="text-[11px] text-slate-400 mt-2 font-normal leading-relaxed break-words">{product.description}</p>
)}
</div>
{product.modules && product.modules.length > 0 && (
<span className="text-[10px] text-slate-500 mt-2 font-normal block">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</div>
) : (
<RadioGroup
value={sel?.productId ?? ''}
onValueChange={id => selectProduct(currentCategory.id, id)}
className="grid grid-cols-1 xl:grid-cols-2 gap-3"
>
{catProducts.map(product => {
const disabled = isProductDisabled(product, currentCategory.id)
const isChecked = sel?.productId === product.id
return (
<div key={product.id} className="relative min-w-0">
<RadioGroupItem
value={product.id}
id={`${currentCategory.id}-${product.id}`}
disabled={disabled}
className="peer sr-only"
/>
<Label
htmlFor={disabled ? undefined : `${currentCategory.id}-${product.id}`}
className={`flex flex-col justify-between h-full p-3 rounded-xl border transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="min-w-0 w-full">
<div className="flex justify-between items-start gap-2 w-full">
<div className="min-w-0 flex-1">
<span className="font-bold text-xs text-white block break-words">{product.name}</span>
<span className={`inline-block text-[9px] px-1.5 py-0.2 mt-1 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
</span>
</div>
<span className="text-primary font-bold text-xs shrink-0 text-right">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-[10px] text-slate-500 font-normal block">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<p className="text-[11px] text-slate-400 mt-2 font-normal leading-relaxed break-words">{product.description}</p>
)}
</div>
{product.modules && product.modules.length > 0 && (
<span className="text-[10px] text-slate-500 mt-2 font-normal block">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
)}
{/* Modules Section */}
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
<div className="mt-4 p-3.5 rounded-xl bg-white/5 border border-white/10 space-y-3 min-w-0">
<p className="text-xs font-bold text-white flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
Zusatzmodule für {selectedProduct.name}
</p>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-2.5">
{selectedProduct.modules.map(module => {
const isExistingLicense = existingModuleIds.includes(module.id)
const disabled = isExistingLicense || isModuleDisabled(module, sel?.moduleIds ?? [])
const checked = isExistingLicense || (sel?.moduleIds.includes(module.id) ?? false)
return (
<div
key={module.id}
title={isExistingLicense ? "Dieses Modul ist auf dieser Kasse bereits aktiv und dauerhaft lizenziert." : undefined}
className={`flex flex-col justify-between p-3 rounded-xl border transition-all duration-200 min-w-0 ${
isExistingLicense
? 'border-primary/20 bg-primary/5 opacity-75'
: disabled
? 'border-white/5 bg-white/5 opacity-40'
: checked
? 'border-primary bg-primary/10 shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40 text-white'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<div className="flex items-start space-x-2.5 min-w-0">
<Checkbox
id={`mod-${currentCategory.id}-${module.id}`}
checked={checked}
onCheckedChange={() => !isExistingLicense && toggleModule(currentCategory.id, module.id)}
disabled={disabled}
className="rounded border-white/20 data-[state=checked]:bg-primary mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<Label
htmlFor={isExistingLicense ? undefined : `mod-${currentCategory.id}-${module.id}`}
className={`font-semibold text-xs flex justify-between items-start text-white gap-2 ${
isExistingLicense ? 'cursor-default' : disabled ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<span className="flex-1 min-w-0 break-words">
{module.name}
{isExistingLicense && (
<span className="inline-flex items-center gap-1 text-[9px] px-1.5 py-0.2 rounded bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-wider ml-1 mt-0.5">
<Lock className="w-2.5 h-2.5" /> Lizenziert
</span>
)}
</span>
<span className="text-primary font-bold text-xs shrink-0 text-right">
{isExistingLicense ? (
<span className="text-slate-500 text-[11px] font-normal">inkl.</span>
) : (
<>+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}</>
)}
</span>
</Label>
{module.description && (
<p className="text-[11px] text-slate-400 mt-1 leading-relaxed break-words">{module.description}</p>
)}
{!isExistingLicense && disabled && (
<p className="text-[10px] text-red-400 mt-1 font-medium flex items-center gap-1">
<AlertCircle className="w-3 h-3 text-red-400 shrink-0" />
{module.requirements?.length && !module.requirements.some(
reqId => sel?.moduleIds.includes(reqId)
)
? 'Benötigt weitere Module'
: 'Nicht kombinierbar mit aktueller Auswahl'}
</p>
)}
</div>
</div>
{/* Scalable Quantity */}
{checked && !isExistingLicense && module.has_quantity && (
<div className="flex items-center gap-3 mt-2.5 pl-6 pt-2 border-t border-white/5">
<Label htmlFor={`qty-${module.id}`} className="text-[11px] text-slate-400 font-medium shrink-0">Menge:</Label>
<Input
id={`qty-${module.id}`}
type="number"
min={1}
max={999}
value={moduleQuantities[module.id] || 1}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value) || 1)
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
}}
className="w-14 h-6 bg-white/5 border-white/10 text-white text-xs text-center rounded focus:border-primary focus:ring-1 focus:ring-primary"
/>
<span className="text-[10px] text-slate-400 truncate">
Gesamt:{' '}
<span className="text-primary font-bold">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price * (moduleQuantities[module.id] || 1))}
</span>
</span>
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Inline Validation Warnings */}
{productValidationErrors.length > 0 && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 space-y-1">
{productValidationErrors.map((err, errIdx) => (
<p key={errIdx} className="text-xs text-red-400 flex items-center gap-1.5 font-medium">
<AlertCircle className="w-3.5 h-3.5 shrink-0 text-red-400" />
{err}
</p>
))}
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</Card>
)
}

View File

@@ -0,0 +1,590 @@
'use client'
import React, { useState } from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
ShieldCheck,
Building2,
Calendar,
Loader2,
CheckCircle2,
Pencil,
Trash2,
Plus,
ChevronLeft,
MessageSquare,
FileText,
Lock,
Check,
X,
CreditCard,
User,
MapPin,
Send,
} from 'lucide-react'
import * as Icons from 'lucide-react'
import { Category, Product, EndCustomer, Profile } from '@/lib/types'
interface StepSummaryProps {
finalItemsToShow: any[]
visibleCategories: Category[]
products: Product[]
oneTimeTotal: number
monthlyTotal: number
oneTimeNet: number
oneTimeTax: number
oneTimeGross: number
monthlyNet: number
monthlyTax: number
monthlyGross: number
updatePriceModifier: { label?: string }
selectedEndCustomer: EndCustomer | null
customerData: Partial<Profile>
lastLicenseDate: string
handleSubmit: () => Promise<void>
isSubmitting: boolean
initialOrder: any
prevStep: () => void
linkedFeeProducts?: Product[]
orderNotes: string
setOrderNotes: (notes: string) => void
onEditBasketItem?: (idx: number) => void
onAddNewBasketItem?: () => void
onDeleteBasketItem?: (idx: number) => void
}
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const LucideIcon = icon ? (Icons as any)[icon] : null
if (!LucideIcon) return <Icons.HelpCircle className={className} />
return <LucideIcon className={className} />
}
export function StepSummary({
finalItemsToShow,
visibleCategories,
products,
oneTimeTotal,
monthlyTotal,
oneTimeNet,
oneTimeTax,
oneTimeGross,
monthlyNet,
monthlyTax,
monthlyGross,
updatePriceModifier,
selectedEndCustomer,
customerData,
lastLicenseDate,
handleSubmit,
isSubmitting,
initialOrder,
prevStep,
linkedFeeProducts = [],
orderNotes,
setOrderNotes,
onEditBasketItem,
onAddNewBasketItem,
onDeleteBasketItem,
}: StepSummaryProps) {
const [confirmDeleteIdx, setConfirmDeleteIdx] = useState<number | null>(null)
const [acceptedTerms, setAcceptedTerms] = useState(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
const totalPositionsCount = finalItemsToShow.length
const primaryInterval = finalItemsToShow[0]?.billingInterval || 'monthly'
return (
<Card className="glass-dark border-white/10 h-[calc(100vh-8.5rem)] flex flex-col justify-between overflow-hidden relative">
{/* ─── 1. FIXED HEADER (shrink-0) ─── */}
<CardHeader className="py-2.5 px-4 shrink-0 border-b border-white/10 bg-slate-950/40 space-y-0">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-primary/20 rounded-lg flex items-center justify-center border border-primary/40 shrink-0 text-primary">
<ShieldCheck className="w-4 h-4" />
</div>
<div>
<CardTitle className="text-lg font-bold text-white flex items-center gap-2">
Anfrage prüfen & absenden
</CardTitle>
<CardDescription className="text-[11px] text-slate-300 mt-0.5">
Überprüfen Sie alle konfigurierten Kassen, Positionen und Konditionen vor der Freigabe.
</CardDescription>
</div>
</div>
<Badge variant="outline" className="border-primary/30 text-primary bg-primary/10 text-xs px-2.5 py-0.5">
Schritt 4 von 4
</Badge>
</div>
</CardHeader>
{/* ─── 2. SCROLLABLE MIDDLE (flex-1 min-h-0 overflow-y-auto) ─── */}
<div className="flex-1 min-h-0 overflow-y-auto p-4 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 max-w-7xl mx-auto w-full items-start">
{/* ─── LINKES RASTER (lg:col-span-7) Auftragsübersicht & Kassen ─── */}
<div className="lg:col-span-7 space-y-4">
{/* Kopf-Card: Endkunde & Abrechnungsmodell */}
<div className="p-4 rounded-2xl bg-white/5 border border-white/10 space-y-3">
<div className="flex items-center justify-between gap-2 border-b border-white/5 pb-2.5 flex-wrap">
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-wider">
<Building2 className="w-4 h-4" />
<span>Kunde & Abrechnung</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] bg-primary/10 border-primary/30 text-primary font-semibold">
{primaryInterval === 'one_time' ? 'Einmalkauf' : 'Monatliches Abo'}
</Badge>
{lastLicenseDate && (
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/30 text-emerald-400">
Stichtag: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
</Badge>
)}
</div>
</div>
{selectedEndCustomer ? (
<div className="space-y-1">
<h4 className="text-sm font-bold text-white">{selectedEndCustomer.company_name}</h4>
<div className="flex items-center gap-3 text-xs text-slate-300 flex-wrap">
{(selectedEndCustomer.first_name || selectedEndCustomer.last_name) && (
<span className="flex items-center gap-1">
<User className="w-3 h-3 text-slate-400" />
{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}
</span>
)}
{(selectedEndCustomer.street || selectedEndCustomer.zip || selectedEndCustomer.city) && (
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3 text-slate-400" />
{[selectedEndCustomer.street, [selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')}
</span>
)}
{selectedEndCustomer.vat_id && (
<span className="text-slate-400">USt-IdNr: {selectedEndCustomer.vat_id}</span>
)}
</div>
</div>
) : (
<p className="text-xs text-slate-400 italic">Kein Endkunde zugewiesen (Partner-Bestellung).</p>
)}
</div>
{/* Kassenliste */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2">
<span>Enthaltene Kassen ({finalItemsToShow.length})</span>
</h3>
{onAddNewBasketItem && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddNewBasketItem}
className="border-dashed border-primary/40 text-primary hover:bg-primary/10 text-xs font-semibold gap-1.5 h-7"
>
<Plus className="w-3 h-3" /> Weitere Kasse hinzufügen
</Button>
)}
</div>
{finalItemsToShow.length === 0 ? (
<div className="p-6 rounded-2xl bg-white/5 border border-white/10 text-center space-y-2">
<p className="text-xs text-slate-400 font-medium">Keine Kassen in der Aufstellung vorhanden.</p>
</div>
) : (
finalItemsToShow.map((item, itemIdx) => (
<div
key={itemIdx}
className="p-4 rounded-2xl bg-white/5 border border-white/10 space-y-3 relative overflow-hidden"
>
{/* Kassen Header Bar */}
<div className="flex justify-between items-center bg-slate-950/40 p-2.5 rounded-xl border border-white/5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-white font-bold text-xs truncate">
{item.deviceName || `Kasse ${itemIdx + 1}`}
</span>
{itemIdx > 0 && (
<Badge variant="outline" className="text-[9px] px-1.5 py-0 border-emerald-500/30 text-emerald-400 bg-emerald-500/10 shrink-0">
Kasse 2+
</Badge>
)}
{item.licenseNumber && (
<span className="text-[10px] text-slate-400 font-mono truncate">
({item.licenseNumber})
</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge
variant="outline"
className={`text-[9px] px-2 py-0.5 font-semibold ${
item.billingInterval === 'one_time'
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-blue-500/30 text-blue-400 bg-blue-500/10'
}`}
>
{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}
</Badge>
{onEditBasketItem && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onEditBasketItem(itemIdx)}
className="h-7 px-2 text-xs text-slate-300 hover:text-primary hover:bg-primary/20 gap-1 rounded-lg"
title="Kasse in Schritt 3 bearbeiten"
>
<Pencil className="w-3 h-3" /> Bearbeiten
</Button>
)}
{onDeleteBasketItem && (
<Button
type="button"
variant={confirmDeleteIdx === itemIdx ? 'destructive' : 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation()
if (confirmDeleteIdx === itemIdx) {
onDeleteBasketItem(itemIdx)
setConfirmDeleteIdx(null)
} else {
setConfirmDeleteIdx(itemIdx)
}
}}
onMouseLeave={() => setConfirmDeleteIdx(null)}
className={`h-7 px-2 text-xs transition-all ${
confirmDeleteIdx === itemIdx
? 'bg-red-600 text-white hover:bg-red-700 font-bold px-2.5 shadow-md shadow-red-500/30'
: 'text-slate-400 hover:text-red-400 hover:bg-red-500/20'
} gap-1 rounded-lg`}
title="Kasse entfernen"
>
<Trash2 className="w-3 h-3" />
{confirmDeleteIdx === itemIdx ? 'Wirklich löschen?' : ''}
</Button>
)}
</div>
</div>
{/* Enthaltene Produkte & Module */}
<div className="space-y-2 text-xs">
{visibleCategories.map((cat) => {
const sel = item.selections[cat.id]
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
sel.productIds.forEach((pId: string) => {
const p = products.find((prod) => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel?.productId) {
const p = products.find((prod) => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) return null
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1 pl-1">
<span className="text-slate-400 text-[10px] font-bold uppercase tracking-wider block">
{cat.name}:
</span>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const isAbo = item.billingInterval === 'monthly'
const displayPrice = isFree ? 0 : isAbo ? ((prod as any).monthly_price ?? prod.base_price) : prod.base_price
return (
<div key={prod.id} className="flex justify-between items-center text-slate-200 pl-2">
<span className="flex items-center gap-1.5 truncate pr-2">
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5 text-primary shrink-0" />
<span className="truncate">{prod.name}</span>
{isFree && (
<span className="text-[9px] bg-green-500/20 text-green-400 px-1.5 py-0.2 rounded border border-green-500/30 font-bold uppercase shrink-0">
Inklusive
</span>
)}
</span>
<span className="font-bold text-xs text-white tabular-nums shrink-0">
{isFree ? '0,00 €' : fmt(displayPrice)}
</span>
</div>
)
})}
{/* Selected Module Chips */}
{sel?.moduleIds && sel.moduleIds.length > 0 && (
<div className="pl-6 space-y-1 pt-0.5">
{sel.moduleIds.map((mId: string) => {
let modObj: any = null
products.forEach((p) => {
const found = p.modules?.find((m) => m.id === mId)
if (found) modObj = found
})
if (!modObj) return null
const isAbo = item.billingInterval === 'monthly'
const qty = item.moduleQuantities?.[mId] || 1
const unitPrice = isAbo ? (modObj.monthly_price ?? modObj.price) : modObj.price
const totalPrice = unitPrice * qty
return (
<div key={mId} className="flex justify-between text-slate-400 text-[11px]">
<span className="truncate pr-2">
+ {modObj.name} {qty > 1 ? `(${qty}x)` : ''}
</span>
<span className="font-semibold text-slate-300 tabular-nums shrink-0">
{fmt(totalPrice)}
</span>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
))
)}
{/* Service- & Zusatz-Positionen */}
{linkedFeeProducts.length > 0 && (
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 space-y-2">
<span className="text-slate-400 text-[10px] font-bold uppercase tracking-wider block">
Service & Gebühren:
</span>
{linkedFeeProducts.map((fp) => (
<div key={fp.id} className="flex justify-between text-xs text-slate-300">
<span>{fp.name}</span>
<span className="font-bold text-white tabular-nums">{fmt(fp.base_price)}</span>
</div>
))}
</div>
)}
</div>
</div>
{/* ─── RECHTES RASTER (lg:col-span-5) Notizen, Bedingungen & Summen ─── */}
<div className="lg:col-span-5 space-y-5">
{/* Glassmorphism Preiskalkulation */}
<div className="p-5 rounded-2xl bg-slate-950/80 border border-white/10 space-y-3 shadow-xl">
<div className="flex items-center gap-2 pb-2 border-b border-white/10">
<CreditCard className="w-4 h-4 text-primary" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
Gesamte Preiskalkulation
</h3>
</div>
{oneTimeTotal > 0 && (
<div className="space-y-1.5 text-xs">
<div className="flex justify-between text-slate-400">
<span>Einmalig (netto):</span>
<span className="tabular-nums font-mono text-white">{fmt(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span className="tabular-nums font-mono text-white">{fmt(oneTimeTax)}</span>
</div>
{updatePriceModifier?.label && (
<div className="text-[11px] text-green-400 font-semibold bg-green-500/10 p-2 rounded-lg border border-green-500/20 my-1">
{updatePriceModifier.label}
</div>
)}
<div className="flex justify-between text-sm font-bold text-white pt-2 border-t border-white/5">
<span>Einmalig gesamt (brutto):</span>
<span className="tabular-nums font-mono text-primary font-extrabold text-base">{fmt(oneTimeGross)}</span>
</div>
</div>
)}
{monthlyTotal > 0 && (
<div className={`space-y-1.5 text-xs ${oneTimeTotal > 0 ? 'pt-3 border-t border-white/10' : ''}`}>
<div className="flex justify-between text-slate-400">
<span>Monatlich (netto):</span>
<span className="tabular-nums font-mono text-white">{fmt(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span className="tabular-nums font-mono text-white">{fmt(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-sm font-bold text-white pt-2 border-t border-white/5">
<span>Monatlich gesamt (brutto):</span>
<span className="tabular-nums font-mono text-primary font-extrabold text-base">{fmt(monthlyGross)} / mtl.</span>
</div>
</div>
)}
</div>
{/* Bestellnotizen */}
<div className="p-4 rounded-2xl bg-white/5 border border-white/10 space-y-2">
<Label htmlFor="order-notes" className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
<MessageSquare className="w-3.5 h-3.5 text-primary" />
<span>Bestellnotizen / Bemerkungen (optional)</span>
</Label>
<textarea
id="order-notes"
rows={3}
value={orderNotes}
onChange={(e) => setOrderNotes(e.target.value)}
placeholder="Besondere Hinweise, Wunschtermine oder Ansprechpartner..."
className="w-full p-3 rounded-xl bg-slate-950/70 border border-white/10 text-xs text-white placeholder:text-slate-500 focus:border-primary focus:outline-none transition-colors resize-none scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent"
/>
</div>
</div>
</div>
</div>
{/* ─── 3. FIXED BOTTOM ACTION BAR (shrink-0) ─── */}
<div className="shrink-0 border-t border-white/10 bg-slate-950/90 backdrop-blur-md px-6 py-3.5 flex flex-col sm:flex-row items-center justify-between gap-3 z-10">
{/* Back Button */}
<Button
variant="outline"
onClick={prevStep}
className="w-full sm:w-auto border-white/10 text-white hover:bg-white/10 h-9 text-xs gap-1.5"
>
<ChevronLeft className="w-4 h-4" /> Zurück zu Schritt 3
</Button>
{/* Info Chip */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-white text-xs">
<span className="text-slate-400">Positionen:</span>
<strong className="text-white">{finalItemsToShow.length} Kassen</strong>
<span className="text-slate-600">|</span>
<span className="text-primary font-bold">
{oneTimeTotal > 0 ? fmt(oneTimeGross) : `${fmt(monthlyGross)} / mtl.`}
</span>
</div>
{/* Submit Button -> opens modal */}
<Button
onClick={() => setShowConfirmModal(true)}
disabled={isSubmitting || finalItemsToShow.length === 0}
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white font-bold px-6 shadow-lg shadow-primary/20 h-9 text-xs gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{initialOrder ? 'Anfrage wird aktualisiert...' : 'Anfrage wird übertragen...'}
</>
) : (
<>
<Send className="w-3.5 h-3.5" />
{initialOrder ? 'Anfrage aktualisieren' : 'Anfrage verbindlich absenden'}
</>
)}
</Button>
</div>
{/* ─── 4. BESTÄTIGUNGS-POPUP / MODAL VOR DEM ABSENDEN ─── */}
{showConfirmModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-lg bg-slate-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-5 text-white">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-primary/20 text-primary border border-primary/30">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<h3 className="text-base font-bold text-white">Richtigkeit & Freigabe bestätigen</h3>
<p className="text-xs text-slate-400 mt-0.5">Letzter Schritt vor Übermittlung der Lizenzierung</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmModal(false)}
disabled={isSubmitting}
className="h-8 w-8 p-0 text-slate-400 hover:text-white rounded-lg"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3 text-xs text-slate-300">
<div className="p-3.5 rounded-xl bg-white/5 border border-white/10 space-y-2">
<div className="flex justify-between items-center text-white font-semibold">
<span>Endkunde:</span>
<span className="text-primary">{selectedEndCustomer?.company_name || 'Kein Kunde gewählt'}</span>
</div>
<div className="flex justify-between items-center text-slate-400">
<span>Anzahl Geräte:</span>
<span className="text-white font-medium">{finalItemsToShow.length} Kassen</span>
</div>
<div className="flex justify-between items-center text-slate-400">
<span>Gesamtbetrag (brutto):</span>
<span className="text-white font-bold">{oneTimeTotal > 0 ? fmt(oneTimeGross) : `${fmt(monthlyGross)} / mtl.`}</span>
</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-950/80 border border-white/10 space-y-3">
<div className="flex items-start gap-3">
<Checkbox
id="modal-terms-checkbox"
checked={acceptedTerms}
onCheckedChange={(checked) => setAcceptedTerms(checked === true)}
className="rounded border-white/20 data-[state=checked]:bg-primary mt-0.5"
/>
<Label htmlFor="modal-terms-checkbox" className="text-xs text-slate-300 leading-relaxed cursor-pointer select-none">
Ich bestätige die Richtigkeit aller gemachten Angaben und akzeptiere die Lizenzvereinbarungen sowie die{' '}
<a href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-white text-primary">
Datenschutzerklärung
</a>.
</Label>
</div>
<div className="p-2.5 rounded-lg bg-primary/10 border border-primary/20 text-[11px] text-slate-300 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary shrink-0" />
<span>B2B-Freigabe: Nach Absenden wird die Lizenzierungsanfrage geprüft und freigegeben.</span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2 border-t border-white/10">
<Button
variant="outline"
onClick={() => setShowConfirmModal(false)}
disabled={isSubmitting}
className="border-white/10 text-white hover:bg-white/10 text-xs h-9 px-4"
>
Abbrechen & Zurück
</Button>
<Button
onClick={async () => {
await handleSubmit()
setShowConfirmModal(false)
}}
disabled={!acceptedTerms || isSubmitting}
className="bg-primary hover:bg-primary/90 text-white font-bold text-xs h-9 px-5 gap-2 shadow-md shadow-primary/20"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Wird übermittelt...
</>
) : (
<>
<Check className="w-4 h-4" />
Jetzt verbindlich absenden
</>
)}
</Button>
</div>
</div>
</div>
)}
</Card>
)
}

View File

@@ -0,0 +1,264 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
ChevronRight,
ChevronLeft,
AlertCircle,
Check,
ShoppingCart,
Save,
Plus,
} from 'lucide-react'
import { Category, Product, CategorySelection } from '@/lib/types'
interface SummarySidebarProps {
visibleCategories: Category[]
selections: Record<string, CategorySelection>
products: Product[]
moduleQuantities: Record<string, number>
billingLabel: (interval: string) => string
oneTimeTotal: number
monthlyTotal: number
oneTimeNet: number
oneTimeTax: number
oneTimeGross: number
monthlyNet: number
monthlyTax: number
monthlyGross: number
updatePriceModifier: { label?: string }
allCategoriesFilled: boolean
productValidationErrors: string[]
editingIdx: number | null
deviceName: string
setDeviceName: (name: string) => void
licenseNumber: string
setLicenseNumber: (num: string) => void
onSaveAndProceed: () => void
isNextStepDisabled: boolean
hasActiveSelection: boolean
showValidationWarning?: boolean
prevStep: () => void
}
export function SummarySidebar({
visibleCategories,
selections,
products,
moduleQuantities,
billingLabel,
oneTimeTotal,
monthlyTotal,
oneTimeNet,
oneTimeTax,
oneTimeGross,
monthlyNet,
monthlyTax,
monthlyGross,
updatePriceModifier,
allCategoriesFilled,
productValidationErrors,
editingIdx,
deviceName,
setDeviceName,
licenseNumber,
setLicenseNumber,
onSaveAndProceed,
isNextStepDisabled,
prevStep,
}: SummarySidebarProps) {
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
const isEditing = editingIdx !== null
const currentDeviceTitle = isEditing
? `Kasse ${editingIdx + 1}`
: (deviceName.trim() || 'Neue Kasse')
const isSaveDisabled = !allCategoriesFilled || productValidationErrors.length > 0
return (
<Card className="glass-dark border-white/10 shadow-2xl rounded-2xl overflow-hidden flex flex-col h-full bg-slate-900/90 backdrop-blur-xl">
{/* ─── 1. TOP HEADER (shrink-0) ─── */}
<CardHeader className="shrink-0 py-2.5 px-4 border-b border-white/10 bg-slate-950/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-white text-sm font-bold flex items-center gap-2">
<ShoppingCart className="w-4 h-4 text-primary" />
<span>Kassen-Konfiguration</span>
</CardTitle>
<Badge
variant="outline"
className={`text-[10px] px-2 py-0.5 font-semibold ${
isEditing
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-primary/40 bg-primary/10 text-primary'
}`}
>
{isEditing ? `Bearbeite #${editingIdx + 1}` : 'Neuer Eintrag'}
</Badge>
</div>
</CardHeader>
{/* ─── 2. SCROLLABLE MIDDLE (flex-1 min-h-0 overflow-y-auto) ─── */}
<CardContent className="p-4 flex-1 min-h-0 overflow-y-auto space-y-4 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{/* Aktuelle Kassen-Auswahl */}
<div className="space-y-3">
<div className="flex items-center justify-between text-xs font-bold text-slate-300 uppercase tracking-wider">
<span>Gewählte Positionen</span>
</div>
{/* Selected Categories List */}
<div className="space-y-2">
{visibleCategories.map(cat => {
const sel = selections[cat.id]
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
sel.productIds.forEach(pId => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel?.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) return (
<div key={cat.id} className="text-[11px] text-slate-500 italic flex items-center gap-1.5">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive shrink-0" />
) : (
<span className="w-3 h-3 inline-block shrink-0" />
)}
<span>{cat.name}: {cat.is_required ? 'nicht gewählt (Pflicht)' : 'nicht gewählt (optional)'}</span>
</div>
)
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-0.5 text-xs">
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wider">{cat.name}</p>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
return (
<div key={prod.id} className="pl-1.5 space-y-0.5">
<div className="flex justify-between text-white text-xs">
<span className="truncate pr-2">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Inkl.)</span>}</span>
<span className="tabular-nums shrink-0 font-medium">
{fmt(actualPrice)}
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return mod ? (
<div key={mId} className="flex justify-between text-[11px] text-slate-400 pl-2">
<span className="truncate pr-2">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="tabular-nums shrink-0 font-medium text-slate-300">
{fmt(mod.price * qty)}
</span>
</div>
) : null
})}
</div>
)
})}
</div>
)
})}
</div>
{/* Pricing Box */}
<div className="p-3 rounded-xl bg-slate-950/80 border border-white/10 space-y-2 text-xs">
{oneTimeTotal > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-slate-400">
<span>Kaufpreis Kasse (netto):</span>
<span className="tabular-nums font-mono">{fmt(oneTimeNet)}</span>
</div>
<div className="flex justify-between font-bold text-white">
<span>Kaufpreis Kasse (brutto):</span>
<span className="tabular-nums font-mono text-primary">{fmt(oneTimeGross)}</span>
</div>
</div>
)}
{monthlyTotal > 0 && (
<div className={`space-y-1 ${oneTimeTotal > 0 ? 'pt-2 border-t border-white/10' : ''}`}>
<div className="flex justify-between text-slate-400">
<span>Mietpreis Kasse (netto):</span>
<span className="tabular-nums font-mono">{fmt(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between font-bold text-white">
<span>Mietpreis Kasse (brutto):</span>
<span className="tabular-nums font-mono text-primary">{fmt(monthlyGross)} / mtl.</span>
</div>
</div>
)}
</div>
</div>
{/* Gerätename & Lizenznummer Eingabe */}
<div className="pt-2 border-t border-white/10 space-y-2.5">
<p className="text-xs font-bold text-white uppercase tracking-wider">
{isEditing ? `Kasse anpassen` : `Kassenbezeichnung`}
</p>
<div className="space-y-1">
<Label htmlFor="device-name-sidebar" className="text-[11px] text-slate-400">Kassenname (optional)</Label>
<Input
id="device-name-sidebar"
placeholder="z. B. Kasse 1, Hauptkasse, Theke"
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg"
/>
</div>
<div className="space-y-1">
<Label htmlFor="license-number-sidebar" className="text-[11px] text-slate-400">Lizenznummer (optional)</Label>
<Input
id="license-number-sidebar"
placeholder="z. B. LIZ-12345"
value={licenseNumber}
onChange={e => setLicenseNumber(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg font-mono uppercase"
/>
</div>
</div>
</CardContent>
{/* ─── 3. FIXED BOTTOM ACTIONS (shrink-0) ─── */}
<CardFooter className="flex flex-col gap-2 shrink-0 p-4 border-t border-white/10 bg-slate-950/90">
{/* Primary Action Button: Save & Proceed immediately to Step 4 */}
<Button
className="w-full h-11 text-xs font-bold bg-primary hover:bg-primary/90 text-white shadow-lg shadow-primary/20 gap-2"
onClick={onSaveAndProceed}
disabled={isSaveDisabled}
>
{isEditing ? (
<>
<Save className="w-4 h-4" /> Änderungen speichern & zur Übersicht <ChevronRight className="w-4 h-4 ml-0.5" />
</>
) : (
<>
<Plus className="w-4 h-4" /> Kasse zum Auftrag hinzufügen <ChevronRight className="w-4 h-4 ml-0.5" />
</>
)}
</Button>
{/* Back Button */}
<Button
variant="ghost"
className="w-full text-slate-400 hover:text-white h-7 text-xs"
onClick={prevStep}
>
<ChevronLeft className="w-3 h-3 mr-1" /> Zurück zum Abrechnungsmodell
</Button>
</CardFooter>
</Card>
)
}

View File

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

View File

@@ -5,8 +5,9 @@ import { createAdminClient } from '@/lib/supabase/admin'
import { headers } from 'next/headers'
import { sendLockoutEmail } from '@/lib/utils/email'
import { sendMail } from '@/utils/mail'
import crypto from 'crypto'
export async function signIn(email: string, password: string) {
export async function signIn(email: string, password: string, deviceHash?: string) {
try {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
@@ -14,38 +15,13 @@ export async function signIn(email: string, password: string) {
password,
})
if (error) {
// Track failed attempts
const { data: usr, error: usrError } = await supabase
.from('users')
.select('failed_attempts, email')
.eq('email', email)
.single()
const attempts = (usr?.failed_attempts ?? 0) + 1
// Update attempts count
await supabase
.from('users')
.update({ failed_attempts: attempts })
.eq('email', email)
// Lock account on 5th failure
if (attempts >= 5) {
await supabase
.from('users')
.update({ role: 'gesperrt' })
.eq('email', email)
// Notify admin
await sendLockoutEmail('info@hephex.de')
return { success: false, error: 'Konto gesperrt nach 5 Fehlversuchen.' }
}
return { success: false, error: error.message }
}
const userId = data.user?.id
if (userId) {
let { data: userData, error: userError } = await supabase
const adminClient = createAdminClient()
let { data: userData, error: userError } = await adminClient
.from('users')
.select('role')
.eq('id', userId)
@@ -75,7 +51,7 @@ export async function signIn(email: string, password: string) {
}
}
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin')) {
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin' && userData.role !== 'verwaltung')) {
await supabase.auth.signOut()
if (userData?.role === 'gesperrt') {
return { success: false, error: "Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator." }
@@ -83,6 +59,23 @@ export async function signIn(email: string, password: string) {
return { success: false, error: "Zugriff verweigert. Nur registrierte Partner dürfen sich anmelden." }
}
// --- 2FA: Geräteprüfung ---
if (deviceHash) {
const { data: knownDevice } = await adminClient
.from('known_devices')
.select('id')
.eq('user_id', userId)
.eq('device_hash', deviceHash)
.maybeSingle()
if (!knownDevice) {
// Gerät unbekannt -> 2FA erforderlich
// Session bleibt aktiv, aber Client muss 2FA bestätigen
await send2FACodeInternal(userId, deviceHash, email)
return { success: true, requires2FA: true, userId, role: userData.role }
}
}
// Andere aktive Sitzungen beenden
await supabase.auth.signOut({ scope: 'others' })
@@ -145,6 +138,179 @@ function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?:
`;
}
function get2FAEmailHtml(code: string) {
const digits = code.split('')
const digitBoxes = digits.map(d =>
`<td style="width: 48px; height: 56px; text-align: center; vertical-align: middle; font-size: 28px; font-weight: 800; color: #1e293b; background-color: #f1f5f9; border: 2px solid #e2e8f0; border-radius: 12px; font-family: 'Courier New', monospace; letter-spacing: 0;">${d}</td>`
).join('<td style="width: 8px;"></td>')
const messageHtml = `
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-top: 0;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6;">
Wir haben eine Anmeldung von einem neuen Gerät erkannt. Bitte bestätigen Sie Ihre Identität mit folgendem Sicherheitscode:
</p>
<div style="text-align: center; margin: 32px 0;">
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
<tr>${digitBoxes}</tr>
</table>
</div>
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-radius: 12px; padding: 16px 20px; margin: 24px 0; border-left: 4px solid #f59e0b;">
<p style="color: #92400e; font-size: 14px; margin: 0; line-height: 1.5;">
<strong>Dieser Code ist 15 Minuten gültig.</strong><br>
Falls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.
</p>
</div>
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 24px;">
Dieser Code dient der Sicherheit Ihres Kontos. Geben Sie ihn niemals an andere Personen weiter.
</p>
`
return getBeautifulEmailHtml('Sicherheitscode', messageHtml)
}
/** Internal: generates code + sends email (no auth check needed) */
async function send2FACodeInternal(userId: string, deviceHash: string, email: string) {
const adminClient = createAdminClient()
const code = Math.floor(100000 + Math.random() * 900000).toString()
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString()
// Alte Codes für dieses Gerät löschen
await adminClient
.from('device_verification_codes')
.delete()
.eq('user_id', userId)
.eq('device_hash', deviceHash)
// Neuen Code speichern
const { data: insData, error: insError } = await adminClient
.from('device_verification_codes')
.insert({ user_id: userId, code, device_hash: deviceHash, expires_at: expiresAt })
.select()
if (insError) {
console.error("2FA CODE INSERT ERROR:", insError)
} else {
console.log("2FA CODE INSERTED:", code, insData)
}
// Mail senden
await sendMail({
to: email,
subject: 'Ihr Sicherheitscode CASPOS Shop',
text: `Ihr Sicherheitscode lautet: ${code}\n\nDieser Code ist 15 Minuten gültig.\n\nFalls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.`,
html: get2FAEmailHtml(code),
})
}
/** Public server action: resend 2FA code */
export async function resend2FACode(userId: string, deviceHash: string) {
try {
const adminClient = createAdminClient()
const { data: authUser, error: authError } = await adminClient.auth.admin.getUserById(userId)
const email = authUser?.user?.email
if (authError || !email) return { success: false, error: 'Benutzer-E-Mail nicht gefunden.' }
await send2FACodeInternal(userId, deviceHash, email)
return { success: true }
} catch (err: any) {
console.error('Resend 2FA error:', err)
return { success: false, error: 'Fehler beim erneuten Senden des Codes.' }
}
}
/** Verify 2FA code and register device */
export async function verifyDevice2FA(userId: string, code: string, deviceHash: string) {
try {
const adminClient = createAdminClient()
const cleanCode = code.trim()
// Code prüfen (unter Berücksichtigung von Leerzeichen & device_hash)
console.log("VERIFYING 2FA: userId =", userId, "code =", cleanCode)
const { data: codeEntries, error: codeError } = await adminClient
.from('device_verification_codes')
.select('*')
.eq('user_id', userId)
.eq('code', cleanCode)
.order('created_at', { ascending: false })
.limit(1)
const codeEntry = codeEntries && codeEntries.length > 0 ? codeEntries[0] : null
console.log("VERIFY 2FA DB RESULT: entry =", codeEntry, "error =", codeError)
if (codeError || !codeEntry) {
// Doppelklick- & Race-Condition Schutz: Prüfen ob das Gerät eben bereits verifiziert wurde
const { data: knownDev } = await adminClient
.from('known_devices')
.select('*')
.eq('user_id', userId)
.limit(1)
if (knownDev && knownDev.length > 0) {
const { data: userData } = await adminClient
.from('users')
.select('role')
.eq('id', userId)
.single()
return { success: true, role: userData?.role || 'partner' }
}
return { success: false, error: 'Ungültiger Sicherheitscode.' }
}
// Ablauf prüfen
if (new Date(codeEntry.expires_at) < new Date()) {
// Code abgelaufen -> löschen
await adminClient
.from('device_verification_codes')
.delete()
.eq('id', codeEntry.id)
return { success: false, error: 'Der Code ist abgelaufen. Bitte fordern Sie einen neuen Code an.' }
}
// Gerät registrieren
const headersList = await headers()
const userAgent = headersList.get('user-agent') || ''
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || headersList.get('x-real-ip') || ''
const targetDeviceHash = deviceHash || codeEntry.device_hash || 'default-device'
await adminClient
.from('known_devices')
.upsert({
user_id: userId,
device_hash: targetDeviceHash,
user_agent: userAgent,
ip_address: ip,
verified_at: new Date().toISOString(),
}, { onConflict: 'user_id,device_hash' })
// Verbrauchte Codes für diesen User löschen
await adminClient
.from('device_verification_codes')
.delete()
.eq('user_id', userId)
// Andere Sessions beenden
const supabase = await createClient()
await supabase.auth.signOut({ scope: 'others' })
// Rolle zurückgeben
const { data: userData } = await adminClient
.from('users')
.select('role')
.eq('id', userId)
.single()
return { success: true, role: userData?.role || 'partner' }
} catch (err: any) {
console.error('Verify 2FA error:', err)
return { success: false, error: 'Fehler bei der Code-Überprüfung.' }
}
}
export async function resetPassword(email: string) {
try {
const admin = createAdminClient()

View File

@@ -0,0 +1,122 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
import type { BrandingSettings } from '@/lib/constants/branding'
export async function getBrandingSettings(): Promise<BrandingSettings> {
try {
const admin = createAdminClient()
const { data } = await admin
.from('settings')
.select('*')
.eq('id', 'branding')
.maybeSingle()
if (!data) {
return {
companyName: '',
logoUrl: '',
developerFooter: 'CASPOS Shop',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
}
}
return {
companyName: data.company_name || '',
logoUrl: data.logo_url || '',
developerFooter: data.developer_footer || 'B2B Shop made by hephex',
street: data.street || '',
zip: data.zip || '',
city: data.city || '',
billingStreet: data.billing_street || '',
billingZip: data.billing_zip || '',
billingCity: data.billing_city || '',
sameBillingAddress: data.same_billing_address !== false,
colorScheme: data.color_scheme || 'modern_blue',
primaryColor: data.primary_color || '#2563eb',
accentColor: data.accent_color || '#38bdf8',
}
} catch (err) {
console.error('Failed to load branding settings:', err)
return {
companyName: '',
logoUrl: '',
developerFooter: 'B2B Shop made by hephex',
street: '',
zip: '',
city: '',
billingStreet: '',
billingZip: '',
billingCity: '',
sameBillingAddress: true,
colorScheme: 'modern_blue',
primaryColor: '#2563eb',
accentColor: '#38bdf8',
}
}
}
export async function saveBrandingSettings(
settings: BrandingSettings
): Promise<{ success: boolean; error?: string }> {
try {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (!userData || userData.role !== 'admin') {
return { success: false, error: 'Keine Berechtigung (nur Admins)' }
}
}
const admin = createAdminClient()
const { error } = await admin
.from('settings')
.upsert({
id: 'branding',
company_name: settings.companyName,
logo_url: settings.logoUrl,
developer_footer: settings.developerFooter,
street: settings.street,
zip: settings.zip,
city: settings.city,
billing_street: settings.sameBillingAddress ? settings.street : settings.billingStreet,
billing_zip: settings.sameBillingAddress ? settings.zip : settings.billingZip,
billing_city: settings.sameBillingAddress ? settings.city : settings.billingCity,
same_billing_address: settings.sameBillingAddress,
color_scheme: settings.colorScheme,
primary_color: settings.primaryColor,
accent_color: settings.accentColor,
updated_at: new Date().toISOString(),
})
if (error) {
console.error('Failed to save branding settings:', error)
return { success: false, error: error.message }
}
revalidatePath('/')
revalidatePath('/admin/einstellungen')
return { success: true }
} catch (err: any) {
console.error('Exception in saveBrandingSettings:', err)
return { success: false, error: err.message || 'Unerwarteter Fehler' }
}
}

View File

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

View File

@@ -1,82 +1,489 @@
interface EmailDetails {
export interface OrderEmailProps {
orderNumber: string
status: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected'
formattedDate: string
customerCompanyName: string
totalDetailsText: string
totalDetailsHtml: string
billingModel?: string // e.g. "Kauf" | "Miete (monatlich)" | "Kauf / Miete"
rejectionReason?: string
siteUrl?: string
items?: any[]
partnerCompanyName?: string
partnerUserName?: string
partnerUserEmail?: string
taxRate?: number
oneTimeNet?: number
monthlyNet?: number
}
export function generateOrderEmailSubject(orderNumber: string, status: string): string {
const formattedOrderNumber = (orderNumber || '').replace(/^BE-/, 'AE-')
if (status === 'approved' || status === 'active' || status === 'completed') {
return `Auftragsbestätigung: Anfrage #${formattedOrderNumber} freigegeben`
}
if (status === 'rejected' || status === 'cancelled') {
return `Status-Update: Anfrage #${formattedOrderNumber} abgelehnt`
}
return `Eingangsbestätigung: Anfrage #${formattedOrderNumber} eingegangen`
}
export function generateOrderEmailHtml(props: OrderEmailProps): { text: string; html: string } {
const formattedOrderNumber = (props.orderNumber || '').replace(/^BE-/, 'AE-')
const siteUrl = props.siteUrl || process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
const taxRate = props.taxRate ?? 19
const orderPortalUrl = `${siteUrl}/my-orders`
// Status Callout configuration
let calloutBg = '#f8fafc'
let calloutBorder = '#cbd5e1'
let calloutTitle = 'Status: Anfrage eingegangen und in Prüfung'
let calloutText = 'Ihre Anfrage ist erfolgreich in unserem System eingegangen und wird derzeit vom Support geprüft.'
let statusBadgeColor = '#2563eb'
if (props.status === 'approved' || props.status === 'active' || props.status === 'completed') {
calloutBg = '#f0fdf4'
calloutBorder = '#86efac'
calloutTitle = 'Status: Freigegeben / Aktiv'
calloutText = 'Ihre Auftragsbestätigung / Rechnung finden Sie als PDF im Anhang dieser E-Mail.'
statusBadgeColor = '#16a34a'
} else if (props.status === 'rejected' || props.status === 'cancelled') {
calloutBg = '#fef2f2'
calloutBorder = '#fca5a5'
calloutTitle = 'Status: Anfrage abgelehnt'
calloutText = props.rejectionReason
? `Begründung: "${props.rejectionReason}"`
: 'Ihre Anfrage wurde vom Support geprüft und konnte leider nicht freigegeben werden.'
statusBadgeColor = '#dc2626'
}
// Calculate prices if items provided
let oneTimeNet = props.oneTimeNet ?? 0
let monthlyNet = props.monthlyNet ?? 0
const items = props.items || []
if (items.length > 0 && props.oneTimeNet === undefined && props.monthlyNet === undefined) {
items.forEach((item: any) => {
if (item.billing_interval === 'monthly') {
monthlyNet += item.base_price || 0
} else {
oneTimeNet += item.base_price || 0
}
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
monthlyNet += price
})
})
}
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
const formatEuro = (val: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
// Determine Billing Model Label
let billingModelLabel = props.billingModel
if (!billingModelLabel) {
if (oneTimeNet > 0 && monthlyNet > 0) {
billingModelLabel = 'Kauf & Miete'
} else if (monthlyNet > 0) {
billingModelLabel = 'Miete (monatlich)'
} else {
billingModelLabel = 'Kauf (einmalig)'
}
}
// Group items by Device
const groupedItems: { [key: string]: any[] } = {}
items.forEach((item: any) => {
const devName = item.device_name || 'Kasse 1'
if (!groupedItems[devName]) {
groupedItems[devName] = []
}
groupedItems[devName].push(item)
})
// Build Text Breakdown
let itemsBreakdownText = ''
if (items.length > 0) {
itemsBreakdownText += '\nKassen-Aufstellung:\n'
Object.entries(groupedItems).forEach(([devName, devItems]) => {
itemsBreakdownText += `\n[ ${devName} ]\n`
devItems.forEach((item: any) => {
itemsBreakdownText += ` - ${item.product_name} (${item.category_name || 'Basis'}): ${formatEuro(item.base_price || 0)} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}\n`
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
itemsBreakdownText += ` + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${formatEuro(price)} mtl.\n`
})
})
})
}
let totalsText = ''
if (oneTimeNet > 0) {
totalsText += `\nEinmalige Beträge:\n- Netto: ${formatEuro(oneTimeNet)}\n- zzgl. ${taxRate}% MwSt: ${formatEuro(oneTimeTax)}\n- Brutto Gesamt: ${formatEuro(oneTimeGross)}\n`
}
if (monthlyNet > 0) {
totalsText += `\nMonatliche Beträge:\n- Netto: ${formatEuro(monthlyNet)} / mtl.\n- zzgl. ${taxRate}% MwSt: ${formatEuro(monthlyTax)} / mtl.\n- Brutto Gesamt: ${formatEuro(monthlyGross)} / mtl.\n`
}
let partnerPlainText = ''
if (props.partnerCompanyName || props.partnerUserName || props.partnerUserEmail) {
partnerPlainText = `\nPartner / Betreuer:\n- Firma: ${props.partnerCompanyName || '-'}\n- Ansprechpartner: ${props.partnerUserName || '-'}\n- E-Mail: ${props.partnerUserEmail || '-'}\n`
}
const text = `CASPOS B2B Portal\nAnfrage #${formattedOrderNumber}\n\n${calloutTitle}\n${calloutText}\n\nAuftragsdetails:\n- Endkunde: ${props.customerCompanyName}\n- Datum: ${props.formattedDate}\n- Abrechnung: ${billingModelLabel}\n${partnerPlainText}${itemsBreakdownText}${totalsText}\n\nAnfrage im Portal ansehen: ${orderPortalUrl}\n\nCASPOS Computerabrechnungssysteme GmbH\nAlte Bundesstraße 16 · 76846 Hauenstein\nAmtsgericht Zweibrücken HRB 12345\nAutomatische Systembenachrichtigung.`
// Build HTML Items Rows
let itemsHtmlRows = ''
if (items.length > 0) {
Object.entries(groupedItems).forEach(([devName, devItems]) => {
itemsHtmlRows += `
<tr>
<td colspan="2" style="padding: 10px 14px; background-color: #f1f5f9; font-weight: 600; font-size: 13px; color: #1e293b; border-bottom: 1px solid #e2e8f0;">
${devName === 'Zusatzleistung' ? 'Backoffice / Zusatzleistung' : `Kassengerät: ${devName}`}
</td>
</tr>
`
devItems.forEach((item: any) => {
itemsHtmlRows += `
<tr>
<td style="padding: 8px 14px; font-size: 13px; color: #334155; font-weight: 500; border-bottom: 1px solid #f1f5f9;">
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name || 'Basis'})</span>
</td>
<td style="padding: 8px 14px; text-align: right; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">
${formatEuro(item.base_price || 0)} <span style="font-size: 11px; color: #64748b; font-weight: normal;">${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}</span>
</td>
</tr>
`
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
itemsHtmlRows += `
<tr>
<td style="padding: 4px 14px 4px 28px; color: #64748b; font-size: 12px; border-bottom: 1px solid #f8fafc;">
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
</td>
<td style="padding: 4px 14px; text-align: right; color: #64748b; font-size: 12px; border-bottom: 1px solid #f8fafc;">
+${formatEuro(price)} <span style="font-size: 10px;">mtl.</span>
</td>
</tr>
`
})
})
})
}
// Build HTML Partner Section
let partnerHtml = ''
if (props.partnerCompanyName || props.partnerUserName || props.partnerUserEmail) {
partnerHtml = `
<tr>
<td colspan="2" style="padding: 12px 0 4px 0; border-top: 1px solid #e2e8f0; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b;">Partner & Betreuung:</td>
</tr>
${props.partnerCompanyName ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">Firma:</td><td style="padding: 3px 0; font-size: 13px; font-weight: 500; color: #0f172a; text-align: right;">${props.partnerCompanyName}</td></tr>` : ''}
${props.partnerUserName ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">Ansprechpartner:</td><td style="padding: 3px 0; font-size: 13px; color: #0f172a; text-align: right;">${props.partnerUserName}</td></tr>` : ''}
${props.partnerUserEmail ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">E-Mail:</td><td style="padding: 3px 0; font-size: 13px; color: #0f172a; text-align: right;">${props.partnerUserEmail}</td></tr>` : ''}
`
}
// Totals Section HTML
let totalsHtml = ''
if (oneTimeNet > 0 || monthlyNet > 0) {
totalsHtml = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 16px; border-top: 2px solid #e2e8f0; padding-top: 12px; font-size: 13px; color: #334155;">
${oneTimeNet > 0 ? `
<tr>
<td colspan="2" style="padding: 4px 0; font-weight: 600; font-size: 11px; text-transform: uppercase; color: #64748b; letter-spacing: 0.05em;">Einmalige Beträge:</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">Netto-Zwischensumme:</td>
<td style="padding: 2px 0; text-align: right; font-weight: 500; color: #0f172a;">${formatEuro(oneTimeNet)}</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 2px 0; text-align: right; color: #64748b;">${formatEuro(oneTimeTax)}</td>
</tr>
<tr>
<td style="padding: 4px 0 10px 0; font-weight: 600; color: #0f172a;">Gesamt einmalig (brutto):</td>
<td style="padding: 4px 0 10px 0; text-align: right; font-weight: 700; color: #0f172a; font-size: 14px;">${formatEuro(oneTimeGross)}</td>
</tr>
` : ''}
${monthlyNet > 0 ? `
<tr>
<td colspan="2" style="padding: ${oneTimeNet > 0 ? '10px' : '4px'} 0 4px 0; font-weight: 600; font-size: 11px; text-transform: uppercase; color: #64748b; letter-spacing: 0.05em; ${oneTimeNet > 0 ? 'border-top: 1px dashed #e2e8f0;' : ''}">Monatlich wiederkehrend:</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">Netto-Zwischensumme:</td>
<td style="padding: 2px 0; text-align: right; font-weight: 500; color: #0f172a;">${formatEuro(monthlyNet)} / mtl.</td>
</tr>
<tr>
<td style="padding: 2px 0; color: #64748b;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 2px 0; text-align: right; color: #64748b;">${formatEuro(monthlyTax)} / mtl.</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: 600; color: #0f172a;">Gesamt monatlich (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: 700; color: #0f172a; font-size: 14px;">${formatEuro(monthlyGross)} / mtl.</td>
</tr>
` : ''}
</table>
`
}
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CASPOS B2B Portal - #${formattedOrderNumber}</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #0f172a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc; padding: 24px 12px;">
<tr>
<td align="center">
<!-- Main Card (max-width 600px) -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);">
<!-- Header Banner -->
<tr>
<td style="background-color: #0f172a; padding: 24px; text-align: left;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td>
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.02em; text-transform: uppercase;">CASPOS B2B Portal</div>
<div style="font-size: 12px; color: #94a3b8; margin-top: 2px;">Die Kasse · Fachhandelsportal</div>
</td>
<td align="right" style="vertical-align: middle;">
<span style="font-family: monospace, Courier, monospace; font-size: 14px; font-weight: 600; color: #38bdf8; background-color: #1e293b; padding: 6px 10px; border-radius: 4px; border: 1px solid #334155;">
#${formattedOrderNumber}
</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Body Content -->
<tr>
<td style="padding: 24px;">
<!-- Status Callout -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: ${calloutBg}; border-left: 4px solid ${statusBadgeColor}; border-top: 1px solid ${calloutBorder}; border-right: 1px solid ${calloutBorder}; border-bottom: 1px solid ${calloutBorder}; border-radius: 4px; margin-bottom: 24px;">
<tr>
<td style="padding: 14px 16px;">
<div style="font-weight: 700; font-size: 14px; color: #0f172a; margin-bottom: 4px;">${calloutTitle}</div>
<div style="font-size: 13px; color: #334155; line-height: 1.5;">${calloutText}</div>
</td>
</tr>
</table>
<!-- Order & Customer Meta Table -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin-bottom: 24px;">
<tr>
<td>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="font-size: 13px;">
<tr>
<td style="padding: 4px 0; color: #64748b; width: 140px;">Endkunde:</td>
<td style="padding: 4px 0; font-weight: 600; color: #0f172a; text-align: right;">${props.customerCompanyName}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #64748b;">Bestelldatum:</td>
<td style="padding: 4px 0; font-weight: 500; color: #0f172a; text-align: right;">${props.formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #64748b;">Abrechnungsmodell:</td>
<td style="padding: 4px 0; font-weight: 500; color: #0f172a; text-align: right;">${billingModelLabel}</td>
</tr>
${partnerHtml}
</table>
</td>
</tr>
</table>
<!-- Hardware / Modules Breakdown -->
${items.length > 0 ? `
<div style="font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #475569; margin-bottom: 8px;">Kassen-Aufstellung</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; margin-bottom: 20px;">
${itemsHtmlRows}
</table>
` : ''}
<!-- Totals -->
${totalsHtml}
<!-- Primary Action Button -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 32px; margin-bottom: 8px;">
<tr>
<td align="center">
<a href="${orderPortalUrl}" target="_blank" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 28px; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">
[ Anfrage im Portal ansehen ]
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; border-top: 1px solid #e2e8f0; padding: 20px 24px; text-align: center; font-size: 12px; color: #64748b; line-height: 1.5;">
<p style="margin: 0; font-weight: 600; color: #475569;">CASPOS Computerabrechnungssysteme GmbH</p>
<p style="margin: 2px 0 0 0;">Alte Bundesstraße 16 · 76846 Hauenstein · Amtsgericht Zweibrücken HRB 12345</p>
<p style="margin: 8px 0 0 0; font-size: 11px; color: #94a3b8;">Dies ist eine automatische Transaktions-E-Mail des CASPOS B2B Portals.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
return { text, html }
}
// Backwards-compatible Wrappers
export function getOrderEmailTemplate(
details: EmailDetails,
details: {
orderNumber: string
formattedDate: string
customerCompanyName: string
totalDetailsText?: string
totalDetailsHtml?: string
itemsDetailsText?: string
itemsDetailsHtml?: string
partnerCompanyName?: string
partnerUserName?: string
partnerUserEmail?: string
items?: any[]
taxRate?: number
oneTimeNet?: number
monthlyNet?: number
billingModel?: string
},
siteUrl: string,
isUpdate: boolean = false
isUpdate: boolean = false,
_primaryColor?: string
) {
const title = isUpdate ? 'Anfrage geändert' : 'Anfrage erhalten'
const intro = isUpdate
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.`
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.`
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${details.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team`
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">${title}</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${intro}</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Details</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
<td style="padding: 4px 0;">${details.orderNumber}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
<td style="padding: 4px 0;">${details.formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td>
<td style="padding: 4px 0;">${details.customerCompanyName}</td>
</tr>
${details.totalDetailsHtml}
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
</div>
`
return { text, html }
return generateOrderEmailHtml({
orderNumber: details.orderNumber,
status: isUpdate ? 'in_review' : 'pending',
formattedDate: details.formattedDate,
customerCompanyName: details.customerCompanyName,
billingModel: details.billingModel,
siteUrl,
items: details.items,
partnerCompanyName: details.partnerCompanyName,
partnerUserName: details.partnerUserName,
partnerUserEmail: details.partnerUserEmail,
taxRate: details.taxRate,
oneTimeNet: details.oneTimeNet,
monthlyNet: details.monthlyNet,
})
}
export function getStatusEmailTemplate(orderNumber: string, oldLabel: string, newLabel: string) {
const text = `Hallo,\n\nder Status deiner Anfrage ${orderNumber} hat sich geändert.\n\nStatus: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nDein CASPOS Team`
export function getStatusEmailTemplate(
orderNumber: string,
_oldLabel: string,
_newLabel: string,
statusKey?: string,
rejectionReason?: string,
extraDetails?: {
customerCompanyName?: string
formattedDate?: string
billingModel?: string
items?: any[]
partnerCompanyName?: string
partnerUserName?: string
partnerUserEmail?: string
taxRate?: number
oneTimeNet?: number
monthlyNet?: number
}
) {
const status = (statusKey || 'pending') as any
return generateOrderEmailHtml({
orderNumber,
status,
formattedDate: extraDetails?.formattedDate || new Date().toLocaleDateString('de-DE'),
customerCompanyName: extraDetails?.customerCompanyName || 'Endkunde',
billingModel: extraDetails?.billingModel,
rejectionReason,
items: extraDetails?.items,
partnerCompanyName: extraDetails?.partnerCompanyName,
partnerUserName: extraDetails?.partnerUserName,
partnerUserEmail: extraDetails?.partnerUserEmail,
taxRate: extraDetails?.taxRate,
oneTimeNet: extraDetails?.oneTimeNet,
monthlyNet: extraDetails?.monthlyNet,
})
}
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">Statusänderung</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status deiner Anfrage <strong>${orderNumber}</strong> wurde aktualisiert.</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
<td style="padding: 4px 0;">${orderNumber}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Vorher:</td>
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Aktuell:</td>
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
</tr>
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Dein CASPOS Team</p>
</div>
export function buildEmailItemsSection(items: any[]) {
const groupedItems: { [key: string]: any[] } = {}
items.forEach((item: any) => {
const devName = item.device_name || 'Kasse 1'
if (!groupedItems[devName]) {
groupedItems[devName] = []
}
groupedItems[devName].push(item)
})
let itemsDetailsText = '\nKassen-Konfigurationen:'
let itemsDetailsHtml = `
<tr>
<td colspan="2" style="padding: 12px 0 6px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Kassen-Konfigurationen:</td>
</tr>
`
return { text, html }
Object.entries(groupedItems).forEach(([deviceName, devItems]) => {
itemsDetailsText += `\n- Kasse: ${deviceName}`
itemsDetailsHtml += `
<tr style="background-color: #f1f5f9;">
<td colspan="2" style="padding: 6px 8px; font-weight: bold; color: #1e3a8a; font-size: 13px;">Kasse: ${deviceName}</td>
</tr>
`
devItems.forEach((item: any) => {
itemsDetailsText += `\n * ${item.product_name} (${item.category_name}): ${Number(item.base_price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}`
itemsDetailsHtml += `
<tr>
<td style="padding: 6px 8px; font-size: 13px; color: #334155; font-weight: 500;">
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name})</span>
</td>
<td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;">
${Number(item.base_price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</td>
</tr>
`
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
itemsDetailsText += `\n + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${Number(price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.`
itemsDetailsHtml += `
<tr>
<td style="padding: 4px 8px 4px 20px; color: #64748b; font-size: 12px;">
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
</td>
<td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;">
+${Number(price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
</td>
</tr>
`
})
})
})
return {
text: itemsDetailsText + '\n',
html: itemsDetailsHtml
}
}

View File

@@ -86,7 +86,7 @@ export async function createEndCustomer(formData: EndCustomerFormData, companyId
.eq('id', user.id)
.single()
const isAdmin = dbUser?.role === 'admin'
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
const resolvedCompanyId = dbUser?.company_id || companyId
if (!isAdmin && !resolvedCompanyId) {

View File

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

View File

@@ -12,10 +12,42 @@ import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transfo
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
import { getProducts, getCategories } from '@/lib/actions/products'
import { validateWizardSelections } from '@/lib/actions/validation'
import { getOrderEmailTemplate, getStatusEmailTemplate } from '@/lib/actions/email-templates'
import {
generateOrderEmailHtml,
generateOrderEmailSubject,
getOrderEmailTemplate,
getStatusEmailTemplate,
buildEmailItemsSection,
} from '@/lib/actions/email-templates'
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
async function fetchOrderPartnerInfo(adminClient: any, companyId: string | null, userId: string | null) {
let partnerCompanyName = ''
let partnerUserName = ''
let partnerUserEmail = ''
if (companyId) {
const { data: comp } = await adminClient.from('companies').select('name').eq('id', companyId).single()
if (comp) partnerCompanyName = comp.name
}
if (userId) {
const { data: dbUser } = await adminClient.from('users').select('first_name, last_name, email, role').eq('id', userId).single()
if (dbUser) {
partnerUserName = [dbUser.first_name, dbUser.last_name].filter(Boolean).join(' ')
partnerUserEmail = dbUser.email || ''
if (!partnerCompanyName && (dbUser.role === 'admin' || dbUser.role === 'verwaltung')) {
partnerCompanyName = 'Administrator / Direktbestellung'
}
}
}
return { partnerCompanyName, partnerUserName, partnerUserEmail }
}
/**
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
@@ -66,15 +98,31 @@ export async function submitOrder(params: {
// Check if user is assigned to a company or is admin
const { data: dbUser } = await supabase
.from('users')
.select('role, company_id')
.select('role, company_id, first_name, last_name, email')
.eq('id', user.id)
.single()
const isAdminUser = dbUser?.role === 'admin'
const isAdminUser = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
if (!isAdminUser && !dbUser?.company_id) {
throw new Error('Sie müssen einer Firma zugewiesen sein, um eine Anfrage zu erstellen.')
}
let partnerCompanyName = ''
if (dbUser?.company_id) {
const { data: comp } = await supabase
.from('companies')
.select('name')
.eq('id', dbUser.company_id)
.single()
if (comp) {
partnerCompanyName = comp.name
}
} else if (isAdminUser) {
partnerCompanyName = 'Administrator / Direktbestellung'
}
const partnerUserName = dbUser ? [dbUser.first_name, dbUser.last_name].filter(Boolean).join(' ') : ''
const partnerUserEmail = dbUser?.email || user.email
// Fetch catalog directly from DB to prevent client tampering
const dbProducts = await getProducts()
const dbCategories = await getCategories()
@@ -146,6 +194,9 @@ export async function submitOrder(params: {
order,
customer: customerSnapshot,
orderSnapshot,
partnerCompanyName,
partnerUserName,
partnerUserEmail,
})
)
@@ -256,17 +307,25 @@ export async function submitOrder(params: {
`
}
const emailTemplate = getOrderEmailTemplate({
const emailTemplate = generateOrderEmailHtml({
orderNumber,
status: 'pending',
formattedDate,
customerCompanyName: customerSnapshot.company_name,
totalDetailsText,
totalDetailsHtml
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false)
items,
partnerCompanyName,
partnerUserName,
partnerUserEmail,
taxRate,
oneTimeNet,
monthlyNet,
})
const mailSubject = generateOrderEmailSubject(orderNumber, 'pending')
await sendMail({
to: user.email,
subject: `Anfragebestätigung ${orderNumber}`,
subject: mailSubject,
text: emailTemplate.text,
html: emailTemplate.html,
attachments: [
@@ -277,6 +336,37 @@ export async function submitOrder(params: {
}
]
})
// Notify all admins
try {
const adminClient = createAdminClient()
const { data: admins } = await adminClient
.from('users')
.select('email')
.in('role', ['admin', 'verwaltung'])
if (admins) {
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
for (const adminEmail of adminEmails) {
if (adminEmail !== user.email) {
await sendMail({
to: adminEmail,
subject: `[Admin-Kopie] ${mailSubject}`,
text: emailTemplate.text,
html: emailTemplate.html,
attachments: [
{
filename: `Anfragebestaetigung_${orderNumber}.pdf`,
content: buffer,
contentType: 'application/pdf',
}
]
})
}
}
}
} catch (adminMailError) {
console.error('Failed to notify admins of new order:', adminMailError)
}
} catch (mailError) {
console.error('Failed to send order confirmation mail:', mailError)
}
@@ -292,14 +382,15 @@ export async function submitOrder(params: {
export async function updateOrderStatus(
orderId: string,
newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
newStatus: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected'
) {
const admin = createAdminClient()
const supabase = await createClient()
// 1. Fetch current order
// 1. Fetch current order with full details (snapshots)
const { data: order, error: fetchError } = await admin
.from('orders')
.select('id, user_id, order_number, status')
.select('*')
.eq('id', orderId)
.single()
@@ -310,7 +401,7 @@ export async function updateOrderStatus(
const oldStatus = order.status
if (oldStatus === newStatus) {
return order
return order as Order
}
// 2. Update order status
@@ -325,31 +416,112 @@ export async function updateOrderStatus(
throw new Error(`Fehler beim Aktualisieren des Status: ${updateError?.message || 'Unbekannt'}`)
}
// 3. Send email to the retailer (händler)
const partnerInfo = await fetchOrderPartnerInfo(admin, updatedOrder.company_id, updatedOrder.user_id)
// 3. Wenn Status auf 'active' oder 'approved' wechselt, generiere PDF und lade es hoch (pdf-invoice-generator)
let attachmentBuffer: Buffer | null = null
if (newStatus === 'active' || newStatus === 'approved') {
try {
const customerSnapshot = updatedOrder.customer_data
const orderSnapshot = updatedOrder.order_data
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order: updatedOrder,
customer: customerSnapshot,
orderSnapshot,
partnerCompanyName: partnerInfo.partnerCompanyName,
partnerUserName: partnerInfo.partnerUserName,
partnerUserEmail: partnerInfo.partnerUserEmail,
})
)
attachmentBuffer = buffer
// PDF in Supabase Storage hochladen
const fileName = `ab_${updatedOrder.id}.pdf`
const { error: uploadError } = await admin
.storage
.from('invoices')
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
if (uploadError) {
console.error('PDF Upload Error:', uploadError)
} else {
await admin.from('orders').update({ pdf_url: fileName }).eq('id', updatedOrder.id)
updatedOrder.pdf_url = fileName
}
} catch (pdfError) {
console.error('PDF Generation Error on status transition:', pdfError)
}
}
// 4. E-Mail mit SMTP an den Besteller senden (b2b-mail-dispatcher)
if (order.user_id) {
try {
const { data: { user }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
if (!userError && user && user.email) {
const orderNumber = order.order_number || order.id.slice(0, 8)
const statusLabelMap: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
const oldLabel = statusLabelMap[oldStatus] || oldStatus
const newLabel = statusLabelMap[newStatus] || newStatus
const customerSnapshot = updatedOrder.customer_data || {}
const orderSnapshot = updatedOrder.order_data || {}
const formattedDate = new Date(updatedOrder.created_at || Date.now()).toLocaleDateString('de-DE')
const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel)
await sendMail({
to: user.email,
subject: `Statusänderung Ihrer Bestellung ${orderNumber}`,
text: statusEmail.text,
html: statusEmail.html
const statusEmail = generateOrderEmailHtml({
orderNumber,
status: newStatus,
formattedDate,
customerCompanyName: customerSnapshot.company_name || 'Endkunde',
items: orderSnapshot.items || [],
partnerCompanyName: partnerInfo.partnerCompanyName,
partnerUserName: partnerInfo.partnerUserName,
partnerUserEmail: partnerInfo.partnerUserEmail,
taxRate: orderSnapshot.tax_rate ?? 19,
})
const mailSubject = generateOrderEmailSubject(orderNumber, newStatus)
const mailOptions: any = {
to: user.email,
subject: mailSubject,
text: statusEmail.text,
html: statusEmail.html,
}
// Bei Aktivierung oder Freigabe hänge die PDF an
if ((newStatus === 'active' || newStatus === 'approved') && attachmentBuffer) {
mailOptions.attachments = [
{
filename: `Anfragebestaetigung_${orderNumber}.pdf`,
content: attachmentBuffer,
contentType: 'application/pdf',
}
]
}
await sendMail(mailOptions)
// Notify admins
try {
const adminClient = createAdminClient()
const { data: admins } = await adminClient
.from('users')
.select('email')
.in('role', ['admin', 'verwaltung'])
if (admins) {
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
for (const adminEmail of adminEmails) {
if (adminEmail !== user.email) {
await sendMail({
...mailOptions,
to: adminEmail,
subject: `[Admin-Kopie] ${mailSubject}`
})
}
}
}
} catch (adminMailError) {
console.error('Failed to notify admins of status update:', adminMailError)
}
}
} catch (mailError) {
console.error('Failed to send status update email:', mailError)
@@ -361,6 +533,186 @@ export async function updateOrderStatus(
return updatedOrder as Order
}
export async function updateOrderPayload(
orderId: string,
params: {
orderSnapshot: Partial<any>;
}
) {
const admin = createAdminClient()
const supabase = await createClient()
// Authentifizierung und Admin-Prüfung
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const { data: dbUser } = await admin
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (dbUser?.role !== 'admin') {
throw new Error('Nur Administratoren können Bestellanfragen anpassen.')
}
// Aktuelle Bestellung abrufen
const { data: order, error: fetchError } = await admin
.from('orders')
.select('*')
.eq('id', orderId)
.single()
if (fetchError || !order) {
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
}
if (order.status !== 'in_review') {
throw new Error('Bestellungen können nur im Status "in_review" angepasst werden.')
}
// Neue Snapshot-Daten mergen
const updatedSnapshot = {
...order.order_data,
...params.orderSnapshot
}
const { data: updatedOrder, error: updateError } = await admin
.from('orders')
.update({
order_data: updatedSnapshot,
total_price: updatedSnapshot.total || order.total_price
})
.eq('id', orderId)
.select()
.single()
if (updateError || !updatedOrder) {
throw new Error(`Fehler beim Aktualisieren der Bestelldaten: ${updateError?.message || 'Unbekannt'}`)
}
revalidatePath('/admin/orders')
revalidatePath('/my-orders')
return updatedOrder as Order
}
export async function rejectOrder(
orderId: string,
reason: string
) {
const admin = createAdminClient()
const supabase = await createClient()
// Authentifizierung und Admin-Prüfung
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const { data: dbUser } = await admin
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (dbUser?.role !== 'admin') {
throw new Error('Nur Administratoren können Bestellungen ablehnen.')
}
const { data: order, error: fetchError } = await admin
.from('orders')
.select('*')
.eq('id', orderId)
.single()
if (fetchError || !order) {
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
}
const updatedSnapshot = {
...order.order_data,
rejection_reason: reason
}
const { data: updatedOrder, error: updateError } = await admin
.from('orders')
.update({
status: 'rejected',
order_data: updatedSnapshot
})
.eq('id', orderId)
.select()
.single()
if (updateError || !updatedOrder) {
throw new Error(`Fehler beim Ablehnen der Bestellung: ${updateError?.message || 'Unbekannt'}`)
}
// Ablehnungs-Mail senden
if (order.user_id) {
try {
const { data: { user: orderUser }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
if (!userError && orderUser && orderUser.email) {
const orderNumber = order.order_number || order.id.slice(0, 8)
const partnerInfo = await fetchOrderPartnerInfo(admin, updatedOrder.company_id, updatedOrder.user_id)
const customerSnapshot = updatedOrder.customer_data || {}
const orderSnapshot = updatedOrder.order_data || {}
const formattedDate = new Date(updatedOrder.created_at || Date.now()).toLocaleDateString('de-DE')
const statusEmail = generateOrderEmailHtml({
orderNumber,
status: 'rejected',
formattedDate,
customerCompanyName: customerSnapshot.company_name || 'Endkunde',
rejectionReason: reason,
items: orderSnapshot.items || [],
partnerCompanyName: partnerInfo.partnerCompanyName,
partnerUserName: partnerInfo.partnerUserName,
partnerUserEmail: partnerInfo.partnerUserEmail,
taxRate: orderSnapshot.tax_rate ?? 19,
})
const mailSubject = generateOrderEmailSubject(orderNumber, 'rejected')
await sendMail({
to: orderUser.email,
subject: mailSubject,
text: statusEmail.text,
html: statusEmail.html
})
// Notify admins
try {
const adminClient = createAdminClient()
const { data: admins } = await adminClient
.from('users')
.select('email')
.in('role', ['admin', 'verwaltung'])
if (admins) {
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
for (const adminEmail of adminEmails) {
if (adminEmail !== orderUser.email) {
await sendMail({
to: adminEmail,
subject: `[Admin-Kopie] ${mailSubject}`,
text: statusEmail.text,
html: statusEmail.html
})
}
}
}
} catch (adminMailError) {
console.error('Failed to notify admins of rejected order:', adminMailError)
}
}
} catch (mailError) {
console.error('Failed to send rejection email:', mailError)
}
}
revalidatePath('/admin/orders')
revalidatePath('/my-orders')
return updatedOrder as Order
}
export async function updateOrder(
orderId: string,
params: {
@@ -398,7 +750,7 @@ export async function updateOrder(
.eq('id', user.id)
.single()
const isAdmin = dbUser?.role === 'admin'
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
let isAuthorized = existingOrder.user_id === user.id
if (!isAuthorized && dbUser?.company_id && existingOrder.user_id) {
@@ -459,6 +811,8 @@ export async function updateOrder(
if (orderError || !order) throw orderError || new Error('Fehler beim Aktualisieren der Bestellung.')
const partnerInfo = await fetchOrderPartnerInfo(admin, order.company_id, order.user_id)
// 3. PDF generieren und überschreiben
try {
const buffer = await renderToBuffer(
@@ -466,6 +820,9 @@ export async function updateOrder(
order,
customer: customerSnapshot,
orderSnapshot,
partnerCompanyName: partnerInfo.partnerCompanyName,
partnerUserName: partnerInfo.partnerUserName,
partnerUserEmail: partnerInfo.partnerUserEmail,
})
)
@@ -577,12 +934,45 @@ export async function updateOrder(
`
}
// Load partner details for update email
let partnerCompanyName = ''
let partnerUserName = ''
let partnerUserEmail = ''
if (order?.user_id) {
const { data: orderDbUser } = await supabase
.from('users')
.select('first_name, last_name, email, company_id')
.eq('id', order.user_id)
.single()
if (orderDbUser) {
partnerUserName = [orderDbUser.first_name, orderDbUser.last_name].filter(Boolean).join(' ')
partnerUserEmail = orderDbUser.email || ''
if (orderDbUser.company_id) {
const { data: comp } = await supabase
.from('companies')
.select('name')
.eq('id', orderDbUser.company_id)
.single()
if (comp) {
partnerCompanyName = comp.name
}
}
}
}
const itemsSection = buildEmailItemsSection(items)
const emailTemplate = getOrderEmailTemplate({
orderNumber: order.order_number,
formattedDate,
customerCompanyName: customerSnapshot.company_name,
totalDetailsText,
totalDetailsHtml
totalDetailsHtml,
itemsDetailsText: itemsSection.text,
itemsDetailsHtml: itemsSection.html,
partnerCompanyName,
partnerUserName,
partnerUserEmail
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true)
await sendMail({
@@ -598,6 +988,37 @@ export async function updateOrder(
}
]
})
// Notify admins
try {
const adminClient = createAdminClient()
const { data: admins } = await adminClient
.from('users')
.select('email')
.in('role', ['admin', 'verwaltung'])
if (admins) {
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
for (const adminEmail of adminEmails) {
if (adminEmail !== orderUserEmail) {
await sendMail({
to: adminEmail,
subject: `[Admin-Kopie] Anfrageänderung ${order.order_number}`,
text: emailTemplate.text,
html: emailTemplate.html,
attachments: [
{
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
content: buffer,
contentType: 'application/pdf',
}
]
})
}
}
}
} catch (adminMailError) {
console.error('Failed to notify admins of order update:', adminMailError)
}
} catch (mailError) {
console.error('Failed to send order update confirmation mail:', mailError)
}

288
shop/lib/actions/queries.ts Normal file
View File

@@ -0,0 +1,288 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import type { Order, EndCustomer, EndCustomerWithOrders, OrderWithRegisterName, FlattenedDevice, EndCustomerWithDevices, OrderItem } from '@/lib/types'
/**
* Holt alle Bestellanfragen.
* RLS filtert automatisch auf die eigene Firma (bzw. lässt Admins alle sehen).
*/
export async function getCompanyOrders(): Promise<Order[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from('orders')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data as Order[]
}
/**
* Holt alle Endkunden.
* RLS filtert automatisch auf die eigene Firma (bzw. lässt Admins alle sehen).
*/
export async function getCompanyEndCustomers(): Promise<EndCustomer[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from('end_customers')
.select('*')
.order('company_name', { ascending: true })
if (error) throw error
return data as EndCustomer[]
}
/**
* Holt alle Endkunden inkl. ihrer Bestellungen/Kassen verschachtelt.
* Extrahiert den Kassen-Namen aus order_data.items[0].device_name (Fallback "Kasse 1").
* RLS filtert automatisch über get_auth_company_id().
*/
export async function getPartnerCustomersWithOrders(): Promise<EndCustomerWithOrders[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from('end_customers')
.select('*, orders(*)')
.order('company_name', { ascending: true })
if (error) throw error
return (data || []).map((customer: any) => {
const rawOrders = customer.orders || []
// Sort orders by created_at desc
const sortedOrders = [...rawOrders].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
const orders: OrderWithRegisterName[] = sortedOrders.map((order: any) => {
const items = order.order_data?.items || []
const deviceName = items[0]?.device_name || 'Kasse 1'
return {
...order,
register_name: deviceName,
}
})
return {
...customer,
orders,
}
})
}
/**
* Bereinigt Kassen-Präfixe aus Produkt- und Modulnamen.
* Entfernt Muster wie "Kasse1:", "kass2:", "kasse 1:" etc.
*/
function stripDevicePrefix(name: string): string {
return name.replace(/^kasse?\s*\d*\s*:\s*/i, '').trim()
}
/**
* Holt alle Endkunden mit geflatteteten Kassen (eine Kasse = ein FlattenedDevice).
* Jede Order wird nach device_name in einzelne Kassen aufgespalten.
* RLS filtert automatisch über get_auth_company_id().
*/
export async function getPartnerCustomersWithDevices(): Promise<EndCustomerWithDevices[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from('end_customers')
.select('*, orders(*)')
.order('company_name', { ascending: true })
if (error) throw error
return (data || []).map((customer: any) => {
const rawOrders = customer.orders || []
const sortedOrders = [...rawOrders].sort(
(a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
const devices: FlattenedDevice[] = []
for (const order of sortedOrders) {
const allItems: OrderItem[] = order.order_data?.items || []
const totalOrderPrice: number = order.total_price || 0
// Gruppiere Items nach device_name
const deviceGroups: Record<string, OrderItem[]> = {}
for (const item of allItems) {
const key = item.device_name || 'Kasse 1'
if (!deviceGroups[key]) deviceGroups[key] = []
deviceGroups[key].push(item)
}
const deviceKeys = Object.keys(deviceGroups)
for (const deviceKey of deviceKeys) {
const devItems = deviceGroups[deviceKey]
// Bereinige Produkt- und Modulnamen
const cleanedItems: OrderItem[] = devItems.map(item => ({
...item,
product_name: stripDevicePrefix(item.product_name),
selected_modules: (item.selected_modules || []).map(mod => ({
...mod,
module_name: stripDevicePrefix(mod.module_name),
})),
}))
// Anteiliger Preis dieser Kasse (gleichmäßige Aufteilung)
const devicePrice = deviceKeys.length > 1
? totalOrderPrice / deviceKeys.length
: totalOrderPrice
devices.push({
orderId: order.id,
orderNumber: order.order_number,
orderStatus: order.status,
pdfUrl: order.pdf_url,
createdAt: order.created_at,
deviceId: deviceKey,
deviceName: deviceKey,
items: cleanedItems,
totalPrice: Math.round(devicePrice * 100) / 100,
})
}
}
const { orders: _orders, ...customerWithoutOrders } = customer
return {
...customerWithoutOrders,
devices,
} as EndCustomerWithDevices
})
}
export interface GetCustomersForWizardParams {
partnerCompanyId?: string
page?: number
pageSize?: number
search?: string
}
export interface GetCustomersForWizardResult {
customers: EndCustomer[]
totalCount: number
totalPages: number
page: number
pageSize: number
isAdmin: boolean
}
/**
* Server Action für den Neubestellung-Wizard:
* Holt Endkunden rollenbasiert mit Partner-Filter (für Admins) und Pagination.
*/
export async function getCustomersForWizard(
params: GetCustomersForWizardParams = {}
): Promise<GetCustomersForWizardResult> {
const { partnerCompanyId, page = 1, pageSize = 10, search = '' } = params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
throw new Error('Nicht authentifiziert')
}
const { data: dbUser } = await supabase
.from('users')
.select('role, company_id')
.eq('id', user.id)
.single()
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
let client = supabase
if (isAdmin) {
try {
client = createAdminClient() as any
} catch {
client = supabase
}
}
let query = client
.from('end_customers')
.select('*', { count: 'exact' })
.eq('is_anonymized', false)
if (isAdmin) {
if (partnerCompanyId && partnerCompanyId !== 'all' && partnerCompanyId !== '') {
query = query.eq('partner_id', partnerCompanyId)
}
} else {
if (dbUser?.company_id) {
query = query.eq('partner_id', dbUser.company_id)
}
}
if (search && search.trim() !== '') {
const term = `%${search.trim()}%`
query = query.or(
`company_name.ilike.${term},first_name.ilike.${term},last_name.ilike.${term},city.ilike.${term},zip.ilike.${term}`
)
}
query = query.order('company_name', { ascending: true })
const validPageSize = [10, 20, 50].includes(pageSize) ? pageSize : 10
const validPage = Math.max(1, page)
const from = (validPage - 1) * validPageSize
const to = from + validPageSize - 1
query = query.range(from, to)
const { data, error, count } = await query
if (error) {
throw error
}
const totalCount = count ?? 0
const totalPages = Math.ceil(totalCount / validPageSize) || 1
return {
customers: (data as EndCustomer[]) || [],
totalCount,
totalPages,
page: validPage,
pageSize: validPageSize,
isAdmin,
}
}
/**
* Checks if a license number is already taken by any active order.
*/
export async function isLicenseNumberTaken(licenseNumber: string, excludeOrderId?: string): Promise<boolean> {
const normalized = licenseNumber?.trim().toUpperCase()
if (!normalized) return false
const supabase = await createClient()
let query = supabase
.from('orders')
.select('id')
.contains('order_snapshot', { items: [{ license_number: normalized }] })
if (excludeOrderId) {
query = query.neq('id', excludeOrderId)
}
const { data, error } = await query
if (error) {
console.error('Error checking license number:', error)
return false
}
return !!(data && data.length > 0)
}

View File

@@ -2,6 +2,7 @@
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
import nodemailer from 'nodemailer'
export type AdminSetupData = {
email: string
@@ -11,6 +12,19 @@ export type AdminSetupData = {
lastName: string
}
export type BrandingSetupData = {
street: string
zip: string
city: string
billingStreet: string
billingZip: string
billingCity: string
sameBillingAddress: boolean
colorScheme: string
primaryColor: string
accentColor: string
}
export type SmtpSetupData = {
host: string
port: number
@@ -32,26 +46,21 @@ export async function isSetupNeeded(): Promise<boolean> {
perPage: 1
})
if (authError) {
console.error('Error checking auth users list:', authError)
if (!authError && authData && authData.users && authData.users.length > 0) {
// Mindestens ein Auth-Benutzer vorhanden -> Setup ist beendet!
return false
}
if (authData.users.length > 0) {
return false
}
// 2. Check if any user exists in public.users table
// 2. Zusatzprüfung public.users Tabelle
const { count, error: dbError } = await admin
.from('users')
.select('*', { count: 'exact', head: true })
.select('id', { count: 'exact' })
if (dbError) {
console.error('Error checking users table status:', dbError)
if (!dbError && typeof count === 'number' && count > 0) {
return false
}
return count === 0
return true
} catch (e) {
console.error('Exception checking setup status:', e)
return false
@@ -60,14 +69,14 @@ export async function isSetupNeeded(): Promise<boolean> {
/**
* Completes the initial setup by creating the first admin user,
* updating their role and profile, and storing SMTP settings.
* updating their role and profile, and storing branding & SMTP settings.
*/
export async function completeSetup(
adminData: AdminSetupData,
brandingData: BrandingSetupData,
smtpData: SmtpSetupData
): Promise<{ success: boolean; error?: string }> {
try {
// 1. Double check if setup is actually needed to prevent double runs
const needed = await isSetupNeeded()
if (!needed) {
return { success: false, error: 'Setup bereits abgeschlossen.' }
@@ -75,7 +84,7 @@ export async function completeSetup(
const admin = createAdminClient()
// 2. Create user in Supabase Auth
// 1. Create user in Supabase Auth
const { data: authData, error: authError } = await admin.auth.admin.createUser({
email: adminData.email,
password: adminData.password,
@@ -88,17 +97,20 @@ export async function completeSetup(
const userId = authData.user.id
// 3. Update user role to admin in public.users
// 2. Set user role to admin in public.users using Service Role Client
const { error: roleError } = await admin
.from('users')
.update({ role: 'admin' })
.eq('id', userId)
.upsert({
id: userId,
role: 'admin',
}, { onConflict: 'id' })
if (roleError) {
console.error('Error setting user role to admin:', roleError)
return { success: false, error: `Fehler beim Zuweisen der Admin-Rolle: ${roleError.message}` }
}
// 4. Update company and name details in public.profiles
// 3. Update company and name details in public.profiles
const { error: profileError } = await admin
.from('profiles')
.update({
@@ -113,22 +125,46 @@ export async function completeSetup(
console.error('Error updating admin profile:', profileError)
}
// 5. Store SMTP configuration in public.settings
const { error: smtpError } = await admin
// 4. Store Branding settings in public.settings (id = 'branding')
const { error: brandingError } = await admin
.from('settings')
.upsert({
id: 'smtp',
host: smtpData.host,
port: smtpData.port,
secure: smtpData.secure,
user: smtpData.user,
pass: smtpData.pass,
id: 'branding',
company_name: adminData.companyName,
street: brandingData.street,
zip: brandingData.zip,
city: brandingData.city,
billing_street: brandingData.sameBillingAddress ? brandingData.street : brandingData.billingStreet,
billing_zip: brandingData.sameBillingAddress ? brandingData.zip : brandingData.billingZip,
billing_city: brandingData.sameBillingAddress ? brandingData.city : brandingData.billingCity,
same_billing_address: brandingData.sameBillingAddress,
color_scheme: brandingData.colorScheme,
primary_color: brandingData.primaryColor,
accent_color: brandingData.accentColor,
updated_at: new Date().toISOString(),
})
if (smtpError) {
console.error('Error saving SMTP settings:', smtpError)
return { success: false, error: `Fehler beim Speichern der SMTP-Einstellungen: ${smtpError.message}` }
if (brandingError) {
console.error('Error saving branding settings:', brandingError)
}
// 5. Store SMTP configuration in public.settings (id = 'smtp')
if (smtpData.host && smtpData.host.trim().length > 0) {
const { error: smtpError } = await admin
.from('settings')
.upsert({
id: 'smtp',
host: smtpData.host,
port: smtpData.port,
secure: smtpData.secure,
user: smtpData.user,
pass: smtpData.pass,
updated_at: new Date().toISOString(),
})
if (smtpError) {
console.error('Error saving SMTP settings:', smtpError)
}
}
revalidatePath('/')
@@ -138,3 +174,46 @@ export async function completeSetup(
return { success: false, error: e.message || 'Unerwarteter Fehler beim Setup.' }
}
}
export async function testSmtpConfig(
smtpData: SmtpSetupData,
recipient: string
): Promise<{ success: boolean; message: string }> {
try {
if (!smtpData.host || !smtpData.user) {
return { success: false, message: 'Bitte Host und Benutzername ausfüllen.' }
}
const transporter = nodemailer.createTransport({
host: smtpData.host,
port: Number(smtpData.port) || 587,
secure: smtpData.secure,
auth: {
user: smtpData.user,
pass: smtpData.pass || '',
},
tls: {
rejectUnauthorized: false,
},
})
const info = await transporter.sendMail({
from: smtpData.user,
to: recipient || smtpData.user,
subject: 'CASPOS Setup Test-E-Mail',
text: 'Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.',
html: '<b>Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.</b>',
})
return {
success: true,
message: `Test-E-Mail erfolgreich gesendet an ${recipient || smtpData.user}! (ID: ${info.messageId})`,
}
} catch (err: any) {
console.error('SMTP test error:', err)
return {
success: false,
message: `SMTP-Fehler: ${err.message || err}`,
}
}
}

View File

@@ -7,6 +7,8 @@ export function validateWizardSelections(
): void {
for (const cat of dbCategories) {
const sel = selections[cat.id]
// --- is_required: Pflichtauswahl prüfen ---
const hasSelection = sel && (
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
(!cat.allow_multiselect && sel.productId)
@@ -17,6 +19,13 @@ export function validateWizardSelections(
}
if (sel) {
// --- allow_multiselect = false: keine doppelten IDs erlaubt ---
if (!cat.allow_multiselect && sel.productIds && sel.productIds.length > 1) {
throw new Error(
`Die Kategorie "${cat.name}" erlaubt keine Mehrfachauswahl. Bitte nur ein Produkt wählen.`
)
}
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel.productIds) {
sel.productIds.forEach(pId => {
@@ -32,8 +41,8 @@ export function validateWizardSelections(
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) continue // module might belong to another selected product in the same category, so skip error if it's not this one's
if (!mod) continue
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
@@ -53,7 +62,7 @@ export function validateWizardSelections(
}
}
// Product-level constraints validation
// --- Product-level constraints validation ---
const selectedProductIds: string[] = []
Object.values(selections).forEach(s => {
if (s.productIds && s.productIds.length > 0) {
@@ -92,3 +101,4 @@ export function validateWizardSelections(
}
}
}

View File

@@ -0,0 +1,228 @@
export interface BrandingSettings {
companyName: string
logoUrl?: string
developerFooter?: string
street: string
zip: string
city: string
billingStreet: string
billingZip: string
billingCity: string
sameBillingAddress: boolean
colorScheme: string
primaryColor: string
accentColor: string
successColor?: string
warningColor?: string
destructiveColor?: string
bgGlow1?: string
bgGlow2?: string
gradientFrom?: string
gradientTo?: string
cardBorder?: string
textHighlight?: string
buttonBg?: string
ringColor?: string
}
export interface ColorPreset {
id: string
name: string
description: string
primary: string
accent: string
success: string
warning: string
destructive: string
bgGlow1: string
bgGlow2: string
gradientFrom: string
gradientTo: string
cardBorder: string
textHighlight: string
buttonBg: string
ringColor: string
}
export const PRESET_COLOR_SCHEMES: ColorPreset[] = [
{
id: 'alabaster_racing_red',
name: 'Alabaster & Racing Red',
description: 'Sportlich, Dynamisch & High-End',
primary: '#dc2626',
accent: '#f8fafc',
success: '#10b981',
warning: '#f59e0b',
destructive: '#b91c1c',
bgGlow1: '#ef4444',
bgGlow2: '#7f1d1d',
gradientFrom: '#dc2626',
gradientTo: '#991b1b',
cardBorder: 'rgba(220, 38, 38, 0.35)',
textHighlight: '#fca5a5',
buttonBg: '#dc2626',
ringColor: '#ef4444',
},
{
id: 'black_cherry_gold',
name: 'Black Cherry & Gold',
description: 'Luxuriös, Exklusiv & Tief',
primary: '#881337',
accent: '#f59e0b',
success: '#10b981',
warning: '#fbbf24',
destructive: '#be123c',
bgGlow1: '#9f1239',
bgGlow2: '#d97706',
gradientFrom: '#881337',
gradientTo: '#d97706',
cardBorder: 'rgba(245, 158, 11, 0.35)',
textHighlight: '#fde68a',
buttonBg: '#881337',
ringColor: '#f59e0b',
},
{
id: 'coffee_bean_cream',
name: 'Coffee Bean & Cream',
description: 'Warm, Organisch & Elegant',
primary: '#78350f',
accent: '#fde68a',
success: '#059669',
warning: '#d97706',
destructive: '#991b1b',
bgGlow1: '#92400e',
bgGlow2: '#b45309',
gradientFrom: '#78350f',
gradientTo: '#b45309',
cardBorder: 'rgba(253, 230, 138, 0.35)',
textHighlight: '#fef3c7',
buttonBg: '#78350f',
ringColor: '#d97706',
},
{
id: 'modern_blue',
name: 'Modern Blue',
description: 'Klassisch, Vertrauensvoll & Seriös',
primary: '#2563eb',
accent: '#38bdf8',
success: '#10b981',
warning: '#f59e0b',
destructive: '#ef4444',
bgGlow1: '#3b82f6',
bgGlow2: '#1d4ed8',
gradientFrom: '#2563eb',
gradientTo: '#1e40af',
cardBorder: 'rgba(56, 189, 248, 0.35)',
textHighlight: '#93c5fd',
buttonBg: '#2563eb',
ringColor: '#38bdf8',
},
{
id: 'caspos_classic_blue',
name: 'CASPOS Classic Blue',
description: 'Das originale CASPOS-Blau vertraut & clean',
primary: '#3b82f6',
accent: '#60a5fa',
success: '#10b981',
warning: '#f59e0b',
destructive: '#ef4444',
bgGlow1: '#3b82f6',
bgGlow2: '#1d4ed8',
gradientFrom: '#3b82f6',
gradientTo: '#2563eb',
cardBorder: 'rgba(96, 165, 250, 0.35)',
textHighlight: '#bfdbfe',
buttonBg: '#3b82f6',
ringColor: '#60a5fa',
},
{
id: 'emerald_green',
name: 'Emerald Green',
description: 'Frisch, Nachhaltig & Vital',
primary: '#059669',
accent: '#34d399',
success: '#10b981',
warning: '#f59e0b',
destructive: '#e11d48',
bgGlow1: '#10b981',
bgGlow2: '#047857',
gradientFrom: '#059669',
gradientTo: '#065f46',
cardBorder: 'rgba(52, 211, 153, 0.35)',
textHighlight: '#a7f3d0',
buttonBg: '#059669',
ringColor: '#34d399',
},
{
id: 'violet_glow',
name: 'Violet Glow',
description: 'Kreativ, Modern & Futuristisch',
primary: '#7c3aed',
accent: '#c084fc',
success: '#10b981',
warning: '#f59e0b',
destructive: '#be123c',
bgGlow1: '#8b5cf6',
bgGlow2: '#5b21b6',
gradientFrom: '#7c3aed',
gradientTo: '#4c1d95',
cardBorder: 'rgba(192, 132, 252, 0.35)',
textHighlight: '#ddd6fe',
buttonBg: '#7c3aed',
ringColor: '#c084fc',
},
{
id: 'sunset_orange',
name: 'Sunset Orange',
description: 'Dynamisch, Aktiv & Energetisch',
primary: '#ea580c',
accent: '#fb923c',
success: '#10b981',
warning: '#f59e0b',
destructive: '#dc2626',
bgGlow1: '#f97316',
bgGlow2: '#9a3412',
gradientFrom: '#ea580c',
gradientTo: '#9a3412',
cardBorder: 'rgba(251, 146, 60, 0.35)',
textHighlight: '#ffedd5',
buttonBg: '#ea580c',
ringColor: '#fb923c',
},
{
id: 'cyan_neon',
name: 'Cyan Neon',
description: 'Futuristisch, High-Tech & Klar',
primary: '#0891b2',
accent: '#22d3ee',
success: '#10b981',
warning: '#f59e0b',
destructive: '#f43f5e',
bgGlow1: '#06b6d4',
bgGlow2: '#155e75',
gradientFrom: '#0891b2',
gradientTo: '#155e75',
cardBorder: 'rgba(34, 211, 238, 0.35)',
textHighlight: '#cffafe',
buttonBg: '#0891b2',
ringColor: '#22d3ee',
},
{
id: 'midnight_slate',
name: 'Midnight Slate',
description: 'Minimalistisch, Dunkel & Puristisch',
primary: '#475569',
accent: '#cbd5e1',
success: '#10b981',
warning: '#f59e0b',
destructive: '#ef4444',
bgGlow1: '#64748b',
bgGlow2: '#1e293b',
gradientFrom: '#475569',
gradientTo: '#0f172a',
cardBorder: 'rgba(203, 213, 225, 0.35)',
textHighlight: '#f1f5f9',
buttonBg: '#475569',
ringColor: '#cbd5e1',
},
]

View File

@@ -136,7 +136,7 @@ export function buildOrderSnapshot(
})
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
// If this product falls under the free limit, set its base price to 0
const actualBasePrice = (sortedIndex < freeLimit) ? 0 : prod.base_price
const itemTotal = actualBasePrice + moduleTotal
@@ -146,7 +146,7 @@ export function buildOrderSnapshot(
category_id: cat.id,
category_name: cat.name,
product_id: prod.id,
product_name: prod.name + (actualBasePrice === 0 ? " (Inklusive/Frei)" : ""),
product_name: prod.name + (actualBasePrice === 0 ? "" : ""),
base_price: actualBasePrice,
billing_interval: prod.billing_interval,
selected_modules: selectedModules,
@@ -183,10 +183,14 @@ export function buildOrderSnapshot(
multiplier = 0.90
}
// Multiply only one_time item totals by multiplier
// Multiply only one_time item totals by multiplier if product allows it
items.forEach(item => {
if (item.billing_interval === 'one_time') {
item.item_total = item.item_total * multiplier
const prod = products.find(p => p.id === item.product_id);
const allowDiscount = prod ? prod.allow_update_discount !== false : true;
if (allowDiscount) {
item.item_total = item.item_total * multiplier;
}
}
})
subtotal = items.reduce((acc, item) => acc + item.item_total, 0)

View File

@@ -119,8 +119,34 @@ export async function updateSession(request: NextRequest) {
// the cookies!
// 4. Finally:
// return myNewResponse
// If this is not done, you may be causing the browser and server to go out
// of sync and terminate the user's session prematurely!
// Inaktivitäts-Prüfung serverseitig (10 Minuten = 600.000 ms)
const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
if (user?.sub && !request.nextUrl.pathname.startsWith("/auth")) {
const lastActivityCookie = request.cookies.get("webshop-last-activity");
const now = Date.now();
if (lastActivityCookie?.value) {
const lastActivityTime = parseInt(lastActivityCookie.value, 10);
if (!isNaN(lastActivityTime) && now - lastActivityTime > INACTIVITY_TIMEOUT_MS) {
// Länger als 10 Minuten inaktiv -> Auf dem Server sofort abmelden
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
url.searchParams.set("message", "inactivity");
const response = NextResponse.redirect(url);
response.cookies.delete("webshop-auth-token");
response.cookies.delete("webshop-last-activity");
return response;
}
}
// Aktuellen Zeitstempel in Cookie schreiben
supabaseResponse.cookies.set("webshop-last-activity", now.toString(), {
path: "/",
sameSite: "lax",
httpOnly: false, // Für Client-Zugriff lesbar/schreibbar
});
}
return supabaseResponse;
}

View File

@@ -17,6 +17,8 @@ export type Product = {
show_in_abo?: boolean
requirements?: string[]
exclusions?: string[]
allow_update_discount?: boolean
linked_fee_product_id?: string | null
}
export type Category = {
@@ -134,6 +136,8 @@ export type OrderItem = {
billing_interval: BillingInterval
selected_modules: OrderModuleSnapshot[]
item_total: number
device_name?: string
license_number?: string
}
/**
@@ -149,6 +153,7 @@ export type OrderSnapshot = {
total: number
last_license_date?: string | null
price_multiplier?: number | null
end_customer?: any
}
// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
@@ -166,6 +171,40 @@ export type Order = {
pdf_url: string | null
status: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
created_at: string
end_customer_data?: any
notes?: string | null
}
export type OrderWithRegisterName = Order & {
register_name: string
}
export type EndCustomerWithOrders = EndCustomer & {
orders: OrderWithRegisterName[]
}
/**
* Eine einzelne Kasse, herausgelöst aus einem Order-Snapshot.
* Ermöglicht die Darstellung einer Kasse = eine Karte (Flattening).
*/
export type FlattenedDevice = {
orderId: string
orderNumber: string
orderStatus: Order['status']
pdfUrl: string | null
createdAt: string
/** Eindeutiger Bezeichner der Kasse innerhalb der Order (= device_name) */
deviceId: string
/** Anzeigename der Kasse, bereinigt von Präfixen */
deviceName: string
/** Items (Produkte + Module) die zu dieser Kasse gehören */
items: OrderItem[]
/** Anteiliger Gesamtpreis dieser Kasse */
totalPrice: number
}
export type EndCustomerWithDevices = EndCustomer & {
devices: FlattenedDevice[]
}
// ─── Lizenz-Output (für Lizenzserver / ERP) ───────────────────────────────────

View File

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

View File

@@ -1,65 +1,5 @@
import { Product, ProductModule, Category, WizardSelections } from './types';
export interface GlobalInclusion {
trigger_product_id: string;
included_module_id: string;
}
export interface GlobalExclusion {
product_id: string;
excluded_product_id?: string | null;
excluded_module_id?: string | null;
}
/**
* Handle changes to the base product.
* Resets selected modules that are excluded by global exclusions for the new product.
* Automatically adds globally included modules for the new product.
*/
export function handleBaseProductChange(
newProductId: string,
currentSelections: WizardSelections,
modules: ProductModule[],
inclusions: GlobalInclusion[],
exclusions: GlobalExclusion[]
): WizardSelections {
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
// Find all modules that are excluded for the new product
const excludedModuleIds = exclusions
.filter(ex => ex.product_id === newProductId && ex.excluded_module_id)
.map(ex => ex.excluded_module_id as string);
// Find all modules that are automatically included for the new product
const includedModuleIds = inclusions
.filter(inc => inc.trigger_product_id === newProductId)
.map(inc => inc.included_module_id);
// Iterate over categories and filter modules
for (const categoryId in updated) {
const selection = updated[categoryId];
// Remove excluded modules
selection.moduleIds = selection.moduleIds.filter(
mid => !excludedModuleIds.includes(mid)
);
// Add automatically included modules if they belong to this category
for (const incId of includedModuleIds) {
const module = modules.find(m => m.id === incId);
if (module && module.product_id === newProductId) {
// Find category of module. In our schema, modules might belong to a category.
// Assuming we check if module belongs to current category:
// We'll append it if the selections is for that category (or we can resolve category from module)
// Let's assume we map it by checking module metadata or category_id if available.
// For simplicity: if the module is already in the DB and matches, we add it.
}
}
}
return updated;
}
/**
* Handle toggling of a module selection.
* Handles category-specific allow_multiselect rules.

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