Compare commits

111 Commits

Author SHA1 Message Date
DanielS
2f96552fe8 revert: Remove group by partner option from end customers view
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-06-25 02:23:50 +02:00
DanielS
6a2fc16f75 feat: Add group by partner option to company end customers list
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 02:18:53 +02:00
DanielS
0ff4df2a12 feat: Add end customer view for companies with CRUD and partner assignment
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 02:18:00 +02:00
DanielS
ef9422908d feat: list end customers in companies admin and improve dark/light mode contrast across all admin components
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 02:11:07 +02:00
DanielS
bf27c62f62 feat: add button and modal to display users (partners) of a company in companies management page
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-06-25 01:58:34 +02:00
DanielS
5a2edef0df feat: add search field to companies list page
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:57:40 +02:00
DanielS
a1ad75c2db feat: add delivery address migration, update companies server actions and rewrite companies page to support creation and edit of full address
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-06-25 01:52:05 +02:00
DanielS
85df922498 feat: make company selection mandatory for partner role and add inline company creation in UserDialog
All checks were successful
Staging Build / build (push) Successful in 2m21s
2026-06-25 01:43:53 +02:00
DanielS
67a2c2a67b feat: add hideLocked checkbox to filter out gesperrt users by default
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:41:51 +02:00
DanielS
07c2758b73 feat: add first_name and last_name to users and implement inline editing in user-list
All checks were successful
Staging Build / build (push) Successful in 2m40s
2026-06-25 01:38:07 +02:00
DanielS
30a9de0e6d style: clarify user deletion blocking message and offer to lock instead
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 01:33:57 +02:00
DanielS
24929494a6 feat: check for existing orders before user deletion and offer to lock instead
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:33:38 +02:00
DanielS
86d71b2441 fix: improve deleteUser error logging and handling to prevent silent DB failures
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-25 01:30:10 +02:00
DanielS
744302e8ab fix: resolve popover cutoff inside table by setting overflow-visible and adding debug logging for companies
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:29:01 +02:00
DanielS
396f135e87 fix: resolve infinite loading on user deletion by adding try-finally and deleting from public.users first
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-25 01:25:49 +02:00
DanielS
03d711c2e1 style: rename CASPOS to CASPOS Shop in welcome email
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-25 01:19:33 +02:00
DanielS
2de8913dac style: replace HEPHEX branding with CASPOS in welcome email
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:18:26 +02:00
DanielS
82e877249a style: improve welcome email layout and content
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-25 01:17:54 +02:00
DanielS
872e973a36 fix: add missing use client directive to user-dialog.tsx
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-06-25 01:11:14 +02:00
DanielS
4cd6d86f33 feat: add company selection to user dialog, require email password link on user creation, password validation with confirmation, and company grouping with search
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-25 01:09:19 +02:00
DanielS
69c8e8e5c1 fix: use token_hash with /auth/confirm route for password reset link
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-25 00:54:10 +02:00
DanielS
5652ce1098 fix: replace localhost in reset link with staging domain
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-06-25 00:30:20 +02:00
DanielS
cebf677758 fix: clean users.ts rewrite, add company_id to createUser, fix resetPassword link
All checks were successful
Staging Build / build (push) Successful in 2m38s
2026-06-25 00:20:18 +02:00
DanielS
410289a519 Fix typings and company name access
All checks were successful
Staging Build / build (push) Successful in 2m38s
2026-06-25 00:07:08 +02:00
DanielS
49607f484f Add email send to password reset
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-24 23:56:52 +02:00
DanielS
2120698192 Clean admin settings page, remove duplicate imports and partner code
All checks were successful
Staging Build / build (push) Successful in 2m41s
2026-06-24 23:39:33 +02:00
DanielS
a9a5dd4e6f Cleanup admin settings page, remove duplicate imports and partner code
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-24 23:37:40 +02:00
DanielS
520599986c Add user lock/unlock functionality and remove partner management
Some checks failed
Staging Build / build (push) Failing after 26s
2026-06-24 23:29:16 +02:00
DanielS
864bb3a0c1 Fix companies actions, add assignCompanyToUser, cleanup duplicates
All checks were successful
Staging Build / build (push) Successful in 2m41s
2026-06-24 23:11:02 +02:00
DanielS
82c89b70bf Add company CRUD, admin sidebar order, partner/company pages
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-24 23:05:15 +02:00
DanielS
ac0b0f607d Add Partner/Firmen links, migration for companies and partner relationship
All checks were successful
Staging Build / build (push) Successful in 2m40s
2026-06-24 22:51:02 +02:00
DanielS
4e86ad7a9d Rename companies to partners and update UI
All checks were successful
Staging Build / build (push) Successful in 2m44s
2026-06-24 22:29:51 +02:00
DanielS
7a14aa0f75 feat: add company management and user-company assignment in admin panel
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-24 17:12:40 +02:00
DanielS
6b66cc5c41 feat: add lock/unlock button to partner table in Einstellungen
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-06-24 17:05:16 +02:00
DanielS
cd04309e3e feat: add user role management (admin/partner/gesperrt) with login block
All checks were successful
Staging Build / build (push) Successful in 2m44s
2026-06-24 14:26:25 +02:00
DanielS
26de6820e3 feat: add Einstellungen, SMTP and DB-Tools links to admin sidebar
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-24 14:04:24 +02:00
DanielS
9fbab07e1a fix: resolve header logout state synchronization issue
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-06-24 13:31:30 +02:00
DanielS
fe1d15090a feat: hide all demo badges when demo mode is disabled
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-24 13:19:00 +02:00
DanielS
3c60889e2a feat: implement admin validation middleware, DB integrity scan, automatic schema repair, and reindexing dashboard 2026-06-24 13:06:23 +02:00
DanielS
6e5cee0180 refactor: rename proxy.ts to middleware.ts and update function name to middleware 2026-06-24 13:02:28 +02:00
DanielS
f2db40fa36 fix: resolve client-side connection timeout by stripping internal port 8000 on Supabase URL rewriting
All checks were successful
Staging Build / build (push) Successful in 2m36s
2026-06-24 11:00:44 +02:00
DanielS
0968f1a753 ci: add docker prune step to clean up Gitea runner cache after push
All checks were successful
Staging Build / build (push) Successful in 2m35s
2026-06-24 10:57:25 +02:00
DanielS
ac5bd766a4 ci: add docker image prune -f to staging workflow deployment script 2026-06-24 10:56:00 +02:00
DanielS
5c8c198fc5 fix: production login error masking, resolve navbar hydration mismatch, and gracefully handle database render errors
All checks were successful
Staging Build / build (push) Successful in 2m15s
2026-06-24 10:54:00 +02:00
DanielS
e1f47ddcd0 feat: add role selection to user creation and UI updates
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 01:58:01 +02:00
DanielS
934879e5cf fix: redirect to home page on sign out 2026-06-24 01:40:17 +02:00
DanielS
4a17ea378e feat: make orders list clearer and put navbar globally except for admin area
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 01:37:01 +02:00
DanielS
e829006b77 feat: update contact link on homepage to point to caspos.de/kontakt/
Some checks failed
Staging Build / build (push) Failing after 1m56s
2026-06-24 01:26:44 +02:00
DanielS
76e46c08a4 feat: update privacy policy links to point to caspos.de/datenschutz/
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-24 01:25:41 +02:00
DanielS
01c46be9ef add zulip dummy for quick access
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-24 01:23:30 +02:00
DanielS
f09cec4e25 fix: ignore network errors during concurrent session check to prevent false-positive logouts
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-24 01:14:33 +02:00
DanielS
b34ed79d2b feat: implement next redirect flow, remove inactivity auto-logout, and add concurrent session termination notification
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 01:09:17 +02:00
DanielS
47664c0aae fix: resolve mixed content errors by synchronizing client supabase URL protocol with window location protocol
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-24 00:57:22 +02:00
DanielS
5662b9f770 feat: allow admin to order licenses and query all partner end customers
All checks were successful
Staging Build / build (push) Successful in 2m15s
2026-06-24 00:32:20 +02:00
DanielS
a56eb93ffc feat: handle redirects for admin in /order page and implement next query param on login
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-24 00:29:46 +02:00
DanielS
615b0719f1 feat: redirect user dynamically based on role instead of /protected after login
All checks were successful
Staging Build / build (push) Successful in 2m14s
2026-06-24 00:23:51 +02:00
DanielS
62e3c7804a feat: fix logout button, inactivity tracker, and concurrent session termination
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:15:59 +02:00
DanielS
9878cdb2a2 feat: add CASPOS Store brand and Demo badge to admin sidebar footer
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:12:45 +02:00
DanielS
f19fda5494 feat: enforce admin role checks in layouts and navbar navigation
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-24 00:05:16 +02:00
DanielS
7b22889e23 feat: add partner list and customer list explorer to admin settings
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-24 00:02:27 +02:00
DanielS
bdbf840529 feat: lock down registration and enforce partner-only logins
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 23:59:22 +02:00
DanielS
b71b84da1b migration: create public.customers table with RLS
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-23 23:52:57 +02:00
DanielS
1b4e998cf9 feat: add admin settings page with toggle switch for demo banner
All checks were successful
Staging Build / build (push) Successful in 2m16s
2026-06-23 23:43:22 +02:00
DanielS
a99adb88d6 feat: update navbar links, settings labels, dark mode text color, and banner toggle hook 2026-06-23 23:43:16 +02:00
DanielS
dd2c3859e9 feat: add custom navbar with inline supabase clients and custom dropdown
All checks were successful
Staging Build / build (push) Successful in 2m22s
2026-06-23 23:27:23 +02:00
DanielS
0729dd21be Implement dynamic SMTP setting loader, admin product dependency matrix UI, recursive deselect cascade, and backend catalog validation
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 04:42:50 +02:00
DanielS
de1d228fb2 feat: port all auth forms to Next.js Server Actions to avoid exposing Supabase API to the browser
All checks were successful
Staging Build / build (push) Successful in 2m19s
2026-06-23 04:39:00 +02:00
DanielS
3228c76675 fix: use maybeSingle for settings query to avoid failing when the table is empty
All checks were successful
Staging Build / build (push) Successful in 2m18s
2026-06-23 04:36:16 +02:00
DanielS
b039da3ff8 fix: force dynamic rendering on admin users page to load data at request-time
All checks were successful
Staging Build / build (push) Successful in 2m20s
2026-06-23 04:29:58 +02:00
DanielS
07eb18c61c debug: print detailed error message when loading admin users fails
All checks were successful
Staging Build / build (push) Successful in 2m25s
2026-06-23 04:11:14 +02:00
DanielS
5ba9869400 feat: create users and settings tables, replace getSession with getUser
All checks were successful
Staging Build / build (push) Successful in 2m29s
2026-06-23 04:05:39 +02:00
DanielS
9542fdfe4a migration: add order_number and order_hash columns to orders
All checks were successful
Staging Build / build (push) Successful in 2m27s
2026-06-23 04:02:36 +02:00
DanielS
88f18246ce feat: split pricing into one-time and monthly totals, fix invoice pdf rendering
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 04:01:03 +02:00
DanielS
6b76e82220 fix: resolve customer creation fk violation and implement instant updates in admin dialogs
All checks were successful
Staging Build / build (push) Successful in 2m26s
2026-06-23 03:27:46 +02:00
DanielS
898a5253e5 fix: remove default seeded categories from migrations
All checks were successful
Staging Build / build (push) Successful in 2m26s
2026-06-23 03:22:30 +02:00
DanielS
756ec5c002 feat: add automatic database migration and seeding on docker startup
All checks were successful
Staging Build / build (push) Successful in 2m47s
2026-06-23 03:14:40 +02:00
DanielS
235efd5a47 fix: use static auth cookie name to sync session across browser and server contexts
All checks were successful
Staging Build / build (push) Successful in 1m56s
2026-06-23 02:58:55 +02:00
DanielS
c0c5240f8d fix: resolve internal docker hostnames to client window location hostname in browser context
All checks were successful
Staging Build / build (push) Successful in 1m49s
2026-06-23 02:42:50 +02:00
DanielS
ca69db2395 fix: prioritize SUPABASE_URL and SUPABASE_ANON_KEY server-side environment variables
All checks were successful
Staging Build / build (push) Successful in 1m47s
2026-06-23 02:37:48 +02:00
DanielS
e789a38d19 fix: add environment variable fallback for Supabase client configuration
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 02:36:31 +02:00
DanielS
2ec5993e63 Add Supabase ARG/ENV before build and in runtime stage
All checks were successful
Staging Build / build (push) Successful in 1m59s
2026-06-23 02:16:45 +02:00
DanielS
6db2610c8d Update Docker build to use internal supabase‑kong URL"
All checks were successful
Staging Build / build (push) Successful in 1m53s
2026-06-23 02:10:11 +02:00
DanielS
0b2809b13e Add Supabase ARG/ENV before build in Dockerfile (final)
All checks were successful
Staging Build / build (push) Successful in 1m41s
2026-06-23 01:32:46 +02:00
DanielS
9ffa04e551 Insert Supabase ARG/ENV before Next.js build in Dockerfile
Some checks failed
Staging Build / build (push) Has been cancelled
2026-06-23 01:16:37 +02:00
DanielS
505629f798 Add build args to Docker build step in staging workflow
All checks were successful
Staging Build / build (push) Successful in 1m46s
2026-06-23 01:09:58 +02:00
DanielS
08418e22d3 Add Supabase keys to staging build step
All checks were successful
Staging Build / build (push) Successful in 1m44s
2026-06-23 01:03:55 +02:00
DanielS
0f55e17095 Enable Next.js standalone mode and simplify Dockerfile copy block
All checks were successful
Staging Build / build (push) Successful in 1m43s
2026-06-23 00:32:08 +02:00
DanielS
fa1edc9c12 Add cookies handling to smtp-settings routes
Some checks failed
Staging Build / build (push) Failing after 1m55s
2026-06-23 00:27:55 +02:00
DanielS
bab2930b97 Add cookies handling to send-test-email route
Some checks failed
Staging Build / build (push) Failing after 27s
2026-06-23 00:26:08 +02:00
DanielS
e97ff51dc8 Fix client directive on admin settings page and install nodemailer
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-23 00:23:47 +02:00
DanielS
4402d4ec64 Add SMTP mail utility, admin settings page, and API routes for SMTP configuration and test email
Some checks failed
Staging Build / build (push) Failing after 25s
2026-06-23 00:17:41 +02:00
DanielS
4b9c289ec4 test build server
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-06-22 23:54:12 +02:00
DanielS
072ceaf5f0 fix build yaml staging adress of the host for deployment
All checks were successful
Staging Build / build (push) Successful in 3m28s
2026-06-22 23:40:47 +02:00
DanielS
e31721432b Update optional TS copy lines with wildcard in Dockerfile
Some checks failed
Staging Build / build (push) Failing after 2m34s
2026-06-22 23:34:29 +02:00
DanielS
e1a555d4f9 Fix duplicate next.config copy in Dockerfile
Some checks failed
Staging Build / build (push) Failing after 1m30s
2026-06-22 23:29:29 +02:00
DanielS
8bf59b20f6 Make public copy flexible with wildcard in Dockerfile 2026-06-22 23:29:06 +02:00
DanielS
3bc7c6ad3b Make Dockerfile tolerant to missing config files
Some checks failed
Staging Build / build (push) Failing after 1m32s
2026-06-22 23:24:58 +02:00
DanielS
65e133a391 fix staging build yaml
Some checks failed
Staging Build / build (push) Failing after 1m45s
2026-06-22 23:18:41 +02:00
DanielS
7ab0c28f6f fix staging build yaml docker build image
Some checks failed
Staging Build / build (push) Failing after 29s
2026-06-22 23:14:22 +02:00
DanielS
c187c1dc60 fix dockerfile position
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-22 23:10:58 +02:00
DanielS
9ddf5faf66 change from GITEA to REGISTRY for build server secrets
Some checks failed
Staging Build / build (push) Failing after 41s
2026-06-22 23:05:13 +02:00
DanielS
f11322e98d ci: fix docker login command and update deployment paths
Some checks failed
Staging Build / build (push) Failing after 31s
2026-06-22 22:45:20 +02:00
DanielS
126de81834 add container for build server
Some checks failed
Staging Build / build (push) Failing after 1m22s
2026-06-22 22:41:04 +02:00
DanielS
7bb6807d37 Fix global error page, update CI workflow, add Dockerfile
Some checks failed
Staging Build / build (push) Failing after 34s
2026-06-22 22:30:55 +02:00
DanielS
3eb72481ce Add key props to recent orders list rendering
Some checks failed
Staging Build / build (push) Failing after 26s
2026-06-22 22:25:21 +02:00
DanielS
2403408b6c Fix React key props in order and my-orders pages 2026-06-22 22:24:21 +02:00
DanielS
f91499861e fix: korrigiere workflow syntax
Some checks failed
Staging Build / build (push) Failing after 28s
2026-06-22 21:41:42 +02:00
DanielS
84ced5a170 remove unused build config
Some checks failed
Staging Build / build (push) Failing after 2s
2026-06-22 21:38:57 +02:00
DanielS
d7a45576b2 build server test
Some checks failed
Staging Build / build (push) Failing after 1m1s
2026-06-22 21:32:53 +02:00
DanielS
dedf1945ef correct build server config
Some checks failed
Staging Build / build (push) Failing after 24s
2026-06-22 21:27:06 +02:00
e6913c03f5 Merge pull request 'develop build server' (#1) from develop into staging
Some checks failed
Staging Build / build (push) Failing after 48s
Reviewed-on: #1
2026-06-22 21:24:09 +02:00
72 changed files with 5246 additions and 694 deletions

View File

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

51
Dockerfile Normal file
View File

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

35
scripts/init_supabase.sh Normal file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
set -e
# Initialize Supabase if needed and ensure default admin user exists
# Requires environment variables:
# SUPABASE_URL base URL of Supabase instance (e.g., http://192.168.1.132:8000)
# SUPABASE_SERVICE_ROLE_KEY service role secret key with admin privileges
# DEFAULT_USER_EMAIL email for the default user (default: info@hephex.de)
# DEFAULT_USER_PASSWORD password for the default user (default: Kathi2022!)
DEFAULT_EMAIL="${DEFAULT_USER_EMAIL:-info@hephex.de}"
DEFAULT_PASSWORD="${DEFAULT_USER_PASSWORD:-Kathi2022!}"
if [[ -z "$SUPABASE_URL" || -z "$SUPABASE_SERVICE_ROLE_KEY" ]]; then
echo "Supabase URL or Service Role Key not set skipping user creation."
exit 0
fi
# Check if the user already exists
EXISTING=$(curl -s "${SUPABASE_URL}/auth/v1/admin/users?apikey=${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Content-Type: application/json" | grep -i "${DEFAULT_EMAIL}")
if [[ -n "$EXISTING" ]]; then
echo "Default user ${DEFAULT_EMAIL} already exists."
else
echo "Creating default user ${DEFAULT_EMAIL}..."
curl -s -X POST "${SUPABASE_URL}/auth/v1/admin/users?apikey=${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \
-H "Content-Type: application/json" \
-d "{\"email\": \"${DEFAULT_EMAIL}\", \"password\": \"${DEFAULT_PASSWORD}\", \"email_confirmed\": true}"
echo "User created."
fi
exec "$@"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
/* Admin Settings without Partner Management */
'use client';
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
export default function AdminSettings() {
const [demoActive, setDemoActive] = useState(true);
useEffect(() => {
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
}, []);
const toggleDemo = (checked: boolean) => {
setDemoActive(checked);
localStorage.setItem('demo_banner_disabled', (!checked).toString());
window.dispatchEvent(new Event('storage_demo_changed'));
};
return (
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
Verwalten Sie globale Shopeinstellungen.
</p>
</div>
{/* Allgemeine Einstellungen */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-4">
<h2 className="text-lg font-bold">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>
</div>
);
}

View File

@@ -1,15 +1,33 @@
import Link from 'next/link'
import { LayoutDashboard, Package, Settings, Users, LogOut, LayoutGrid, ShoppingCart } from 'lucide-react'
import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2 } from 'lucide-react'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { AdminLogoutButton } from '@/components/admin/logout-menu-item'
import { DemoWrapper } from '@/components/DemoWrapper'
export default function AdminLayout({
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/auth/login')
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (!userData || userData.role !== 'admin') {
redirect('/')
}
return (
<div className="flex min-h-screen bg-[#020617] text-white">
<div className="flex min-h-screen bg-slate-50 dark:bg-[#020617] text-slate-900 dark:text-white">
{/* Sidebar */}
<aside className="w-64 border-r border-white/5 bg-black/20 backdrop-blur-xl flex flex-col">
<aside className="w-64 border-r border-slate-200 dark:border-white/5 bg-white dark:bg-black/20 backdrop-blur-xl flex flex-col">
<div className="p-6">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
@@ -20,37 +38,59 @@ export default function AdminLayout({
</div>
<nav className="flex-1 px-4 space-y-2 py-4">
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<LayoutDashboard className="w-5 h-5" />
Dashboard
</Link>
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<Package className="w-5 h-5" />
Produkte
</Link>
<Link href="/admin/categories" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Link href="/admin/categories" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<LayoutGrid className="w-5 h-5" />
Kategorien
</Link>
<Link href="/admin/orders" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Link href="/admin/orders" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<ShoppingCart className="w-5 h-5" />
Bestellungen
</Link>
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<Users className="w-5 h-5" />
Benutzer
</Link>
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<Link href="/admin/companies" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<Building2 className="w-5 h-5" />
Firmen
</Link>
<div className="pt-2 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
</div>
<Link href="/admin/einstellungen" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<Settings className="w-5 h-5" />
Einstellungen
Allgemein
</Link>
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<Wrench className="w-5 h-5" />
SMTP
</Link>
<Link href="/admin/tools" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<Database className="w-5 h-5" />
DB-Tools
</Link>
</nav>
<div className="p-4 border-t border-white/5">
<button className="flex w-full items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition-all">
<LogOut className="w-5 h-5" />
Abmelden
</button>
<div className="p-4 border-t border-slate-200 dark:border-white/5 space-y-4">
<AdminLogoutButton />
<div className="px-3 pt-2 flex items-center gap-2 border-t border-slate-200 dark:border-white/5 opacity-60">
<span className="font-bold text-sm tracking-tighter text-slate-800 dark:text-white">CASPOS Store</span>
<DemoWrapper>
<span className="text-[10px] bg-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</DemoWrapper>
</div>
</div>
</aside>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { InactivityTracker } from "@/components/inactivity-tracker";
import { HeaderWrapper } from "@/components/HeaderWrapper";
import { Navbar } from "@/components/Navbar";
import "./globals.css";
const defaultUrl = process.env.VERCEL_URL
@@ -33,7 +36,10 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<InactivityTracker />
<HeaderWrapper navbar={<Navbar />}>
{children}
</HeaderWrapper>
</ThemeProvider>
</body>
</html>

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

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

View File

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

View File

@@ -113,7 +113,7 @@ export default async function MyOrdersPage() {
<div className="flex flex-wrap gap-2">
{items.map((item, i) => (
<span
key={i}
key={item.product_id}
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
>
{item.product_name}

View File

@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { getProducts, getCategories } from '@/lib/actions/products'
import { getEndCustomers } from '@/lib/actions/end-customers'
import { OrderWizard } from '@/components/order-wizard'
@@ -31,19 +32,48 @@ async function OrderDataWrapper() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/auth/login')
redirect('/auth/login?next=/order')
}
const products = await getProducts()
const categories = await getCategories()
const endCustomers = await getEndCustomers()
// Fetch profile if exists
const { data: profile } = await supabase
.from('profiles')
.select('*')
// Check user role
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
const products = await getProducts().catch(() => [])
const categories = await getCategories().catch(() => [])
// Admin sees all end customers across partners
let endCustomers = []
if (userData?.role === 'admin') {
try {
const adminClient = createAdminClient()
const { data } = await adminClient
.from('end_customers')
.select('*')
.order('company_name', { ascending: true })
endCustomers = data || []
} catch (e) {
console.error('Fehler beim Laden aller Endkunden für Admin:', e)
}
} else {
endCustomers = await getEndCustomers().catch(() => [])
}
// Fetch profile safely using maybeSingle
let profile = null
try {
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.maybeSingle()
profile = data
} catch (e) {
console.error('Fehler beim Laden des Profils:', e)
}
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
}

View File

@@ -79,8 +79,8 @@ export default async function OrderSuccessPage({
<CardContent className="space-y-4">
{/* Produkte */}
<div className="space-y-3">
{items.map((item, i) => (
<div key={i} className="space-y-1">
{items.map((item) => (
<div key={item.product_id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-semibold text-white">{item.product_name}</span>
<span className="text-white">
@@ -90,8 +90,8 @@ export default async function OrderSuccessPage({
</span>
</span>
</div>
{item.selected_modules.map((mod, j) => (
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400">
{item.selected_modules.map((mod) => (
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
<span>+ {mod.module_name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
</div>

View File

@@ -1,108 +1,110 @@
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
import { Shield, Cpu, ArrowRight, Key, Zap, CheckCircle2 } from 'lucide-react'
import { Navbar } from '@/components/Navbar'
export default function Home() {
return (
<div className="flex flex-col min-h-screen bg-[#020617] text-white">
<div className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-400 font-medium tracking-wide z-50">
Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
</div>
<header className="px-4 lg:px-6 h-16 flex items-center border-b border-white/5 backdrop-blur-md sticky top-0 z-50">
<Link className="flex items-center justify-center gap-2" href="#">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<Rocket className="h-5 w-5 text-white" />
</div>
<span className="font-bold text-xl tracking-tighter">CASPOS Store</span>
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
Demo
</span>
</Link>
<nav className="ml-auto flex gap-4 sm:gap-6 items-center">
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
Bestellen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-customers">
Meine Kunden
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-orders">
Meine Bestellungen
</Link>
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
Admin
</Link>
<Button variant="outline" className="border-white/10 hover:bg-white/5">
Login
</Button>
</nav>
</header>
<main className="flex-1">
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-primary/20 blur-[120px] rounded-full -z-10 opacity-30" />
<div className="container mx-auto">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-full bg-amber-500/10 px-3 py-1 text-sm text-amber-400 font-medium border border-amber-500/20 mb-4">
Demo-Umgebung für Testbestellungen
</div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
CASPOS <span className="text-gradient">Software-Bestellung</span>
</h1>
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
Modulare Lösungen, einfaches Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
</p>
</div>
<div className="space-x-4 mt-8">
<Link href="/order">
<Button size="lg" className="h-12 px-8 text-lg font-semibold shadow-lg shadow-primary/20 transition-all hover:scale-105">
Jetzt bestellen <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="/contact">
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
Contact Us
</Button>
</Link>
<>
{/* Hero Section: Zielgerichtet auf Lizenzen */}
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full -z-10 opacity-30" />
<div className="container mx-auto">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-full bg-blue-500/10 px-3 py-1 text-sm text-blue-600 dark:text-blue-400 font-medium border border-blue-500/20 mb-4">
CASPOS Lizenz-Management-Server
</div>
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
POS Software <span className="text-blue-600 dark:text-blue-500">Lizenzen & Module</span>
</h1>
<p className="mx-auto max-w-[700px] text-slate-600 dark:text-slate-400 md:text-xl mt-6">
Erwerben und verwalten Sie digitale Lizenzen für CASPOS Kassensysteme. Sofortige Aktivierung und automatische Bereitstellung für Ihre Kunden.
</p>
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]">
<div className="container mx-auto px-4 md:px-6">
<div className="grid gap-12 lg:grid-cols-3">
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Cpu className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Handel</h3>
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. Wir können es.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Shield className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Gastronomie</h3>
<p className="text-slate-400">Egal ob mobiles Ordern, einer Küchen-Anzeige oder einen Pick-Up-Bord. Wir können es.</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
<div className="p-3 bg-primary/10 rounded-xl">
<Rocket className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
</div>
{/* High-Contrast Buttons (Light/Dark Ready) */}
<div className="space-x-4 mt-8">
<Link href="/order">
<Button size="lg" className="h-12 px-8 text-lg font-bold transition-all hover:scale-105 bg-slate-900 text-white hover:bg-slate-800 border border-slate-200 dark:bg-white dark:text-black dark:hover:bg-slate-200 dark:border-none">
Lizenz bestellen <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer">
<Button size="lg" variant="outline" className="h-12 px-8 text-lg font-semibold bg-transparent border-slate-400 text-slate-900 hover:bg-slate-100 dark:border-white/40 dark:text-white dark:hover:bg-white/10">
Fragen? Kontaktieren Sie uns
</Button>
</Link>
</div>
</div>
</section>
</main>
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-white/5 bg-[#020617]">
</div>
</section>
{/* Feature Grid: Aufgeteilt nach echten POS-Modulen */}
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-white/[0.02]">
<div className="container mx-auto px-4 md:px-6">
<div className="grid gap-12 lg:grid-cols-3">
{/* Modul 1 */}
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
<div className="p-3 bg-blue-600/10 rounded-xl">
<Cpu className="h-8 w-8 text-blue-600 dark:text-blue-500" />
</div>
<h3 className="text-xl font-bold">Handel & ERP</h3>
<p className="text-slate-600 dark:text-slate-400">Erweiterungen für Handelskassen, Bestandsführung und vollwertige Warenwirtschaftssysteme (ERP).</p>
</div>
{/* Modul 2 */}
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
<div className="p-3 bg-blue-600/10 rounded-xl">
<Zap className="h-8 w-8 text-blue-600 dark:text-blue-500" />
</div>
<h3 className="text-xl font-bold">Gastronomie</h3>
<p className="text-slate-600 dark:text-slate-400">Lizenzen für mobiles Bestellen (Orderman), digitale Küchen-Monitore und interaktive Abhol-Boards.</p>
</div>
{/* Modul 3 */}
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
<div className="p-3 bg-blue-600/10 rounded-xl">
<Shield className="h-8 w-8 text-blue-600 dark:text-blue-500" />
</div>
<h3 className="text-xl font-bold">Hybride Systeme</h3>
<p className="text-slate-600 dark:text-slate-400">Das beste aus beiden Welten. Handel mit Gastronomie und umgekehrt. </p>
</div>
</div>
</div>
</section>
{/* Kurze Info-Sektion für Partner / Händler */}
<section className="w-full py-12 md:py-24 border-t border-slate-200 dark:border-white/5">
<div className="container mx-auto px-4 max-w-4xl text-center">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl mb-6">Wie funktioniert die Bereitstellung?</h2>
<div className="grid gap-6 sm:grid-cols-3 text-left">
<div className="flex gap-2 items-start">
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>1. Lizenz wählen:</strong> Gewünschtes POS-Modul im Shop selektieren.</p>
</div>
<div className="flex gap-2 items-start">
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>2. Kunde zuweisen:</strong> Lizenz direkt einem Kunden-Account zuordnen.</p>
</div>
<div className="flex gap-2 items-start">
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>3. Live gehen:</strong> Lizenz-Key (`.key`) wird sofort serverseitig aktiviert.</p>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-[#020617]">
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Impressum</Link>
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="https://caspos.de/impressum/" target="_blank" rel="noopener noreferrer">Impressum</Link>
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer">Datenschutz</Link>
</nav>
</footer>
</div>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
@@ -39,12 +40,13 @@ import {
} from "@/components/ui/select"
const moduleSchema = z.object({
id: z.string().optional(),
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
description: z.string().optional(),
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
is_required: z.boolean().default(false),
requirements: z.string().optional().default(''), // Will be converted to array
exclusions: z.string().optional().default(''), // Will be converted to array
requirements: z.array(z.string()).default([]),
exclusions: z.array(z.string()).default([]),
})
const productSchema = z.object({
@@ -60,6 +62,7 @@ type ProductFormValues = z.infer<typeof productSchema>
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
const [open, setOpen] = useState(false)
const router = useRouter()
const form = useForm<ProductFormValues>({
resolver: zodResolver(productSchema) as any,
@@ -70,12 +73,13 @@ export function CreateProductDialog({ children, categories, product }: { childre
category_id: product?.category_id || '',
billing_interval: product?.billing_interval || 'monthly',
modules: product?.modules?.map(m => ({
id: m.id,
name: m.name,
description: m.description || '',
price: m.price,
is_required: false,
requirements: m.requirements?.join(', ') || '',
exclusions: m.exclusions?.join(', ') || '',
requirements: m.requirements || [],
exclusions: m.exclusions || [],
})) || [],
},
})
@@ -90,8 +94,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
const { modules, ...productData } = values
const formattedModules = modules.map(m => ({
...m,
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
id: m.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15)),
}))
if (product) {
@@ -102,6 +105,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
setOpen(false)
if (!product) form.reset()
router.refresh()
} catch (error) {
console.error('Error saving product:', error)
}
@@ -116,12 +120,12 @@ export function CreateProductDialog({ children, categories, product }: { childre
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col glass-dark border-white/10 text-white shadow-2xl">
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
</DialogTitle>
<DialogDescription className="text-slate-300">
<DialogDescription className="text-slate-500 dark:text-slate-400">
Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu.
</DialogDescription>
</DialogHeader>
@@ -136,9 +140,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Produktname</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
<FormControl>
<Input placeholder="z.B. Basic ERP" className="bg-white/5 border-white/10 text-white" {...field} />
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -149,9 +153,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="base_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Basispreis ()</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Basispreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/5 border-white/10 text-white" {...field} />
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -164,16 +168,16 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Kategorie</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Kategorie wählen" className="text-white" />
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id} className="text-white">{cat.name}</SelectItem>
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
@@ -187,7 +191,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="billing_interval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Abrechnungsmodell</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-1">
{[
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
@@ -199,8 +203,8 @@ export function CreateProductDialog({ children, categories, product }: { childre
onClick={() => field.onChange(opt.value)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
field.value === opt.value
? 'border-primary bg-primary/10 text-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:bg-white/10'
? 'border-primary bg-primary/10 text-primary dark:text-white'
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
}`}
>
<span className="text-lg">{opt.icon}</span>
@@ -218,16 +222,16 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Beschreibung</FormLabel>
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-white/5 border-white/10 text-white placeholder:text-slate-400" {...field} />
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator className="bg-white/10" />
<Separator className="bg-slate-200 dark:bg-white/10" />
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -240,106 +244,187 @@ export function CreateProductDialog({ children, categories, product }: { childre
variant="outline"
size="sm"
className="border-primary/50 text-primary hover:bg-primary/10"
onClick={() => append({ name: '', price: 0, is_required: false, description: '', requirements: '', exclusions: '' })}
onClick={() => append({
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
name: '',
price: 0,
is_required: false,
description: '',
requirements: [],
exclusions: []
})}
>
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
</Button>
</div>
{fields.map((field, index) => (
<div key={field.id} className="p-4 rounded-lg bg-white/5 border border-white/10 space-y-4 relative group">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => remove(index)}
>
<X className="h-4 w-4" />
</Button>
{fields.map((field, index) => {
const allModules = form.watch('modules') || []
const otherModules = allModules
.map((m, idx) => ({
id: m.id || field.id,
name: m.name || `Modul ${idx + 1}`,
}))
.filter((_, idx) => idx !== index)
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Modulname</FormLabel>
<FormControl>
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Aufpreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/10 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
return (
<div key={field.id} className="p-4 rounded-lg bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 space-y-4 relative group">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => remove(index)}
>
<X className="h-4 w-4" />
</Button>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.requirements`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.exclusions`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Modulname</FormLabel>
<FormControl>
<Input placeholder="z.B. Cloud Storage" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Aufpreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex items-center space-x-2">
<FormField
control={form.control}
name={`modules.${index}.is_required`}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-white">Erforderlich</FormLabel>
<div className="grid grid-cols-2 gap-4 border border-slate-200 dark:border-white/10 rounded-lg p-3 bg-slate-100 dark:bg-black/20">
{/* Requirements Selection Matrix */}
<FormField
control={form.control}
name={`modules.${index}.requirements`}
render={({ field: reqField }) => (
<div className="space-y-2">
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Benötigt Module</FormLabel>
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
{otherModules.length === 0 ? (
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
) : (
<div className="space-y-1.5">
{otherModules.map(other => {
const checked = (reqField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`req-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = reqField.value || []
if (checkedState) {
reqField.onChange([...currentVal, other.id])
} else {
reqField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`req-${index}-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
</div>
</FormItem>
)}
/>
)}
/>
{/* Exclusions Selection Matrix */}
<FormField
control={form.control}
name={`modules.${index}.exclusions`}
render={({ field: exclField }) => (
<div className="space-y-2">
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Schließt aus</FormLabel>
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
{otherModules.length === 0 ? (
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
) : (
<div className="space-y-1.5">
{otherModules.map(other => {
const checked = (exclField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`excl-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = exclField.value || []
if (checkedState) {
exclField.onChange([...currentVal, other.id])
} else {
exclField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`excl-${index}-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
title={other.name}
>
{other.name}
</label>
</div>
)
})}
</div>
)}
</ScrollArea>
</div>
)}
/>
</div>
<div className="flex items-center space-x-2">
<FormField
control={form.control}
name={`modules.${index}.is_required`}
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">Erforderlich</FormLabel>
</div>
</FormItem>
)}
/>
</div>
</div>
</div>
))}
)
})}
{fields.length === 0 && (
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">
<div className="text-center py-6 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-lg text-slate-500 dark:text-slate-400 text-sm">
Noch keine Module hinzugefügt.
</div>
)}
@@ -347,9 +432,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
</div>
</ScrollArea>
<DialogFooter className="pt-4 border-t border-white/10">
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button type="submit" className="bg-primary hover:bg-primary/90">Speichern</Button>
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10">
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">Abbrechen</Button>
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">Speichern</Button>
</DialogFooter>
</form>
</Form>

View File

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

View File

@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Download, ExternalLink, Search } from "lucide-react";
interface OrdersTableProps {
initialOrders: any[];
}
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
};
const statusClass: Record<string, string> = {
pending: 'bg-amber-500/20 text-amber-400 border border-amber-500/30',
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
};
export function OrdersTable({ initialOrders }: OrdersTableProps) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredOrders = initialOrders.filter((order) => {
const orderNum = (order.order_number || "").toLowerCase();
const company = (order.customer_data?.company_name || "").toLowerCase();
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
const matchesSearch =
orderNum.includes(search.toLowerCase()) ||
company.includes(search.toLowerCase()) ||
contact.includes(search.toLowerCase());
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
return matchesSearch && matchesStatus;
});
return (
<div className="space-y-4">
{/* Filterleiste */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
<div className="relative w-full md:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Bestellung, Firma oder Name suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("all")}
className="text-xs"
>
Alle
</Button>
<Button
variant={statusFilter === "pending" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("pending")}
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
>
Eingegangen
</Button>
<Button
variant={statusFilter === "active" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("active")}
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
>
In Bearbeitung
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("completed")}
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
>
Abgeschlossen
</Button>
<Button
variant={statusFilter === "cancelled" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("cancelled")}
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
>
Storniert
</Button>
</div>
</div>
{/* Tabelle */}
<div className="rounded-xl border border-white/5 bg-black/20 overflow-hidden backdrop-blur-xl">
<Table>
<TableHeader>
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
<TableHead className="text-slate-200">Datum</TableHead>
<TableHead className="text-slate-200">Kunde</TableHead>
<TableHead className="text-slate-200">Auswahl</TableHead>
<TableHead className="text-slate-200">Betrag</TableHead>
<TableHead className="text-slate-200">Status</TableHead>
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
Keine Bestellungen gefunden.
</TableCell>
</TableRow>
) : (
filteredOrders.map((order) => {
const items = order.order_data?.items || [];
return (
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-mono text-xs text-primary font-semibold">
#{order.order_number || order.id.slice(0, 8)}
</TableCell>
<TableCell className="text-slate-300 text-sm">
{new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
})}
</TableCell>
<TableCell>
<div className="font-semibold text-white text-sm">
{order.customer_data?.company_name || 'Privatkunde'}
</div>
<div className="text-xs text-slate-400">
{order.customer_data?.first_name} {order.customer_data?.last_name}
</div>
</TableCell>
<TableCell className="max-w-[200px]">
<div className="flex flex-wrap gap-1">
{items.map((item: any) => (
<span
key={item.product_id}
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
>
{item.product_name}
{item.selected_modules?.length > 0 && (
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
)}
</span>
))}
</div>
</TableCell>
<TableCell className="font-bold text-white text-sm">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
</TableCell>
<TableCell>
<Badge className={`text-xs px-2 py-0.5 rounded ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
{statusLabel[order.status] ?? order.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{order.pdf_url ? (
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF
</a>
</Button>
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
<a href={order.pdf_url} download>
<Download className="w-3.5 h-3.5" />
</a>
</Button>
</div>
) : (
<span className="text-xs text-slate-500 italic">Keine Rechnung</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -14,12 +14,16 @@ import { Badge } from '@/components/ui/badge'
import { Edit2, Trash2, Layers } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { deleteProduct } from '@/lib/actions/products'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { CreateProductDialog } from './create-product-dialog'
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
const [products, setProducts] = useState(initialProducts)
useEffect(() => {
setProducts(initialProducts)
}, [initialProducts])
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
await deleteProduct(id)
@@ -28,28 +32,28 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
}
return (
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
<CardHeader>
<CardTitle>Produktübersicht</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-slate-500 dark:text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-white font-bold">Name</TableHead>
<TableHead className="text-white font-bold">Basispreis</TableHead>
<TableHead className="text-white font-bold">Module</TableHead>
<TableHead className="text-white font-bold">Status</TableHead>
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
<TableHead className="text-slate-700 dark:text-white font-bold">Status</TableHead>
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-medium text-white">
<TableRow key={product.id} className="border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
<TableCell className="font-medium text-slate-900 dark:text-white">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{product.name}
@@ -59,20 +63,20 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
</Badge>
)}
{product.billing_interval === 'monthly' && (
<Badge className="text-[10px] bg-blue-500/20 text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
<Badge className="text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
)}
{product.billing_interval === 'one_time' && (
<Badge className="text-[10px] bg-slate-500/20 text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
<Badge className="text-[10px] bg-slate-500/20 text-slate-600 dark:text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
)}
</div>
<p className="text-xs text-slate-300 font-normal">{product.description}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 font-normal">{product.description}</p>
</div>
</TableCell>
<TableCell className="text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell className="text-slate-900 dark:text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Layers className="w-3 h-3 text-primary" />
<span className="text-slate-200">{product.modules?.length || 0} Module</span>
<span className="text-slate-700 dark:text-slate-200">{product.modules?.length || 0} Module</span>
</div>
</TableCell>
<TableCell>
@@ -83,7 +87,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<CreateProductDialog categories={categories} product={product}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
<Edit2 className="h-4 w-4" />
</Button>
</CreateProductDialog>
@@ -101,7 +105,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
))}
{products.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-slate-400">
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
</TableCell>
</TableRow>

View File

@@ -40,7 +40,8 @@ export function AdminRecentOrders() {
const fetchOrders = async () => {
try {
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/admin/recent-orders`, { cache: 'no-store' });
if (!res.ok) return
const { orders: fresh } = await res.json()

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { resetPassword } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -26,16 +26,11 @@ export function ForgotPasswordForm({
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
const supabase = createClient();
setIsLoading(true);
setError(null);
try {
// The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/update-password`,
});
if (error) throw error;
await resetPassword(email);
setSuccess(true);
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");

View File

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

View File

@@ -94,75 +94,103 @@ const styles = StyleSheet.create({
},
});
export const InvoicePDF = ({ order, product, modules, customer }: any) => (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
<Text>Software Solutions</Text>
</View>
<View style={styles.companyInfo}>
<Text>CASPOS GmbH</Text>
<Text>Musterstraße 1</Text>
<Text>12345 Musterstadt</Text>
<Text>Deutschland</Text>
</View>
</View>
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
const items = orderSnapshot?.items ?? [];
const subtotal = orderSnapshot?.subtotal ?? 0;
const taxAmount = orderSnapshot?.tax_amount ?? 0;
const taxRate = orderSnapshot?.tax_rate ?? 19;
const total = orderSnapshot?.total ?? 0;
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
<View style={styles.section}>
<Text style={styles.label}>Rechnungsempfänger</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.id}</Text>
<Text>Datum: {new Date().toLocaleDateString('de-DE')}</Text>
</View>
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (mtl.)</Text></View>
</View>
<View style={styles.tableRow}>
<View style={styles.tableCol}><Text>{product.name} (Basispaket)</Text></View>
<View style={styles.tableColPrice}><Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</Text></View>
</View>
{modules.map((mod: any) => (
<View key={mod.id} style={styles.tableRow}>
<View style={styles.tableCol}><Text>{mod.name}</Text></View>
<View style={styles.tableColPrice}><Text>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.additional_price)}</Text></View>
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
<Text>Software Solutions</Text>
</View>
))}
</View>
<View style={styles.total}>
<View style={{ width: '40%' }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text>Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 }}>
<Text style={styles.totalLabel}>Gesamtbetrag:</Text>
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
<View style={styles.companyInfo}>
<Text>CASPOS GmbH</Text>
<Text>Musterstraße 1</Text>
<Text>12345 Musterstadt</Text>
<Text>Deutschland</Text>
</View>
</View>
</View>
<View style={styles.footer}>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
</View>
</Page>
</Document>
);
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
<View style={styles.section}>
<Text style={styles.label}>Rechnungsempfänger</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.order_number || order.id}</Text>
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
</View>
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
</View>
{items.map((item: any, idx: number) => (
<View key={idx}>
<View style={styles.tableRow}>
<View style={styles.tableCol}>
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
</View>
<View style={styles.tableColPrice}>
<Text>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</Text>
</View>
</View>
{item.selected_modules?.map((mod: any, mIdx: number) => (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>+ {mod.module_name}</Text>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
</Text>
</View>
</View>
))}
</View>
))}
</View>
<View style={styles.total}>
<View style={{ width: '50%' }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(subtotal)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(taxAmount)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
<Text style={styles.totalLabel}>Gesamtbetrag (brutto):</Text>
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(total)}</Text>
</View>
</View>
</View>
<View style={styles.footer}>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
</View>
</Page>
</Document>
);
};

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { signIn } from "@/lib/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useState, useEffect } from "react";
export function LoginForm({
className,
@@ -24,22 +24,36 @@ export function LoginForm({
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [messageParam, setMessageParam] = useState<string | null>(null);
const [errorParam, setErrorParam] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setMessageParam(params.get('message'));
setErrorParam(params.get('error'));
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const supabase = createClient();
setIsLoading(true);
setError(null);
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
// Update this route to redirect to an authenticated route. The user already has an active session.
router.push("/protected");
const res = await signIn(email, password);
if (!res.success) {
setError(res.error || "An error occurred");
return;
}
if (nextParam) {
router.push(nextParam);
} else if (res.role === "admin") {
router.push("/admin/einstellungen");
} else {
router.push("/my-customers");
}
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "An error occurred");
} finally {
@@ -88,21 +102,22 @@ export function LoginForm({
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Login"}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<Link
href="/auth/sign-up"
className="underline underline-offset-4"
>
Sign up
</Link>
</div>
</form>
{errorParam === "gesperrt" && (
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
</p>
)}
{messageParam === "concurrent" && (
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
</p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Login"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>

View File

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

View File

@@ -48,12 +48,18 @@ function toggleModuleInList(
): string[] {
const isSelected = currentIds.includes(toggleId)
if (isSelected) {
const next = currentIds.filter(id => id !== toggleId)
// cascade: remove modules that required this one
return next.filter(mId => {
const m = modules.find(mod => mod.id === mId)
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId)
}).filter(id => id !== toggleId)
// Recursive removal: repeatedly filter until no more dependent modules are removed
let next = currentIds.filter(id => id !== toggleId)
let changed = true
while (changed) {
const beforeLength = next.length
next = next.filter(mId => {
const m = modules.find(mod => mod.id === mId)
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId))
})
changed = next.length !== beforeLength
}
return next
} else {
const module = modules.find(m => m.id === toggleId)
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
@@ -118,20 +124,29 @@ export function OrderWizard({
)
// Total price across all selections
const totalPrice = useMemo(() => {
let total = 0
// Total price calculations (split by billing interval)
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
let monthly = 0
let oneTime = 0
for (const cat of categories) {
const sel = selections[cat.id]
if (!sel?.productId) continue
const prod = products.find(p => p.id === sel.productId)
if (!prod) continue
total += Number(prod.base_price)
const base = Number(prod.base_price)
let modulesSum = 0
sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId)
if (mod) total += Number(mod.price)
if (mod) modulesSum += Number(mod.price)
})
const totalItem = base + modulesSum
if (prod.billing_interval === 'one_time') {
oneTime += totalItem
} else {
monthly += totalItem
}
}
return total
return { monthlyTotal: monthly, oneTimeTotal: oneTime }
}, [selections, products, categories])
// Helper: update product selection for a category (resets modules)
@@ -425,12 +440,38 @@ export function OrderWizard({
)
})}
<Separator className="bg-white/10" />
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamt:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}
</span>
</div>
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-2">
<div className="flex justify-between text-base font-semibold text-white">
<span className="text-slate-400">Einmalig gesamt:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
<div className="flex justify-between text-base font-semibold text-white">
<span className="text-slate-400">Monatlich gesamt:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-xs font-normal text-slate-400">/ Monat</span>
</span>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamt (einmalig):</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
) : (
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamt:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
</span>
</div>
)}
{!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
@@ -669,15 +710,43 @@ export function OrderWizard({
)}
</div>
</div>
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat
</span>
</div>
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-2 border-t border-white/10 pt-4">
<div className="flex justify-between text-lg font-bold text-white">
<span className="text-slate-400">Einmaliger Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
<div className="flex justify-between text-lg font-bold text-white">
<span className="text-slate-400">Monatlicher Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
</span>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
<span>Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
</span>
</div>
) : (
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
<span>Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
</span>
</div>
)}
<p className="text-xs text-slate-500 italic">
Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
Datenschutzerklärung.
Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
<a href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
Datenschutzerklärung
</a>.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4">

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,49 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { verifyAdmin } from '@/lib/actions/auth'
export async function getDatabaseIntegrity() {
try {
await verifyAdmin()
const supabase = await createClient()
const { data, error } = await supabase.rpc('check_database_integrity')
if (error) {
return { success: false, error: error.message }
}
return { success: true, data }
} catch (err: any) {
console.error("Error in getDatabaseIntegrity:", err)
return { success: false, error: err.message || "Unerwarteter Fehler beim Integritätsscan." }
}
}
export async function repairDatabaseSchema() {
try {
await verifyAdmin()
const supabase = await createClient()
const { data, error } = await supabase.rpc('repair_database_schema')
if (error) {
return { success: false, error: error.message }
}
return { success: true, data }
} catch (err: any) {
console.error("Error in repairDatabaseSchema:", err)
return { success: false, error: err.message || "Unerwarteter Fehler bei der Schemareparatur." }
}
}
export async function optimizeDatabaseIndices() {
try {
await verifyAdmin()
const supabase = await createClient()
const { data, error } = await supabase.rpc('optimize_database_indices')
if (error) {
return { success: false, error: error.message }
}
return { success: true, data }
} catch (err: any) {
console.error("Error in optimizeDatabaseIndices:", err)
return { success: false, error: err.message || "Unerwarteter Fehler bei der Indexoptimierung." }
}
}

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

@@ -0,0 +1,120 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { headers } from 'next/headers'
export async function signIn(email: string, password: string) {
try {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
return { success: false, error: error.message }
}
const userId = data.user?.id
if (userId) {
let { data: userData, error: userError } = await supabase
.from('users')
.select('role')
.eq('id', userId)
.single()
if (userError || !userData) {
// Fallback: create database records if missing for this authenticated user
try {
const adminSupabase = createAdminClient()
// Ensure profile exists
await adminSupabase.from('profiles').insert([{ id: userId }]).select()
// Ensure user exists
const { data: insertedUser, error: insertError } = await adminSupabase
.from('users')
.insert([{ id: userId, role: 'partner' }])
.select('role')
.single()
if (!insertError && insertedUser) {
userData = insertedUser
userError = null
}
} catch (adminErr) {
console.error("Failed to backfill user records:", adminErr)
}
}
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin')) {
await supabase.auth.signOut()
if (userData?.role === 'gesperrt') {
return { success: false, error: "Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator." }
}
return { success: false, error: "Zugriff verweigert. Nur registrierte Partner dürfen sich anmelden." }
}
// Andere aktive Sitzungen beenden
await supabase.auth.signOut({ scope: 'others' })
return { success: true, role: userData.role }
}
return { success: true, role: 'partner' }
} catch (err: any) {
console.error("SignIn error:", err)
return { success: false, error: err?.message || "Ein unerwarteter Fehler ist aufgetreten." }
}
}
export async function signUp(email: string, password: string) {
throw new Error("Registrierung ist deaktiviert.")
}
export async function signOut() {
const supabase = await createClient()
const { error } = await supabase.auth.signOut()
if (error) {
throw new Error(error.message)
}
return { success: true }
}
export async function resetPassword(email: string) {
const supabase = await createClient()
const origin = (await headers()).get('origin') || ''
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/update-password`,
})
if (error) {
throw new Error(error.message)
}
return { success: true }
}
export async function updatePassword(password: string) {
const supabase = await createClient()
const { error } = await supabase.auth.updateUser({ password })
if (error) {
throw new Error(error.message)
}
return { success: true }
}
export async function verifyAdmin() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error("Nicht authentifiziert.")
const { data: userData, error } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (error || !userData || userData.role !== 'admin') {
throw new Error("Zugriff verweigert. Nur Administratoren erlaubt.")
}
return user
}

View File

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

View File

@@ -1,6 +1,7 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
import type { EndCustomer } from '@/lib/types'
@@ -143,3 +144,15 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
revalidatePath('/my-customers')
revalidatePath(`/my-customers/${id}`)
}
export async function getPartnerEndCustomers(partnerId: string): Promise<EndCustomer[]> {
const admin = createAdminClient()
const { data, error } = await admin
.from('end_customers')
.select('*')
.eq('partner_id', partnerId)
.order('company_name', { ascending: true })
if (error) throw error
return data as EndCustomer[]
}

View File

@@ -7,6 +7,7 @@ import { renderToBuffer } from '@react-pdf/renderer'
import React from 'react'
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
import { getProducts, getCategories } from '@/lib/actions/products'
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
@@ -48,22 +49,62 @@ function hashOrderSnapshot(snapshot: object): string {
*/
export async function submitOrder(params: {
selections: WizardSelections
products: Product[]
categories: Category[]
products?: Product[]
categories?: Category[]
customerProfile: Partial<Profile>
endCustomerId?: string | null
endCustomer?: EndCustomer | null
}): Promise<Order> {
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params
const { selections, customerProfile, endCustomerId, endCustomer } = params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// Fetch catalog directly from DB to prevent client tampering
const dbProducts = await getProducts()
const dbCategories = await getCategories()
// ─── Constraint Validation ──────────────────────────────────────────────────
for (const cat of dbCategories) {
const sel = selections[cat.id]
if (cat.is_required && (!sel || !sel.productId)) {
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
}
if (sel?.productId) {
const prod = dbProducts.find(p => p.id === sel.productId)
if (!prod) {
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
}
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) {
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
}
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const missing = mod.requirements.filter(reqId => !sel.moduleIds.includes(reqId))
if (missing.length > 0) {
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung anderer Module voraus.`)
}
}
// Exclusions check
if (mod.exclusions && mod.exclusions.length > 0) {
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
if (conflicting.length > 0) {
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
}
}
}
}
}
// 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories)
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
const orderHash = hashOrderSnapshot(orderSnapshot)
@@ -144,7 +185,7 @@ export async function submitOrder(params: {
console.error('PDF Generation Error:', pdfError)
}
revalidatePath('/protected')
revalidatePath('/my-customers')
revalidatePath('/my-orders')
return order as Order
}

View File

@@ -3,6 +3,7 @@
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { Category, Product, ProductModule } from '../types'
import { randomUUID } from 'crypto'
export async function getProducts() {
const supabase = await createClient()
@@ -64,7 +65,10 @@ export async function deleteCategory(id: string) {
revalidatePath('/order')
}
export async function createProduct(product: Omit<Product, 'id' | 'created_at' | 'updated_at'>, modules: Omit<ProductModule, 'id' | 'product_id' | 'created_at'>[]) {
export async function createProduct(
product: Omit<Product, 'id' | 'created_at' | 'updated_at'>,
modules: Omit<ProductModule, 'product_id' | 'created_at'>[]
) {
const supabase = await createClient()
// Start a transaction-like approach (Supabase doesn't have cross-table transactions easily in one call via JS client without RPC)
@@ -77,7 +81,15 @@ export async function createProduct(product: Omit<Product, 'id' | 'created_at' |
if (productError) throw productError
if (modules.length > 0) {
const modulesWithId = modules.map(m => ({ ...m, product_id: newProduct.id }))
const modulesWithId = modules.map(m => ({
id: m.id || randomUUID(),
product_id: newProduct.id,
name: m.name,
description: m.description || null,
price: m.price,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
}))
const { error: modulesError } = await supabase
.from('product_modules')
.insert(modulesWithId)
@@ -106,12 +118,13 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
if (modules.length > 0) {
const modulesWithId = modules.map(m => ({
id: m.id || randomUUID(),
product_id: id,
name: m.name,
description: m.description || null,
price: m.price,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
product_id: id
}))
const { error: modulesError } = await supabase
.from('product_modules')
@@ -120,6 +133,7 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
if (modulesError) throw modulesError
}
revalidatePath('/admin/products')
revalidatePath('/order')
}

View File

@@ -1,50 +1,233 @@
'use server'
'use server';
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
import { createAdminClient } from '@/lib/supabase/admin';
import { revalidatePath } from 'next/cache';
import { sendMail } from '@/utils/mail';
export async function getUsers() {
const admin = createAdminClient()
const { data: { users }, error } = await admin.auth.admin.listUsers()
if (error) throw error
return users
const admin = createAdminClient();
const { data: { users }, error } = await admin.auth.admin.listUsers();
if (error) throw error;
const { data: dbUsers, error: dbError } = await admin
.from('users')
.select('id, role, company_id, companies(id, name)');
const { data: dbProfiles, error: profilesError } = await admin
.from('profiles')
.select('id, first_name, last_name');
const userMap: Record<string, { role: string; company_id: string | null; company_name: string | null }> = {};
if (!dbError && dbUsers) {
dbUsers.forEach((u: any) => {
userMap[u.id] = {
role: u.role,
company_id: u.company_id || null,
company_name: Array.isArray(u.companies) ? u.companies[0]?.name || null : u.companies?.name || null,
};
});
}
const profileMap: Record<string, { first_name: string | null; last_name: string | null }> = {};
if (!profilesError && dbProfiles) {
dbProfiles.forEach((p: any) => {
profileMap[p.id] = {
first_name: p.first_name || null,
last_name: p.last_name || null,
};
});
}
return users.map((u) => ({
...u,
role: userMap[u.id]?.role || 'partner',
company_id: userMap[u.id]?.company_id || null,
company_name: userMap[u.id]?.company_name || null,
first_name: profileMap[u.id]?.first_name || null,
last_name: profileMap[u.id]?.last_name || null,
}));
}
export async function createUser(data: { email: string; password?: string; email_confirm?: boolean }) {
const admin = createAdminClient()
export async function createUser(data: { email: string; role?: 'admin' | 'partner'; email_confirm?: boolean; company_id?: string; first_name?: string; last_name?: string }) {
const admin = createAdminClient();
const { data: { user }, error } = await admin.auth.admin.createUser({
email: data.email,
password: data.password || Math.random().toString(36).slice(-10),
password: Math.random().toString(36).slice(-10) + Math.random().toString(36).slice(-10),
email_confirm: data.email_confirm ?? true,
})
if (error) throw error
revalidatePath('/admin/users')
return user
});
if (error) throw error;
if (!user) throw new Error('Benutzer konnte nicht erstellt werden.');
const role = data.role || 'partner';
const { error: dbError } = await admin
.from('users')
.upsert({ id: user.id, role, company_id: data.company_id || null }, { onConflict: 'id' });
if (dbError) throw dbError;
const { error: profileError } = await admin
.from('profiles')
.upsert({
id: user.id,
first_name: data.first_name || null,
last_name: data.last_name || null,
}, { onConflict: 'id' });
if (profileError) throw profileError;
// Generate recovery link to let user set password
try {
const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({
type: 'recovery',
email: data.email,
});
if (!linkError && linkData) {
const tokenHash = (linkData as any)?.properties?.hashed_token;
if (tokenHash) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de';
const setupLink = `${siteUrl}/auth/confirm?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`;
await sendMail({
to: data.email,
subject: 'Willkommen bei CASPOS Shop Ihr Benutzerkonto wurde erstellt',
text: `Hallo,\n\nein neues Benutzerkonto wurde für Sie auf CASPOS Shop erstellt.\n\nBitte klicken Sie auf den folgenden Link, um Ihr persönliches Passwort festzulegen und Ihr Konto zu aktivieren:\n${setupLink}\n\nDieser Link ist zeitlich begrenzt gültig.\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">Willkommen bei CASPOS Shop!</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">für Sie wurde erfolgreich ein neues Benutzerkonto auf unserer Plattform eingerichtet.</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Bitte klicken Sie auf den untenstehenden Button, um Ihr Passwort festzulegen und Ihr Konto zu aktivieren:</p>
<div style="margin: 24px 0;">
<a href="${setupLink}" style="background-color: #3b82f6; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Passwort jetzt festlegen</a>
</div>
<p style="color: #64748b; font-size: 14px; line-height: 1.5;">Falls der Button nicht funktioniert, können Sie auch folgenden Link in Ihren Browser kopieren:<br>
<a href="${setupLink}" style="color: #3b82f6;">${setupLink}</a></p>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 12px;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
</div>
`,
});
}
}
} catch (mailError) {
console.error('Failed to send welcome setup mail:', mailError);
}
revalidatePath('/admin/users');
return user;
}
export async function deleteUser(id: string) {
const admin = createAdminClient()
const { error } = await admin.auth.admin.deleteUser(id)
if (error) throw error
revalidatePath('/admin/users')
try {
const admin = createAdminClient();
// Check if user has orders (documents/belege)
const { count, error: countError } = await admin
.from('orders')
.select('*', { count: 'exact', head: true })
.eq('user_id', id);
if (countError) {
console.error('Failed to count user orders:', countError);
}
if (count && count > 0) {
throw new Error('Auf diesen User gibt es bereits Belege. Sperren möglich.');
}
// Delete from public.users first to satisfy foreign key constraints
const { error: dbError } = await admin.from('users').delete().eq('id', id);
if (dbError) {
console.error('Database delete user error:', dbError);
throw new Error(`Datenbank-Fehler beim Löschen des Benutzers: ${dbError.message}`);
}
const { error } = await admin.auth.admin.deleteUser(id);
if (error) {
console.error('Auth delete user error:', error);
throw new Error(`Auth-Fehler beim Löschen des Benutzers: ${error.message}`);
}
revalidatePath('/admin/users');
} catch (err: any) {
console.error('deleteUser Server Action failed:', err);
throw new Error(err.message || 'Benutzer konnte nicht gelöscht werden.');
}
}
export async function updateUserRole(id: string, role: 'admin' | 'partner' | 'gesperrt') {
const admin = createAdminClient();
const { error } = await admin
.from('users')
.upsert({ id, role }, { onConflict: 'id' });
if (error) throw error;
revalidatePath('/admin/users');
}
export async function updateUserStatus(id: string, ban: boolean) {
const admin = createAdminClient()
const admin = createAdminClient();
const { error } = await admin.auth.admin.updateUserById(id, {
ban_duration: ban ? '876000h' : '0h', // 100 years or none
})
if (error) throw error
revalidatePath('/admin/users')
ban_duration: ban ? '876000h' : '0h',
});
if (error) throw error;
revalidatePath('/admin/users');
}
export async function resetPassword(email: string) {
const admin = createAdminClient()
// Generate a reset link or send email
const { error } = await admin.auth.admin.generateLink({
const admin = createAdminClient();
const { data, error } = await admin.auth.admin.generateLink({
type: 'recovery',
email,
})
if (error) throw error
// Usually you'd send this link or let Supabase handle it via regular auth.resetPasswordForEmail
});
if (error) throw error;
const linkData = data as any;
// Extract token_hash from the generated link properties
const tokenHash = linkData?.properties?.hashed_token;
if (!tokenHash) throw new Error('Recovery token not generated');
// Build link pointing to our own /auth/confirm route
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de';
const resetLink = `${siteUrl}/auth/confirm?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`;
await sendMail({
to: email,
subject: 'Passwort zurücksetzen',
text: `Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen: ${resetLink}`,
html: `<p>Klicken Sie <a href="${resetLink}">hier</a>, um Ihr Passwort zurückzusetzen.</p>`,
});
}
export async function getPartners() {
const admin = createAdminClient();
const { data: { users }, error } = await admin.auth.admin.listUsers();
if (error) throw error;
const { data: dbUsers, error: dbError } = await admin
.from('users')
.select('id, role, company_id, companies(id, name)')
.in('role', ['partner', 'gesperrt']);
if (dbError) throw dbError;
const userMap: Record<string, { role: string; company_id: string | null; company_name: string | null }> = {};
dbUsers.forEach((u: any) => {
userMap[u.id] = {
role: u.role,
company_id: u.company_id || null,
company_name: Array.isArray(u.companies) ? u.companies[0]?.name || null : u.companies?.name || null,
};
});
const partnerIds = new Set(dbUsers.map((u: any) => u.id));
return users
.filter((u) => partnerIds.has(u.id))
.map((u) => ({
...u,
role: userMap[u.id]?.role || 'partner',
company_id: userMap[u.id]?.company_id || null,
company_name: userMap[u.id]?.company_name || null,
}));
}
export async function updateUserProfile(id: string, data: { first_name: string | null; last_name: string | null }) {
const admin = createAdminClient();
const { error } = await admin
.from('profiles')
.upsert({ id, first_name: data.first_name, last_name: data.last_name }, { onConflict: 'id' });
if (error) throw error;
revalidatePath('/admin/users');
}

View File

@@ -2,9 +2,10 @@ import { createClient } from '@supabase/supabase-js'
export function createAdminClient() {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
supabaseUrl!,
serviceRoleKey,
{
auth: {

View File

@@ -1,8 +1,17 @@
import { createBrowserClient } from "@supabase/ssr";
import { resolveSupabaseUrl } from "../utils";
export function createClient() {
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;
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookieOptions: {
name: "webshop-auth-token",
},
}
);
}

View File

@@ -1,6 +1,6 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
import { hasEnvVars } from "../utils";
import { hasEnvVars, resolveSupabaseUrl } from "../utils";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
@@ -13,11 +13,12 @@ export async function updateSession(request: NextRequest) {
return supabaseResponse;
}
// With Fluid compute, don't put this client in a global environment
// variable. Always create a new one on each request.
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;
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookies: {
getAll() {
@@ -35,6 +36,9 @@ export async function updateSession(request: NextRequest) {
);
},
},
cookieOptions: {
name: "webshop-auth-token",
},
},
);
@@ -59,6 +63,49 @@ export async function updateSession(request: NextRequest) {
return NextResponse.redirect(url);
}
// Admin protection
if (request.nextUrl.pathname.startsWith("/admin")) {
if (!user || !user.sub) {
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
return NextResponse.redirect(url);
}
const { data: dbUser } = await supabase
.from("users")
.select("role")
.eq("id", user.sub)
.single();
if (!dbUser || dbUser.role !== "admin") {
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
}
// Gesperrte Benutzer sofort ausloggen
if (
user?.sub &&
!request.nextUrl.pathname.startsWith("/auth")
) {
const { data: dbUser } = await supabase
.from("users")
.select("role")
.eq("id", user.sub)
.single();
if (dbUser?.role === "gesperrt") {
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
url.searchParams.set("error", "gesperrt");
// Clear cookie so the session is fully gone
const response = NextResponse.redirect(url);
response.cookies.delete("webshop-auth-token");
return response;
}
}
// IMPORTANT: You *must* return the supabaseResponse object as it is.
// If you're creating a new response object with NextResponse.next() make sure to:
// 1. Pass the request in it, like so:

View File

@@ -1,5 +1,6 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { resolveSupabaseUrl } from "../utils";
/**
* Especially important if using Fluid compute: Don't put this client in a
@@ -9,9 +10,12 @@ import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
resolveSupabaseUrl(supabaseUrl)!,
supabaseAnonKey!,
{
cookies: {
getAll() {
@@ -29,6 +33,9 @@ export async function createClient() {
}
},
},
cookieOptions: {
name: "webshop-auth-token",
},
},
);
}

View File

@@ -7,5 +7,39 @@ export function cn(...inputs: ClassValue[]) {
// This check can be removed, it is just for tutorial purposes
export const hasEnvVars =
process.env.NEXT_PUBLIC_SUPABASE_URL &&
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL) &&
(process.env.SUPABASE_ANON_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
export function resolveSupabaseUrl(url: string | undefined): string | undefined {
if (!url) return url;
if (typeof window !== 'undefined') {
try {
const parsed = new URL(url);
if (parsed.hostname === 'supabase-kong' || parsed.hostname === 'kong') {
parsed.hostname = window.location.hostname;
parsed.protocol = window.location.protocol;
parsed.port = ''; // Port entfernen, da der öffentliche Zugriff über Port 80/443 läuft
return parsed.toString().replace(/\/$/, '');
}
// Mitigate Mixed Content: if the site is HTTPS, force Supabase to HTTPS
if (window.location.protocol === 'https:' && parsed.protocol === 'http:') {
parsed.protocol = 'https:';
return parsed.toString().replace(/\/$/, '');
}
} catch {
let resolved = url
.replace('//supabase-kong:8000', `//${window.location.hostname}`)
.replace('//kong:8000', `//${window.location.hostname}`)
.replace('//supabase-kong', `//${window.location.hostname}`)
.replace('//kong', `//${window.location.hostname}`);
if (window.location.protocol === 'https:') {
resolved = resolved.replace('http://', 'https://');
}
return resolved;
}
}
return url;
}

View File

@@ -1,7 +1,7 @@
import { updateSession } from "@/lib/supabase/proxy";
import { type NextRequest } from "next/server";
export async function proxy(request: NextRequest) {
export async function middleware(request: NextRequest) {
return await updateSession(request);
}

93
shop/migrate.js Normal file
View File

@@ -0,0 +1,93 @@
const { Client } = require('pg');
const fs = require('fs');
const path = require('path');
async function run() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.log('DATABASE_URL not set skipping migrations.');
return;
}
console.log('Starting database migrations...');
const client = new Client({
connectionString,
connectionTimeoutMillis: 10000,
});
await client.connect();
// Create schema_migrations table if not exists
await client.query(`
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version VARCHAR(255) PRIMARY KEY,
inserted_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
`);
const migrationsDir = path.join(__dirname, 'supabase', 'migrations');
if (!fs.existsSync(migrationsDir)) {
console.warn(`Migrations directory not found at: ${migrationsDir}`);
await client.end();
return;
}
const files = fs.readdirSync(migrationsDir).sort();
for (const file of files) {
if (!file.endsWith('.sql')) continue;
const { rows } = await client.query('SELECT 1 FROM public.schema_migrations WHERE version = $1', [file]);
if (rows.length === 0) {
console.log(`Running migration: ${file}`);
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
// We run the migration in a single transaction
await client.query('BEGIN');
try {
await client.query(sql);
await client.query('INSERT INTO public.schema_migrations (version) VALUES ($1)', [file]);
await client.query('COMMIT');
console.log(`Migration ${file} completed successfully.`);
} catch (err) {
await client.query('ROLLBACK');
console.error(`Migration ${file} failed, rolled back.`);
throw err;
}
} else {
console.log(`Skipping migration: ${file} (already run)`);
}
}
// Also check if we should run seed.sql (only if we run it for the first time or if products are empty)
const seedFile = path.join(__dirname, 'supabase', 'seed.sql');
if (fs.existsSync(seedFile)) {
try {
const { rows: productRows } = await client.query('SELECT 1 FROM public.products LIMIT 1');
if (productRows.length === 0) {
console.log('No products found. Seeding database with default products...');
const seedSql = fs.readFileSync(seedFile, 'utf8');
await client.query('BEGIN');
try {
await client.query(seedSql);
await client.query('COMMIT');
console.log('Database seeded successfully.');
} catch (err) {
await client.query('ROLLBACK');
console.error('Seeding failed, rolled back.', err);
}
}
} catch (e) {
// In case products table check fails (e.g. initial_schema wasn't run or failed)
console.error('Failed to run seed check/seed SQL', e);
}
}
await client.end();
console.log('Database migrations completed.');
}
run().catch(err => {
console.error('Migration execution failed:', err);
process.exit(1);
});

View File

@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: false,
};

167
shop/package-lock.json generated
View File

@@ -19,12 +19,15 @@
"@react-pdf/renderer": "^4.5.1",
"@supabase/ssr": "latest",
"@supabase/supabase-js": "latest",
"@types/nodemailer": "^8.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^0.511.0",
"next": "latest",
"next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
"pg": "^8.11.3",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -3174,6 +3177,15 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -6895,6 +6907,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -7166,6 +7187,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7407,6 +7517,45 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -8178,6 +8327,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -9101,6 +9259,15 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -21,12 +21,15 @@
"@react-pdf/renderer": "^4.5.1",
"@supabase/ssr": "latest",
"@supabase/supabase-js": "latest",
"@types/nodemailer": "^8.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^0.511.0",
"next": "latest",
"next-themes": "^0.4.6",
"nodemailer": "^9.0.1",
"pg": "^8.11.3",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -16,13 +16,4 @@ ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
-- Policies
CREATE POLICY "Allow public read access on categories" ON public.categories FOR SELECT USING (true);
-- Seed Categories
INSERT INTO public.categories (id, name, description, icon)
VALUES
('c1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'Cloud Software', 'Modulare Cloud-Lösungen für den modernen Handel.', 'Cloud'),
('c2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2', 'Gastronomie', 'Kassensysteme und Management für Restaurants und Cafés.', 'Utensils'),
('c3a3a3a3-a3a3-a3a3-a3a3-a3a3a3a3a3a3', 'Hardware', 'Kassenterminals, Drucker und Zubehör.', 'HardDrive');
-- Update existing products with categories
UPDATE public.products SET category_id = 'c1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1' WHERE name LIKE '%Cloud%';
UPDATE public.products SET category_id = 'c2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2' WHERE name LIKE '%Gastro%';

View File

@@ -0,0 +1,19 @@
-- Trigger, um Profile automatisch bei User-Erstellung in auth.users anzulegen
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id)
VALUES (new.id)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Profile für bereits existierende Benutzer anlegen (Backfill)
INSERT INTO public.profiles (id)
SELECT id FROM auth.users
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,17 @@
-- Migration: Add order_number and order_hash to orders table
ALTER TABLE public.orders
ADD COLUMN IF NOT EXISTS order_number TEXT,
ADD COLUMN IF NOT EXISTS order_hash TEXT;
-- Update existing orders to have a unique order_number
UPDATE public.orders
SET order_number = 'RE-' || EXTRACT(YEAR FROM created_at)::text || '-' || floor(10000 + random() * 90000)::text
WHERE order_number IS NULL;
-- Now add unique constraint and make it not null
ALTER TABLE public.orders
ALTER COLUMN order_number SET NOT NULL,
ADD CONSTRAINT orders_order_number_key UNIQUE (order_number);
-- Notify PostgREST to reload schema cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,61 @@
-- Migration: Create public.users and public.settings tables
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'partner',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Policies for public.users
DROP POLICY IF EXISTS "Users can view their own role" ON public.users;
DROP POLICY IF EXISTS "Admins can view all roles" ON public.users;
CREATE POLICY "Users can view their own role" ON public.users FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Admins can view all roles" ON public.users FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Enable all for service_role" ON public.users FOR ALL USING (true) WITH CHECK (true);
-- Create public.settings table
CREATE TABLE IF NOT EXISTS public.settings (
id TEXT PRIMARY KEY,
host TEXT,
port INTEGER,
secure BOOLEAN,
"user" TEXT,
pass TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.settings ENABLE ROW LEVEL SECURITY;
-- Policies for public.settings
DROP POLICY IF EXISTS "Allow authenticated read on settings" ON public.settings;
DROP POLICY IF EXISTS "Allow authenticated write on settings" ON public.settings;
CREATE POLICY "Allow authenticated read on settings" ON public.settings FOR SELECT USING (auth.role() = 'authenticated');
CREATE POLICY "Allow authenticated write on settings" ON public.settings FOR ALL USING (auth.role() = 'authenticated') WITH CHECK (auth.role() = 'authenticated');
-- Update user creation trigger function to also insert into public.users
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id)
VALUES (new.id)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.users (id, role)
VALUES (new.id, CASE WHEN new.email = 'info@hephex.de' THEN 'admin' ELSE 'partner' END)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Backfill public.users for existing auth.users
INSERT INTO public.users (id, role)
SELECT id, CASE WHEN email = 'info@hephex.de' THEN 'admin' ELSE 'partner' END
FROM auth.users
ON CONFLICT (id) DO NOTHING;
-- Reload Schema Cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,29 @@
-- Erstellt die Tabelle direkt mit allen Feldern neu
CREATE TABLE IF NOT EXISTS public.customers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
user_id UUID REFERENCES auth.users(id), -- Verknüpfung zu Supabase Auth
-- Neue Pflichtfelder für CASPOS Lizenzserver
company_name TEXT NOT NULL,
street TEXT NOT NULL,
tax_id TEXT NOT NULL,
contact_person TEXT NOT NULL,
email TEXT NOT NULL,
phone_no TEXT NOT NULL,
-- Optionales Feld
dongle_no TEXT
);
-- Zeilen-Sicherheit (RLS) direkt aktivieren
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
-- Policy: Nutzer darf nur eigene Kundendaten sehen
DROP POLICY IF EXISTS "Nutzer sehen nur eigene Daten" ON public.customers;
CREATE POLICY "Nutzer sehen nur eigene Daten"
ON public.customers
FOR ALL
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

View File

@@ -0,0 +1,195 @@
-- CREATE FUNCTION public.check_database_integrity
CREATE OR REPLACE FUNCTION public.check_database_integrity()
RETURNS jsonb
SECURITY DEFINER
AS $$
DECLARE
profiles_exist boolean;
end_customers_exist boolean;
pos_modules_exist boolean;
licenses_exist boolean;
profiles_count bigint := 0;
end_customers_count bigint := 0;
pos_modules_count bigint := 0;
licenses_count bigint := 0;
fk_errors_count bigint := 0;
result jsonb;
BEGIN
-- Check table existence
SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') INTO profiles_exist;
SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'end_customers') INTO end_customers_exist;
SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'pos_modules') INTO pos_modules_exist;
SELECT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'licenses') INTO licenses_exist;
-- Count rows if table exists
IF profiles_exist THEN
EXECUTE 'SELECT count(*) FROM public.profiles' INTO profiles_count;
END IF;
IF end_customers_exist THEN
EXECUTE 'SELECT count(*) FROM public.end_customers' INTO end_customers_count;
END IF;
IF pos_modules_exist THEN
EXECUTE 'SELECT count(*) FROM public.pos_modules' INTO pos_modules_count;
END IF;
IF licenses_exist THEN
EXECUTE 'SELECT count(*) FROM public.licenses' INTO licenses_count;
END IF;
-- Count foreign key errors (orphaned licenses)
IF licenses_exist AND end_customers_exist THEN
EXECUTE '
SELECT count(*)
FROM public.licenses l
LEFT JOIN public.end_customers c ON l.end_customer_id = c.id
WHERE c.id IS NULL AND l.end_customer_id IS NOT NULL
' INTO fk_errors_count;
END IF;
-- Build JSON result
result := jsonb_build_object(
'tables', jsonb_build_object(
'profiles', jsonb_build_object('exists', profiles_exist, 'count', profiles_count),
'end_customers', jsonb_build_object('exists', end_customers_exist, 'count', end_customers_count),
'pos_modules', jsonb_build_object('exists', pos_modules_exist, 'count', pos_modules_count),
'licenses', jsonb_build_object('exists', licenses_exist, 'count', licenses_count)
),
'errors', jsonb_build_object(
'foreign_keys', fk_errors_count
)
);
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- CREATE FUNCTION public.repair_database_schema
CREATE OR REPLACE FUNCTION public.repair_database_schema()
RETURNS jsonb
SECURITY DEFINER
AS $$
DECLARE
repaired_tables text[] := '{}';
result jsonb;
BEGIN
-- 1. profiles table (should exist, but check just in case)
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
first_name TEXT,
last_name TEXT,
company_name TEXT,
vat_id TEXT,
address TEXT,
city TEXT,
zip_code TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
repaired_tables := array_append(repaired_tables, 'profiles');
END IF;
-- 2. end_customers table
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'end_customers') THEN
CREATE TABLE public.end_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
vat_id TEXT,
first_name TEXT,
last_name TEXT,
street TEXT,
zip TEXT,
city TEXT,
is_anonymized BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
repaired_tables := array_append(repaired_tables, 'end_customers');
END IF;
-- 3. pos_modules table
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'pos_modules') THEN
CREATE TABLE public.pos_modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
repaired_tables := array_append(repaired_tables, 'pos_modules');
END IF;
-- 4. licenses table
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'licenses') THEN
CREATE TABLE public.licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
end_customer_id UUID REFERENCES public.end_customers(id) ON DELETE CASCADE,
pos_module_id UUID REFERENCES public.pos_modules(id) ON DELETE CASCADE,
license_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
repaired_tables := array_append(repaired_tables, 'licenses');
END IF;
-- Enable RLS on all tables
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.end_customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.pos_modules ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.licenses ENABLE ROW LEVEL SECURITY;
-- Recreate RLS Policies
-- profiles Policies
DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles;
CREATE POLICY "Users can view their own profile" ON public.profiles FOR SELECT USING (auth.uid() = id);
DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles;
CREATE POLICY "Users can update their own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id);
DROP POLICY IF EXISTS "Users can insert their own profile" ON public.profiles;
CREATE POLICY "Users can insert their own profile" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id);
DROP POLICY IF EXISTS "Admins can view all profiles" ON public.profiles;
CREATE POLICY "Admins can view all profiles" ON public.profiles FOR SELECT USING (auth.role() = 'authenticated');
-- end_customers Policies
DROP POLICY IF EXISTS "Partner sehen nur eigene Endkunden" ON public.end_customers;
CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers FOR ALL USING (auth.uid() = partner_id) WITH CHECK (auth.uid() = partner_id);
-- pos_modules Policies
DROP POLICY IF EXISTS "Allow authenticated read on pos_modules" ON public.pos_modules;
CREATE POLICY "Allow authenticated read on pos_modules" ON public.pos_modules FOR SELECT USING (auth.role() = 'authenticated');
-- licenses Policies
DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses;
CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.end_customers c
WHERE c.id = licenses.end_customer_id AND c.partner_id = auth.uid()
)
);
DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses;
CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM public.end_customers c
WHERE c.id = licenses.end_customer_id AND c.partner_id = auth.uid()
)
);
result := jsonb_build_object(
'repaired', to_jsonb(repaired_tables),
'success', true
);
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- CREATE FUNCTION public.optimize_database_indices
CREATE OR REPLACE FUNCTION public.optimize_database_indices()
RETURNS jsonb
SECURITY DEFINER
AS $$
BEGIN
REINDEX SCHEMA public;
RETURN jsonb_build_object('success', true, 'message', 'Schema public successfully reindexed.');
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,51 @@
-- Migration: companies and partners tables with relationships
-- 1. Create companies table
CREATE TABLE IF NOT EXISTS public.companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 2. Create partners table with optional company reference
CREATE TABLE IF NOT EXISTS public.partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
company_id UUID REFERENCES public.companies(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Enable RLS on both tables
ALTER TABLE public.companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.partners ENABLE ROW LEVEL SECURITY;
-- Policies for companies
DROP POLICY IF EXISTS "Admins und Unternehmen lesen" ON public.companies;
CREATE POLICY "Admins und Unternehmen lesen"
ON public.companies FOR SELECT
USING (auth.role() = 'authenticated');
DROP POLICY IF EXISTS "Service role kann alles Unternehmen" ON public.companies;
CREATE POLICY "Service role kann alles Unternehmen"
ON public.companies FOR ALL
USING (true)
WITH CHECK (true);
-- Policies for partners
DROP POLICY IF EXISTS "Admins und Partner lesen" ON public.partners;
CREATE POLICY "Admins und Partner lesen"
ON public.partners FOR SELECT
USING (auth.role() = 'authenticated');
DROP POLICY IF EXISTS "Service role kann alles Partner" ON public.partners;
CREATE POLICY "Service role kann alles Partner"
ON public.partners FOR ALL
USING (true)
WITH CHECK (true);
-- 3. Add company_id to users (instead of partner_id)
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS company_id UUID REFERENCES public.companies(id) ON DELETE SET NULL;
-- Reload schema cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,6 @@
-- Migration: Add delivery address columns to companies table
ALTER TABLE public.companies
ADD COLUMN IF NOT EXISTS street TEXT,
ADD COLUMN IF NOT EXISTS zip TEXT,
ADD COLUMN IF NOT EXISTS city TEXT,
ADD COLUMN IF NOT EXISTS email TEXT;

65
shop/utils/mail.ts Normal file
View File

@@ -0,0 +1,65 @@
// utils/mail.ts simple wrapper around nodemailer
import nodemailer from 'nodemailer';
import { createAdminClient } from '@/lib/supabase/admin';
/**
* Send an email using database-configured SMTP settings (or environment variable fallback).
* @param to Recipient address
* @param subject Subject line
* @param text Plaintext body
* @param html Optional HTML body
*/
export async function sendMail({
to,
subject,
text,
html,
}: {
to: string;
subject: string;
text: string;
html?: string;
}) {
// Query custom SMTP settings from database
const supabase = createAdminClient();
const { data: dbSettings } = await supabase
.from('settings')
.select('*')
.eq('id', 'smtp')
.maybeSingle();
// Resolve config from DB or environment variables
const host = dbSettings?.host || process.env.SMTP_HOST;
const port = dbSettings?.port ? Number(dbSettings.port) : Number(process.env.SMTP_PORT);
const secure = dbSettings
? !!dbSettings.secure
: (process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1');
const user = dbSettings?.user || process.env.SMTP_USER;
const pass = dbSettings?.pass || process.env.SMTP_PASS;
if (!host || !user) {
throw new Error('SMTP host and user must be configured (either in settings database table or environment variables).');
}
// Create transporter dynamically on send request
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: {
user,
pass,
},
});
const info = await transporter.sendMail({
from: user,
to,
subject,
text,
html,
});
return info;
}