Compare commits
150 Commits
develop
...
fc51d53aa3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc51d53aa3 | ||
|
|
4b07e68aa0 | ||
|
|
19b4ac2729 | ||
|
|
5b7ccfd8d9 | ||
|
|
fbb75567cf | ||
|
|
8f4f05a3cd | ||
|
|
bdc9e6541c | ||
|
|
e25344a185 | ||
|
|
ccdfa7402b | ||
|
|
fbab22506b | ||
|
|
c9ae185fdf | ||
|
|
da7bd91b6f | ||
|
|
45fb87d589 | ||
|
|
71c6ad3721 | ||
|
|
1a2ace937c | ||
|
|
16fb713665 | ||
|
|
52f2a78753 | ||
|
|
e93cf7805c | ||
|
|
08382ff4a1 | ||
|
|
f44cf1a5f6 | ||
|
|
d3df9998f0 | ||
|
|
47928fad3f | ||
|
|
5a292a8ddc | ||
|
|
9246fb7d77 | ||
|
|
9e189f331a | ||
|
|
fe05e13d3f | ||
|
|
fd7a53b954 | ||
|
|
563a483c8d | ||
|
|
4fa5f71d16 | ||
|
|
ab9f8f93be | ||
|
|
5608e9fcc6 | ||
|
|
25703d400b | ||
|
|
f9fb90848b | ||
|
|
eafd890297 | ||
|
|
af44f4b3be | ||
|
|
b2656345e8 | ||
|
|
7cad819df8 | ||
|
|
b537ca50cf | ||
|
|
b9e6559bff | ||
|
|
2f96552fe8 | ||
|
|
6a2fc16f75 | ||
|
|
0ff4df2a12 | ||
|
|
ef9422908d | ||
|
|
bf27c62f62 | ||
|
|
5a2edef0df | ||
|
|
a1ad75c2db | ||
|
|
85df922498 | ||
|
|
67a2c2a67b | ||
|
|
07c2758b73 | ||
|
|
30a9de0e6d | ||
|
|
24929494a6 | ||
|
|
86d71b2441 | ||
|
|
744302e8ab | ||
|
|
396f135e87 | ||
|
|
03d711c2e1 | ||
|
|
2de8913dac | ||
|
|
82e877249a | ||
|
|
872e973a36 | ||
|
|
4cd6d86f33 | ||
|
|
69c8e8e5c1 | ||
|
|
5652ce1098 | ||
|
|
cebf677758 | ||
|
|
410289a519 | ||
|
|
49607f484f | ||
|
|
2120698192 | ||
|
|
a9a5dd4e6f | ||
|
|
520599986c | ||
|
|
864bb3a0c1 | ||
|
|
82c89b70bf | ||
|
|
ac0b0f607d | ||
|
|
4e86ad7a9d | ||
|
|
7a14aa0f75 | ||
|
|
6b66cc5c41 | ||
|
|
cd04309e3e | ||
|
|
26de6820e3 | ||
|
|
9fbab07e1a | ||
|
|
fe1d15090a | ||
|
|
3c60889e2a | ||
|
|
6e5cee0180 | ||
|
|
f2db40fa36 | ||
|
|
0968f1a753 | ||
|
|
ac5bd766a4 | ||
|
|
5c8c198fc5 | ||
|
|
e1f47ddcd0 | ||
|
|
934879e5cf | ||
|
|
4a17ea378e | ||
|
|
e829006b77 | ||
|
|
76e46c08a4 | ||
|
|
01c46be9ef | ||
|
|
f09cec4e25 | ||
|
|
b34ed79d2b | ||
|
|
47664c0aae | ||
|
|
5662b9f770 | ||
|
|
a56eb93ffc | ||
|
|
615b0719f1 | ||
|
|
62e3c7804a | ||
|
|
9878cdb2a2 | ||
|
|
f19fda5494 | ||
|
|
7b22889e23 | ||
|
|
bdbf840529 | ||
|
|
b71b84da1b | ||
|
|
1b4e998cf9 | ||
|
|
a99adb88d6 | ||
|
|
dd2c3859e9 | ||
|
|
0729dd21be | ||
|
|
de1d228fb2 | ||
|
|
3228c76675 | ||
|
|
b039da3ff8 | ||
|
|
07eb18c61c | ||
|
|
5ba9869400 | ||
|
|
9542fdfe4a | ||
|
|
88f18246ce | ||
|
|
6b76e82220 | ||
|
|
898a5253e5 | ||
|
|
756ec5c002 | ||
|
|
235efd5a47 | ||
|
|
c0c5240f8d | ||
|
|
ca69db2395 | ||
|
|
e789a38d19 | ||
|
|
2ec5993e63 | ||
|
|
6db2610c8d | ||
|
|
0b2809b13e | ||
|
|
9ffa04e551 | ||
|
|
505629f798 | ||
|
|
08418e22d3 | ||
|
|
0f55e17095 | ||
|
|
fa1edc9c12 | ||
|
|
bab2930b97 | ||
|
|
e97ff51dc8 | ||
|
|
4402d4ec64 | ||
|
|
4b9c289ec4 | ||
|
|
072ceaf5f0 | ||
|
|
e31721432b | ||
|
|
e1a555d4f9 | ||
|
|
8bf59b20f6 | ||
|
|
3bc7c6ad3b | ||
|
|
65e133a391 | ||
|
|
7ab0c28f6f | ||
|
|
c187c1dc60 | ||
|
|
9ddf5faf66 | ||
|
|
f11322e98d | ||
|
|
126de81834 | ||
|
|
7bb6807d37 | ||
|
|
3eb72481ce | ||
|
|
2403408b6c | ||
|
|
f91499861e | ||
|
|
84ced5a170 | ||
|
|
d7a45576b2 | ||
|
|
dedf1945ef | ||
| e6913c03f5 |
@@ -7,6 +7,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: webserver
|
runs-on: webserver
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: shop
|
||||||
steps:
|
steps:
|
||||||
- name: Code auschecken
|
- name: Code auschecken
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -15,4 +20,46 @@ jobs:
|
|||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Staging-Build generieren
|
- name: Staging-Build generieren
|
||||||
run: npm run build -- --configuration=staging
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
# KORREKTUR: Wir brennen die interne Docker-Netzwerk-URL fest ein!
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: http://supabase-kong:8000
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.STAGING_SUPABASE_ANON_KEY }}
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Docker login to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.hephex.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
# KORREKTUR: Wir übergeben die INTERNE Docker-URL auch an das Docker-Image!
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_URL="http://supabase-kong:8000" \
|
||||||
|
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="${{ secrets.STAGING_SUPABASE_ANON_KEY }}" \
|
||||||
|
-t gitea.hephex.de/daniel/webshop:staging -f Dockerfile .
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
# KORREKTUR: Auch der Push wird vom Hauptordner aus gestartet
|
||||||
|
- name: Push Docker image
|
||||||
|
run: docker push gitea.hephex.de/daniel/webshop:staging
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Clean up runner Docker cache
|
||||||
|
run: |
|
||||||
|
docker image prune -f
|
||||||
|
docker builder prune -f
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
|
||||||
|
# KORREKTUR: IP-Adresse exakt auf Ihren Proxmox-Staging-Container eingestellt
|
||||||
|
- name: Deploy to Proxmox LXC via SSH
|
||||||
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
|
with:
|
||||||
|
host: 192.168.178.132
|
||||||
|
username: ${{ secrets.PROXMOX_USER }}
|
||||||
|
key: ${{ secrets.PROXMOX_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/webshop/supabase/supabase-project
|
||||||
|
docker compose pull webshop
|
||||||
|
docker compose up -d webshop
|
||||||
|
docker image prune -f
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ example_data/
|
|||||||
shop/Leitfaden
|
shop/Leitfaden
|
||||||
.gitignore
|
.gitignore
|
||||||
db-access.txt
|
db-access.txt
|
||||||
|
2026-06-25-ZAP-Report-.json
|
||||||
|
|||||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Use a multi‑stage 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
35
scripts/init_supabase.sh
Normal 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 "$@"
|
||||||
27
shop/app/_global-error.tsx
Normal file
27
shop/app/_global-error.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
466
shop/app/admin/companies/[id]/customers/page.tsx
Normal file
466
shop/app/admin/companies/[id]/customers/page.tsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
Briefcase,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
getCompanyDetails,
|
||||||
|
getEndCustomersByCompanyWithPartners,
|
||||||
|
getCompanyPartners,
|
||||||
|
adminCreateEndCustomer,
|
||||||
|
adminUpdateEndCustomer,
|
||||||
|
adminDeleteEndCustomer
|
||||||
|
} from '@/lib/actions/companies'
|
||||||
|
|
||||||
|
export default function CompanyCustomersPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const companyId = params.id as string
|
||||||
|
|
||||||
|
const [company, setCompany] = useState<any>(null)
|
||||||
|
const [customers, setCustomers] = useState<any[]>([])
|
||||||
|
const [partners, setPartners] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingAction, setLoadingAction] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Dialog State
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [editingCustomer, setEditingCustomer] = useState<any>(null)
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [companyName, setCompanyName] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [street, setStreet] = useState('')
|
||||||
|
const [zip, setZip] = useState('')
|
||||||
|
const [city, setCity] = useState('')
|
||||||
|
const [vatId, setVatId] = useState('')
|
||||||
|
const [partnerId, setPartnerId] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [compDetails, custData, partData] = await Promise.all([
|
||||||
|
getCompanyDetails(companyId),
|
||||||
|
getEndCustomersByCompanyWithPartners(companyId),
|
||||||
|
getCompanyPartners(companyId)
|
||||||
|
])
|
||||||
|
setCompany(compDetails)
|
||||||
|
setCustomers(custData)
|
||||||
|
setPartners(partData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Daten:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (companyId) {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}, [companyId])
|
||||||
|
|
||||||
|
const handleOpenDialog = (customer: any = null) => {
|
||||||
|
setEditingCustomer(customer)
|
||||||
|
setCompanyName(customer ? customer.company_name || '' : '')
|
||||||
|
setFirstName(customer ? customer.first_name || '' : '')
|
||||||
|
setLastName(customer ? customer.last_name || '' : '')
|
||||||
|
setStreet(customer ? customer.street || '' : '')
|
||||||
|
setZip(customer ? customer.zip || '' : '')
|
||||||
|
setCity(customer ? customer.city || '' : '')
|
||||||
|
setVatId(customer ? customer.vat_id || '' : '')
|
||||||
|
setPartnerId(customer ? customer.partner_id || '' : (partners.length > 0 ? partners[0].id : ''))
|
||||||
|
setEmail(customer ? customer.email || '' : '')
|
||||||
|
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,
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingCustomer) {
|
||||||
|
await adminUpdateEndCustomer(editingCustomer.id, payload)
|
||||||
|
} else {
|
||||||
|
await adminCreateEndCustomer(payload)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Speichern des Endkunden.')
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
if (!confirm(`Endkunden "${name}" wirklich löschen?`)) return
|
||||||
|
setLoadingAction(true)
|
||||||
|
try {
|
||||||
|
await adminDeleteEndCustomer(id)
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Löschen des Endkunden:', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCustomers = customers.filter((c) => {
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
const nameMatch = c.company_name?.toLowerCase().includes(q)
|
||||||
|
const personMatch = `${c.first_name || ''} ${c.last_name || ''}`.toLowerCase().includes(q)
|
||||||
|
const addressMatch = `${c.street || ''} ${c.zip || ''} ${c.city || ''}`.toLowerCase().includes(q)
|
||||||
|
const partnerMatch = c.partner_name?.toLowerCase().includes(q)
|
||||||
|
const emailMatch = c.email?.toLowerCase().includes(q)
|
||||||
|
return nameMatch || personMatch || addressMatch || partnerMatch || emailMatch
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||||
|
{/* Back Link */}
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/admin/companies"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Zurück zur Firmenverwaltung
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||||
|
<Briefcase className="w-8 h-8 text-indigo-500" />
|
||||||
|
Endkunden von {company ? company.name : '...'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||||
|
Hier verwalten Sie alle Endkunden, die Partnern dieser Firma zugeordnet sind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
disabled={loading || partners.length === 0}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold"
|
||||||
|
title={partners.length === 0 ? 'Es müssen zuerst Benutzer für diese Firma existieren' : ''}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> Endkunde anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning if no partners exist */}
|
||||||
|
{!loading && partners.length === 0 && (
|
||||||
|
<div className="p-4 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/30 rounded-xl flex gap-3 text-amber-800 dark:text-amber-300 text-sm">
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Keine Benutzer zugewiesen</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
Diese Firma hat aktuell keine zugewiesenen Partner (Benutzer mit der Rolle "partner").
|
||||||
|
Sie müssen zuerst in der <Link href="/admin/users" className="underline font-semibold hover:text-amber-900 dark:hover:text-amber-200">Benutzerverwaltung</Link> Partner erstellen und dieser Firma zuweisen, um Endkunden anzulegen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table Section */}
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
|
||||||
|
<div className="relative mb-6 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Endkunden suchen (Name, Ansprechpartner, Ort)..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-slate-400 text-sm">Lade Endkunden...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredCustomers.length === 0 ? (
|
||||||
|
<div className="text-center py-16 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-xl text-slate-500">
|
||||||
|
<Users className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
Keine Endkunden gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-white/10 text-slate-400 text-xs font-semibold uppercase">
|
||||||
|
<th className="pb-3">Endkunde (Firma / Kontakt)</th>
|
||||||
|
<th className="pb-3">Lieferadresse</th>
|
||||||
|
<th className="pb-3">E-Mail</th>
|
||||||
|
<th className="pb-3">USt-ID</th>
|
||||||
|
<th className="pb-3">Zugeordneter Partner</th>
|
||||||
|
<th className="pb-3 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||||
|
{filteredCustomers.map((customer) => (
|
||||||
|
<tr key={customer.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{customer.company_name}
|
||||||
|
</div>
|
||||||
|
{(customer.first_name || customer.last_name) && (
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 mt-0.5">
|
||||||
|
<User className="w-3 h-3 text-slate-400 shrink-0" />
|
||||||
|
<span>{customer.first_name || ''} {customer.last_name || ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{customer.street ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{customer.street}, {customer.zip} {customer.city}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400 dark:text-slate-600">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{customer.email || <span className="text-slate-400 dark:text-slate-600">–</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{customer.vat_id || <span className="text-slate-400 dark:text-slate-600">–</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-300">
|
||||||
|
{customer.partner_name || 'Unbekannt'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleOpenDialog(customer)}
|
||||||
|
className="h-8 w-8 text-indigo-500 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||||
|
title="Endkunde bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={loadingAction}
|
||||||
|
onClick={() => handleDelete(customer.id, customer.company_name)}
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
title="Endkunde löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog: Endkunde anlegen / bearbeiten */}
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingCustomer ? 'Endkunden bearbeiten' : 'Neuen Endkunden anlegen'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||||
|
Geben Sie die Firmendaten und optional Kontaktdaten und Adresse des Endkunden an.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-company">Firmenname *</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-company"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Endkunde GmbH"
|
||||||
|
value={companyName}
|
||||||
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-firstname">Vorname</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-firstname"
|
||||||
|
type="text"
|
||||||
|
placeholder="Max"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-lastname">Nachname</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-lastname"
|
||||||
|
type="text"
|
||||||
|
placeholder="Mustermann"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-street">Straße & Hausnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-street"
|
||||||
|
type="text"
|
||||||
|
placeholder="Beispielstraße 12"
|
||||||
|
value={street}
|
||||||
|
onChange={(e) => setStreet(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2 col-span-1">
|
||||||
|
<Label htmlFor="cust-zip">PLZ</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-zip"
|
||||||
|
type="text"
|
||||||
|
placeholder="12345"
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<Label htmlFor="cust-city">Ort</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-city"
|
||||||
|
type="text"
|
||||||
|
placeholder="Musterort"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-vat">USt-ID (VAT-ID)</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-vat"
|
||||||
|
type="text"
|
||||||
|
placeholder="DE123456789"
|
||||||
|
value={vatId}
|
||||||
|
onChange={(e) => setVatId(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-partner">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>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-email">E-Mail</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="kunde@beispiel.de"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={loadingAction}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loadingAction} className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold">
|
||||||
|
{loadingAction ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
{editingCustomer ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
335
shop/app/admin/companies/page.tsx
Normal file
335
shop/app/admin/companies/page.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { getCompanies, createCompany, updateCompany, deleteCompany } from '@/lib/actions/companies'
|
||||||
|
|
||||||
|
export default function CompaniesPage() {
|
||||||
|
const [companies, setCompanies] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [editingCompany, setEditingCompany] = useState<any>(null)
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [street, setStreet] = useState('')
|
||||||
|
const [zip, setZip] = useState('')
|
||||||
|
const [city, setCity] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const loadCompanies = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await getCompanies()
|
||||||
|
setCompanies(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Partner:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCompanies()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenDialog = (company: any = null) => {
|
||||||
|
setEditingCompany(company)
|
||||||
|
setName(company ? company.name || '' : '')
|
||||||
|
setStreet(company ? company.street || '' : '')
|
||||||
|
setZip(company ? company.zip || '' : '')
|
||||||
|
setCity(company ? company.city || '' : '')
|
||||||
|
setEmail(company ? company.email || '' : '')
|
||||||
|
setCreateError(null)
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreating(true)
|
||||||
|
setCreateError(null)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: name.trim(),
|
||||||
|
street: street.trim(),
|
||||||
|
zip: zip.trim(),
|
||||||
|
city: city.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
}
|
||||||
|
if (editingCompany) {
|
||||||
|
await updateCompany(editingCompany.id, payload)
|
||||||
|
} else {
|
||||||
|
await createCompany(payload)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
loadCompanies()
|
||||||
|
} catch (err: any) {
|
||||||
|
setCreateError(err.message || 'Fehler beim Speichern.')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, firmName: string) => {
|
||||||
|
if (!confirm(`Firma "${firmName}" wirklich löschen? Alle zugewiesenen Benutzer werden getrennt.`)) return
|
||||||
|
setDeletingId(id)
|
||||||
|
try {
|
||||||
|
await deleteCompany(id)
|
||||||
|
loadCompanies()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Löschen:', err)
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCompanies = companies.filter((c) => {
|
||||||
|
const nameMatch = c.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
const addressMatch = `${c.street || ''} ${c.zip || ''} ${c.city || ''}`.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
const emailMatch = c.email?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
return nameMatch || addressMatch || emailMatch
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||||
|
<Building2 className="w-8 h-8 text-blue-500" />
|
||||||
|
Firmenverwaltung
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||||
|
Verwalten Sie Firmen, deren Lieferadresse und weisen Sie Partner zu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Firmen-Tabelle */}
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
|
||||||
|
<div className="relative mb-6 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Firmen suchen (Name, Adresse, E-Mail)..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-slate-400 text-sm">Lade Firmen...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredCompanies.length === 0 ? (
|
||||||
|
<div className="text-center py-16 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-xl text-slate-500">
|
||||||
|
<Building2 className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
Keine Firmen gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-white/10 text-slate-400 text-xs font-semibold uppercase">
|
||||||
|
<th className="pb-3">Firmenname</th>
|
||||||
|
<th className="pb-3">Lieferadresse</th>
|
||||||
|
<th className="pb-3">E-Mail</th>
|
||||||
|
<th className="pb-3">Erstellt am</th>
|
||||||
|
<th className="pb-3 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||||
|
{filteredCompanies.map((company) => (
|
||||||
|
<tr key={company.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||||
|
<td className="py-4 font-semibold text-slate-900 dark:text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-blue-500 shrink-0" />
|
||||||
|
{company.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{company.street ? `${company.street}, ${company.zip} ${company.city}` : '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{company.email ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>{company.email}</span>
|
||||||
|
</div>
|
||||||
|
) : '–'}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{company.created_at ? new Date(company.created_at).toLocaleDateString('de-DE') : '–'}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
asChild
|
||||||
|
className="h-8 w-8 text-indigo-500 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||||
|
title="Endkunden dieser Firma verwalten"
|
||||||
|
>
|
||||||
|
<Link href={`/admin/companies/${company.id}/customers`}>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleOpenDialog(company)}
|
||||||
|
className="h-8 w-8 text-blue-500 hover:text-blue-400 hover:bg-blue-500/10"
|
||||||
|
title="Firma bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={deletingId === company.id}
|
||||||
|
onClick={() => handleDelete(company.id, company.name)}
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
title="Firma löschen"
|
||||||
|
>
|
||||||
|
{deletingId === company.id
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Trash2 className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog: Firma anlegen / bearbeiten */}
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||||
|
Geben Sie die Firmendetails und die vollständige Lieferadresse ein. Alle Felder sind Pflichtfelder.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-name">Firmenname</Label>
|
||||||
|
<Input
|
||||||
|
id="company-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Muster GmbH"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-street">Straße & Hausnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="company-street"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Musterstraße 123"
|
||||||
|
value={street}
|
||||||
|
onChange={(e) => setStreet(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2 col-span-1">
|
||||||
|
<Label htmlFor="company-zip">PLZ</Label>
|
||||||
|
<Input
|
||||||
|
id="company-zip"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="12345"
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<Label htmlFor="company-city">Ort</Label>
|
||||||
|
<Input
|
||||||
|
id="company-city"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Musterstadt"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-email">E-Mail für Lieferadresse</Label>
|
||||||
|
<Input
|
||||||
|
id="company-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="lieferung@musterfirma.de"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{createError}</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={creating}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={creating} className="bg-blue-600 hover:bg-blue-700 text-white font-semibold">
|
||||||
|
{creating ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
{editingCompany ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
shop/app/admin/einstellungen/page.tsx
Normal file
210
shop/app/admin/einstellungen/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/* Admin Settings with Database Import / Export functionality */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AdminSettings() {
|
||||||
|
const [demoActive, setDemoActive] = useState(true);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [statusMsg, setStatusMsg] = useState('');
|
||||||
|
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||||
|
setDemoActive(state);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDemo = (checked: boolean) => {
|
||||||
|
setDemoActive(checked);
|
||||||
|
localStorage.setItem('demo_banner_disabled', (!checked).toString());
|
||||||
|
window.dispatchEvent(new Event('storage_demo_changed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
setStatusMsg('Export läuft...');
|
||||||
|
setStatusType('info');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/db-backup');
|
||||||
|
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
setStatusMsg('Export erfolgreich abgeschlossen.');
|
||||||
|
setStatusType('success');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setStatusMsg(`Fehler beim Export: ${err.message}`);
|
||||||
|
setStatusType('error');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImporting(true);
|
||||||
|
setStatusMsg('Import läuft...');
|
||||||
|
setStatusType('info');
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/db-backup', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
|
||||||
|
|
||||||
|
setStatusMsg('Datenbank erfolgreich importiert!');
|
||||||
|
setStatusType('success');
|
||||||
|
setSelectedFile(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
setStatusMsg(`Fehler beim Import: ${err.message}`);
|
||||||
|
setStatusType('error');
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||||
|
Verwalten Sie globale Shopeinstellungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allgemeine Einstellungen */}
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-4">
|
||||||
|
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
Allgemeine Einstellungen
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-lg border border-slate-100 dark:border-white/5">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm text-slate-900 dark:text-white">Demo-Warnmeldungen</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Schaltet gelbe Banner ein oder aus.</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datenimport & -export */}
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<Database className="w-5 h-5 text-primary" />
|
||||||
|
Datenimport & -export
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||||
|
Sichern Sie Ihre gesamte Instanz oder stellen Sie Daten wieder her.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusMsg && (
|
||||||
|
<div className={`p-4 rounded-lg text-sm border ${
|
||||||
|
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||||
|
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
|
||||||
|
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{statusMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Export Card */}
|
||||||
|
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
|
||||||
|
<Download className="w-4 h-4 text-primary" />
|
||||||
|
Daten exportieren
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||||
|
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting || importing}
|
||||||
|
className="w-full bg-primary hover:bg-primary/90 text-white"
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
|
||||||
|
) : (
|
||||||
|
<><Download className="w-4 h-4 mr-2" /> ZIP-Backup herunterladen</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import Card */}
|
||||||
|
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
|
||||||
|
<Upload className="w-4 h-4 text-amber-500" />
|
||||||
|
Daten importieren
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 p-2.5 rounded-lg text-amber-600 dark:text-amber-400 text-[11px] leading-snug">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
<strong>Warnung:</strong> Der Import überschreibt alle Daten dieser Instanz unwiderruflich!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||||
|
disabled={exporting || importing}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<div className="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{selectedFile ? (
|
||||||
|
<span className="text-primary font-semibold">{selectedFile.name}</span>
|
||||||
|
) : (
|
||||||
|
"ZIP-Datei auswählen oder hierher ziehen"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={!selectedFile || exporting || importing}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full border border-slate-200 dark:border-white/10"
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Import läuft...</>
|
||||||
|
) : (
|
||||||
|
<><Upload className="w-4 h-4 mr-2" /> ZIP-Backup einspielen</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
import Link from 'next/link'
|
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,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
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 (
|
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 */}
|
{/* 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">
|
<div className="p-6">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||||
@@ -20,37 +38,59 @@ export default function AdminLayout({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 px-4 space-y-2 py-4">
|
<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" />
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</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" />
|
<Package className="w-5 h-5" />
|
||||||
Produkte
|
Produkte
|
||||||
</Link>
|
</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" />
|
<LayoutGrid className="w-5 h-5" />
|
||||||
Kategorien
|
Kategorien
|
||||||
</Link>
|
</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" />
|
<ShoppingCart className="w-5 h-5" />
|
||||||
Bestellungen
|
Bestellungen
|
||||||
</Link>
|
</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" />
|
<Users className="w-5 h-5" />
|
||||||
Benutzer
|
Benutzer
|
||||||
</Link>
|
</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" />
|
<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>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-white/5">
|
<div className="p-4 border-t border-slate-200 dark:border-white/5 space-y-4">
|
||||||
<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">
|
<AdminLogoutButton />
|
||||||
<LogOut className="w-5 h-5" />
|
<div className="px-3 pt-2 flex items-center gap-2 border-t border-slate-200 dark:border-white/5 opacity-60">
|
||||||
Abmelden
|
<span className="font-bold text-sm tracking-tighter text-slate-800 dark:text-white">CASPOS Store</span>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { OrdersTable } from '@/components/admin/orders-table'
|
||||||
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 { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
export default async function AdminOrdersPage() {
|
export default async function AdminOrdersPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<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</h1>
|
||||||
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte PDFs zu.</p>
|
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte Angebotsbestätigungen zu.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,83 +20,16 @@ export default async function AdminOrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function OrdersData() {
|
async function OrdersData() {
|
||||||
const supabase = await createClient()
|
const adminDb = createAdminClient()
|
||||||
|
|
||||||
const { data: orders, error } = await supabase
|
const { data: orders, error } = await adminDb
|
||||||
.from('orders')
|
.from('orders')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
if (error) {
|
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 (
|
return <OrdersTable initialOrders={orders || []} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
|
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
|
||||||
import { AdminRecentOrders } from '@/components/admin/recent-orders'
|
import { AdminRecentOrders } from '@/components/admin/recent-orders'
|
||||||
@@ -6,18 +7,29 @@ import { AdminRecentOrders } from '@/components/admin/recent-orders'
|
|||||||
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
|
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
|
||||||
async function getKpis() {
|
async function getKpis() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
const adminDb = createAdminClient()
|
||||||
|
|
||||||
const [
|
const [
|
||||||
{ data: orders },
|
{ data: orders },
|
||||||
{ count: userCount },
|
{ count: userCount },
|
||||||
{ count: productCount },
|
{ count: productCount },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from('orders').select('total_price, status'),
|
adminDb.from('orders').select('total_price, status, created_at'),
|
||||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
adminDb.from('profiles').select('*', { count: 'exact', head: true }),
|
||||||
supabase.from('products').select('*', { count: 'exact', head: true }),
|
supabase.from('products').select('*', { count: 'exact', head: true }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const totalRevenue = (orders ?? []).reduce((sum, o) => sum + Number(o.total_price), 0)
|
const now = new Date()
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
const currentMonth = now.getMonth()
|
||||||
|
|
||||||
|
const completedOrdersThisMonth = (orders ?? []).filter(o => {
|
||||||
|
if (o.status !== 'completed') return false
|
||||||
|
const orderDate = new Date(o.created_at)
|
||||||
|
return orderDate.getFullYear() === currentYear && orderDate.getMonth() === currentMonth
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalRevenue = completedOrdersThisMonth.reduce((sum, o) => sum + Number(o.total_price), 0)
|
||||||
const orderCount = (orders ?? []).length
|
const orderCount = (orders ?? []).length
|
||||||
|
|
||||||
return { totalRevenue, orderCount, userCount: userCount ?? 0, productCount: productCount ?? 0 }
|
return { totalRevenue, orderCount, userCount: userCount ?? 0, productCount: productCount ?? 0 }
|
||||||
@@ -34,14 +46,14 @@ export default async function AdminDashboard() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card className="glass-dark border-white/5">
|
<Card className="glass-dark border-white/5">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-slate-300">Umsatz gesamt</CardTitle>
|
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
|
||||||
<TrendingUp className="h-4 w-4 text-primary" />
|
<TrendingUp className="h-4 w-4 text-primary" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-white">
|
<div className="text-2xl font-bold text-white">
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">Aus allen Bestellungen</p>
|
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ async function ProductDataWrapper() {
|
|||||||
|
|
||||||
async function CreateProductAction() {
|
async function CreateProductAction() {
|
||||||
const categories = await getCategories()
|
const categories = await getCategories()
|
||||||
|
const products = await getProducts()
|
||||||
return (
|
return (
|
||||||
<CreateProductDialog categories={categories} />
|
<CreateProductDialog categories={categories} products={products} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
151
shop/app/admin/settings/page.tsx
Normal file
151
shop/app/admin/settings/page.tsx
Normal 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">SMTP‑Einstellungen</h1>
|
||||||
|
{message && <p className="mb-4 text-yellow-300">{message}</p>}
|
||||||
|
<form onSubmit={handleSave} className="space-y-4">
|
||||||
|
<label className="flex flex-col">
|
||||||
|
Host
|
||||||
|
<input
|
||||||
|
name="host"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={settings?.host ?? ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col">
|
||||||
|
Port
|
||||||
|
<input
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={settings?.port ?? ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
name="secure"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.secure ?? false}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>SSL / TLS (secure)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col">
|
||||||
|
Benutzer (SMTP‑User)
|
||||||
|
<input
|
||||||
|
name="user"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={settings?.user ?? ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col">
|
||||||
|
Passwort
|
||||||
|
<input
|
||||||
|
name="pass"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={settings?.pass ?? ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex space-x-4 mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-primary rounded hover:bg-primary/80 transition"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestEmail}
|
||||||
|
className="px-4 py-2 bg-green-600 rounded hover:bg-green-500 transition"
|
||||||
|
>
|
||||||
|
Test‑Mail senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
shop/app/admin/tools/page.tsx
Normal file
283
shop/app/admin/tools/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { getUsers } from '@/lib/actions/users'
|
import { getUsers } from '@/lib/actions/users'
|
||||||
|
import { getCompanies } from '@/lib/actions/companies'
|
||||||
import { UserList } from '@/components/admin/user-list'
|
import { UserList } from '@/components/admin/user-list'
|
||||||
import { UserDialog } from '@/components/admin/user-dialog'
|
import { UserDialog } from '@/components/admin/user-dialog'
|
||||||
import { Users } from 'lucide-react'
|
import { Users } from 'lucide-react'
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function AdminUsersPage() {
|
export default async function AdminUsersPage() {
|
||||||
|
const companies = await getCompanies()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
@@ -18,7 +23,7 @@ export default async function AdminUsersPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<UserDialog />
|
<UserDialog companies={companies} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
|
<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() {
|
async function UserDataWrapper() {
|
||||||
try {
|
try {
|
||||||
const users = await getUsers()
|
const [users, companies] = await Promise.all([getUsers(), getCompanies()])
|
||||||
return <UserList initialUsers={users} />
|
return <UserList initialUsers={users} companies={companies} />
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
console.error('[UserDataWrapper] Error loading users:', error)
|
||||||
return (
|
return (
|
||||||
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center">
|
<div className="p-8 border border-destructive/50 bg-destructive/10 rounded-xl text-destructive text-center space-y-2">
|
||||||
Fehler beim Laden der Benutzer. Stellen Sie sicher, dass der SERVICE_ROLE_KEY korrekt konfiguriert ist.
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
182
shop/app/api/admin/db-backup/route.ts
Normal file
182
shop/app/api/admin/db-backup/route.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createServerClient } from '@supabase/ssr';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import { Client } from 'pg';
|
||||||
|
|
||||||
|
function getPgClient() {
|
||||||
|
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:54322/postgres';
|
||||||
|
return new Client({
|
||||||
|
connectionString,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAdmin() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||||
|
const supabase = createServerClient(
|
||||||
|
supabaseUrl,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignored
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
|
||||||
|
if (authError || !authUser) {
|
||||||
|
return { errorResponse: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return { errorResponse: NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { errorResponse } = await verifyAdmin();
|
||||||
|
if (errorResponse) return errorResponse;
|
||||||
|
|
||||||
|
const client = getPgClient();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = [
|
||||||
|
'categories',
|
||||||
|
'products',
|
||||||
|
'product_modules',
|
||||||
|
'companies',
|
||||||
|
'end_customers',
|
||||||
|
'orders',
|
||||||
|
'profiles',
|
||||||
|
'users',
|
||||||
|
'settings'
|
||||||
|
];
|
||||||
|
|
||||||
|
const backupData: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
const res = await client.query(`SELECT * FROM public.${table}`);
|
||||||
|
backupData[table] = res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zip = new AdmZip();
|
||||||
|
const dataStr = JSON.stringify(backupData, null, 2);
|
||||||
|
zip.addFile('db_backup.json', Buffer.from(dataStr, 'utf8'));
|
||||||
|
const buffer = zip.toBuffer();
|
||||||
|
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': 'attachment; filename=db_backup.zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Backup export failed:', err);
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { errorResponse } = await verifyAdmin();
|
||||||
|
if (errorResponse) return errorResponse;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const zip = new AdmZip(Buffer.from(arrayBuffer));
|
||||||
|
const zipEntries = zip.getEntries();
|
||||||
|
const backupEntry = zipEntries.find(entry => entry.entryName === 'db_backup.json');
|
||||||
|
if (!backupEntry) {
|
||||||
|
return NextResponse.json({ error: 'Invalid backup file structure' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStr = backupEntry.getData().toString('utf8');
|
||||||
|
const backupData = JSON.parse(dataStr);
|
||||||
|
|
||||||
|
const client = getPgClient();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Disable constraints
|
||||||
|
await client.query("SET session_replication_role = 'replica'");
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
'categories',
|
||||||
|
'products',
|
||||||
|
'product_modules',
|
||||||
|
'companies',
|
||||||
|
'end_customers',
|
||||||
|
'orders',
|
||||||
|
'profiles',
|
||||||
|
'users',
|
||||||
|
'settings'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Delete existing rows in all tables
|
||||||
|
for (const table of tables) {
|
||||||
|
await client.query(`DELETE FROM public.${table}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert backup data
|
||||||
|
for (const table of tables) {
|
||||||
|
const rows = backupData[table];
|
||||||
|
if (!rows || rows.length === 0) continue;
|
||||||
|
|
||||||
|
const columns = Object.keys(rows[0]);
|
||||||
|
const columnNamesStr = columns.map(c => `"${c}"`).join(', ');
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const values = columns.map(col => row[col]);
|
||||||
|
const placeholders = columns.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.${table} (${columnNamesStr}) VALUES (${placeholders})`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable constraints
|
||||||
|
await client.query("SET session_replication_role = 'origin'");
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Database imported successfully.' });
|
||||||
|
} catch (err: any) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Backup import failed:', err);
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Backup import request processing failed:', err);
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/recent-orders
|
* GET /api/admin/recent-orders
|
||||||
* Gibt die letzten 10 Bestellungen zurück.
|
* Gibt die letzten 10 Bestellungen und Umsatzdaten der letzten 6 Monate zurück.
|
||||||
* Wird vom Admin-Dashboard alle 30 Sekunden gepollt.
|
* Wird vom Admin-Dashboard alle 30 Sekunden gepollt.
|
||||||
* Zugriff nur für authentifizierte User (Supabase Session).
|
* Zugriff nur für authentifizierte User mit Admin-Rolle (bypasses RLS).
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -16,7 +17,21 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: orders, error } = await supabase
|
// Admin-Rolle prüfen
|
||||||
|
const { data: userData } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('role')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!userData || userData.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminDb = createAdminClient()
|
||||||
|
|
||||||
|
// 1. Letzte 10 Bestellungen laden
|
||||||
|
const { data: orders, error } = await adminDb
|
||||||
.from('orders')
|
.from('orders')
|
||||||
.select('id, order_number, created_at, total_price, status, customer_data')
|
.select('id, order_number, created_at, total_price, status, customer_data')
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
@@ -26,5 +41,47 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ orders })
|
// 2. Umsatzdaten der letzten 6 Monate für das Chart ermitteln
|
||||||
|
const sixMonthsAgo = new Date()
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
|
||||||
|
sixMonthsAgo.setDate(1)
|
||||||
|
sixMonthsAgo.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const { data: chartOrders, error: chartError } = await adminDb
|
||||||
|
.from('orders')
|
||||||
|
.select('total_price, created_at')
|
||||||
|
.neq('status', 'cancelled')
|
||||||
|
.gte('created_at', sixMonthsAgo.toISOString())
|
||||||
|
|
||||||
|
let chartData: { name: string; value: number }[] = []
|
||||||
|
if (!chartError && chartOrders) {
|
||||||
|
const monthlyData: Record<string, number> = {}
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// Die letzten 6 Monate initialisieren
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
const key = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' })
|
||||||
|
monthlyData[key] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Werte aufsummieren
|
||||||
|
chartOrders.forEach(o => {
|
||||||
|
const date = new Date(o.created_at)
|
||||||
|
const key = date.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' })
|
||||||
|
if (key in monthlyData) {
|
||||||
|
monthlyData[key] += Number(o.total_price)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// In Array umwandeln
|
||||||
|
chartData = Object.entries(monthlyData).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: Math.round(value * 100) / 100
|
||||||
|
}))
|
||||||
|
} else if (chartError) {
|
||||||
|
console.error('Chart data fetch error:', chartError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ orders, chartData })
|
||||||
}
|
}
|
||||||
|
|||||||
52
shop/app/api/admin/send-test-email/route.ts
Normal file
52
shop/app/api/admin/send-test-email/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
96
shop/app/api/admin/smtp-settings/route.ts
Normal file
96
shop/app/api/admin/smtp-settings/route.ts
Normal 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' });
|
||||||
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import { SignUpForm } from "@/components/sign-up-form";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Page() {
|
export default function SignUpPage() {
|
||||||
return (
|
redirect("/auth/login");
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
123
shop/app/datenschutz/page.tsx
Normal file
123
shop/app/datenschutz/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Lock } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export default function Datenschutz() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 py-16 px-4">
|
||||||
|
<div className="container max-w-3xl mx-auto space-y-8">
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="ghost" size="sm" className="gap-2 text-slate-400 hover:text-white">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Zurück zur Startseite
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
|
||||||
|
<Lock className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400">Informationen über die Verarbeitung Ihrer personenbezogenen Daten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-slate-900" />
|
||||||
|
|
||||||
|
<div className="space-y-6 text-slate-300">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">1. Datenschutz auf einen Blick</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-semibold text-slate-200">Allgemeiner Hinweis</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung. Wir weisen darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-semibold text-slate-200">Wer ist verantwortlich für die Datenerfassung auf dieser Website?</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber CASPOS GmbH. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-semibold text-slate-200">Welche Rechte haben Sie bezüglich Ihrer Daten?</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Sie haben jederzeit das Recht unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die Berichtigung, Sperrung oder Löschung dieser Daten zu verlangen. Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit unter der im Impressum angegebenen Adresse an uns wenden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">2. Erhebung, Verarbeitung und Nutzung personenbezogener Daten</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Sie können unsere Webseiten grundsätzlich besuchen, ohne dass wir personenbezogene Daten von Ihnen erheben. Personenbezogene Daten werden nur erhoben, wenn Sie uns diese von sich aus zur Durchführung eines Vertrages, oder im Rahmen der Kontaktaufnahme mitteilen. Diese Daten werden ohne Ihre ausdrückliche Einwilligung jeweils allein zur Vertragsabwicklung und Bearbeitung Ihrer Anfragen genutzt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">3. Analyse-Tools und Tools von Drittanbietern</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Beim Besuch unserer Website werden keine Analyse-Tools eingesetzt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">4. Cookies</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
CASPOS GmbH verwendet keine so genannte Cookies.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">5. Server-Log-Dateien</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm text-slate-400 space-y-1">
|
||||||
|
<li>Browsertyp und Browserversion</li>
|
||||||
|
<li>verwendetes Betriebssystem</li>
|
||||||
|
<li>Referrer URL</li>
|
||||||
|
<li>Hostname des zugreifenden Rechners</li>
|
||||||
|
<li>Uhrzeit der Serveranfrage</li>
|
||||||
|
<li>IP-Adresse</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed mt-2">
|
||||||
|
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Grundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. f DSGVO, der die Verarbeitung von Daten zur Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen gestattet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">6. Weitergabe personenbezogener Daten</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Personenbezogenen Daten werden von uns im Rahmen der Vertragsabwicklung an das mit der Lieferung beauftragte Transportunternehmen weitergegeben, soweit dies zur Lieferung der Ware erforderlich ist.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Bei Vorlage einer richterlichen Anordnung darf CASPOS GmbH Ihre persönlichen Daten an die Strafverfolgungsbehörden weitergeben, ohne Sie davon zu informieren. Ebenso behält sich CASPOS GmbH vor, Ihre persönlichen Daten für gerichtliche Auseinandersetzungen einzusetzen, falls dies notwendig ist.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xl font-bold text-white">7. Kontaktformular</h2>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Wenn Sie uns per Kontaktformular Anfragen zukommen lassen, werden Ihre Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Die Verarbeitung der in das Kontaktformular eingegebenen Daten erfolgt somit ausschließlich auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können diese Einwilligung jederzeit widerrufen. Dazu reicht eine formlose Mitteilung per E-Mail an uns. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitungsvorgänge bleibt vom Widerruf unberührt.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Die von Ihnen im Kontaktformular eingegebenen Daten verbleiben bei uns, bis Sie uns zur Löschung auffordern, Ihre Einwilligung zur Speicherung widerrufen oder der Zweck für die Datenspeicherung entfällt (z.B. nach abgeschlossener Bearbeitung Ihrer Anfrage). Zwingende gesetzliche Bestimmungen – insbesondere Aufbewahrungsfristen – bleiben unberührt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
shop/app/impressum/page.tsx
Normal file
111
shop/app/impressum/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Shield } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export default function Impressum() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 py-16 px-4">
|
||||||
|
<div className="container max-w-3xl mx-auto space-y-8">
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="ghost" size="sm" className="gap-2 text-slate-400 hover:text-white">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Zurück zur Startseite
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
|
||||||
|
<Shield className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||||
|
Impressum
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400">Angaben gemäß § 5 TMG.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-slate-900" />
|
||||||
|
|
||||||
|
<div className="space-y-6 text-slate-300">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold text-white">CASPOS Computerabrechnungssysteme GmbH</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Alte Bundesstraße 16<br />
|
||||||
|
D-76846 Hauenstein
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold text-white">Vertreten durch die Geschäftsführer:</h2>
|
||||||
|
<p className="text-sm text-slate-400">Helmut Dietz, Tamara Korn, Julian Winter</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold text-white">Kontakt:</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Telefon: +49 (0) 6392 – 8598 440<br />
|
||||||
|
E-Mail: info@caspos.de
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold text-white">Registereintrag:</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Handelsregister: HRB 32806, Amtsgericht Zweibrücken
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-bold text-white">Umsatzsteuer-ID & Steuernummer:</h2>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Umsatzsteueridentifikationsnummer: DE 158286059<br />
|
||||||
|
Steuernummer: 35/671/01361
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="border-slate-900" />
|
||||||
|
|
||||||
|
<section className="space-y-2 text-slate-400 text-xs">
|
||||||
|
<p className="font-semibold text-slate-300">
|
||||||
|
© 2022 CASPOS Computerabrechnungssysteme GmbH – Alle Rechte vorbehalten
|
||||||
|
</p>
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
Sämtliches Material auf dieser Website wie Texte, Bilder, Illustrationen, Audio- und Videoclips, um nur ein paar Beispiele zu nennen (allgemein „das Material“ genannt), sind urheberrechtlich geschützt und dürfen für keinen anderen Zweck verwendet werden. Abdruck, kopieren, verteilen, Fremdnutzung, Posting und andere unerlaubte Nutzung des Materials ist hiermit ausdrücklich verboten.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr className="border-slate-900" />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-xl font-bold text-white">Haftungsausschluss</h2>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-md font-semibold text-slate-200">1. Inhalt des Onlineangebotes</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Der Autor übernimmt keinerlei Gewähr für die Aktualität, Korrektheit, Vollständigkeit oder Qualität der bereitgestellten Informationen. Haftungsansprüche gegen den Autor, welche sich auf Schäden materieller oder ideeller Art beziehen, die durch die Nutzung oder Nichtnutzung der dargebotenen Informationen bzw. durch die Nutzung fehlerhafter und unvollständiger Informationen verursacht wurden, sind grundsätzlich ausgeschlossen, sofern seitens des Autors kein nachweislich vorsätzliches oder grob fahrlässiges Verschulden vorliegt.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Alle Angebote sind freibleibend und unverbindlich. Der Autor behält es sich ausdrücklich vor, Teile der Seiten oder das gesamte Angebot ohne gesonderte Ankündigung zu verändern, zu ergänzen, zu löschen oder die Veröffentlichung zeitweise oder endgültig einzustellen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-md font-semibold text-slate-200">2. Verweise und Links</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Bei direkten oder indirekten Verweisen auf fremde Webseiten („Hyperlinks“), die außerhalb des Verantwortungsbereiches des Autors liegen, würde eine Haftungsverpflichtung ausschließlich in dem Fall in Kraft treten, in dem der Autor von den Inhalten Kenntnis hat und es ihm technisch möglich und zumutbar wäre, die Nutzung im Falle rechtswidriger Inhalte zu verhindern. Der Autor erklärt hiermit ausdrücklich, dass zum Zeitpunkt der Linksetzung keine illegalen Inhalte auf den zu verlinkenden Seiten erkennbar waren.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Auf die aktuelle und zukünftige Gestaltung, die Inhalte oder die Urheberschaft der gelinkten/verknüpften Seiten hat der Autor keinerlei Einfluss. Deshalb distanziert er sich hiermit ausdrücklich von allen Inhalten aller gelinkten /verknüpften Seiten, die nach der Linksetzung verändert wurden. Diese Feststellung gilt für alle innerhalb des eigenen Internetangebotes gesetzten Links und Verweise sowie für Fremdeinträge in vom Autor eingerichteten Gästebüchern, Diskussionsforen und Mailinglisten. Für illegale, fehlerhafte oder unvollständige Inhalte und insbesondere für Schäden, die aus der Nutzung oder Nichtnutzung solcherart dargebotener Informationen entstehen, haftet allein der Anbieter der Seite, auf welche verwiesen wurde, nicht derjenige, der über Links auf die jeweilige Veröffentlichung lediglich verweist.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-md font-semibold text-slate-200">4. Rechtswirksamkeit dieses Haftungsausschlusses</h3>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed">
|
||||||
|
Dieser Haftungsausschluss ist als Teil des Internetangebotes zu betrachten, von dem aus auf diese Seite verwiesen wurde. Sofern Teile oder einzelne Formulierungen dieses Textes der geltenden Rechtslage nicht, nicht mehr oder nicht vollständig entsprechen sollten, bleiben die übrigen Teile des Dokumentes in ihrem Inhalt und ihrer Gültigkeit davon unberührt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { InactivityTracker } from "@/components/inactivity-tracker";
|
||||||
|
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
||||||
|
import { Navbar } from "@/components/Navbar";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const defaultUrl = process.env.VERCEL_URL
|
const defaultUrl = process.env.VERCEL_URL
|
||||||
@@ -10,7 +13,7 @@ const defaultUrl = process.env.VERCEL_URL
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(defaultUrl),
|
metadataBase: new URL(defaultUrl),
|
||||||
title: "CASPOS-Shop | Die Kasse",
|
title: "CASPOS-Shop | Die Kasse",
|
||||||
description: "Konfigurieren und bestellen Sie Ihre maßgeschneiderte Business-Software in Minuten.",
|
description: "Konfigurieren Sie Ihre maßgeschneiderte Business-Software in Minuten und fragen Sie sie an.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -33,7 +36,10 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{children}
|
<InactivityTracker />
|
||||||
|
<HeaderWrapper navbar={<Navbar />}>
|
||||||
|
{children}
|
||||||
|
</HeaderWrapper>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
5
shop/app/login/page.tsx
Normal file
5
shop/app/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
const [anonymizing, setAnonymizing] = useState(false)
|
const [anonymizing, setAnonymizing] = useState(false)
|
||||||
const [showGdprConfirm, setShowGdprConfirm] = useState(false)
|
const [showGdprConfirm, setShowGdprConfirm] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
|
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,6 +37,7 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
street: c.street ?? '',
|
street: c.street ?? '',
|
||||||
zip: c.zip ?? '',
|
zip: c.zip ?? '',
|
||||||
city: c.city ?? '',
|
city: c.city ?? '',
|
||||||
|
email: c.email ?? '',
|
||||||
})
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
@@ -121,6 +122,7 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
{ label: 'USt-IdNr.', key: 'vat_id', span: false },
|
{ label: 'USt-IdNr.', key: 'vat_id', span: false },
|
||||||
{ label: 'Vorname', key: 'first_name', span: false },
|
{ label: 'Vorname', key: 'first_name', span: false },
|
||||||
{ label: 'Nachname', key: 'last_name', span: false },
|
{ label: 'Nachname', key: 'last_name', span: false },
|
||||||
|
{ label: 'E-Mail', key: 'email', span: true },
|
||||||
{ label: 'Straße & Hausnummer', key: 'street', span: true },
|
{ label: 'Straße & Hausnummer', key: 'street', span: true },
|
||||||
{ label: 'PLZ', key: 'zip', span: false },
|
{ label: 'PLZ', key: 'zip', span: false },
|
||||||
{ label: 'Ort', key: 'city', span: false },
|
{ label: 'Ort', key: 'city', span: false },
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function NewCustomerPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '',
|
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -58,6 +58,7 @@ export default function NewCustomerPage() {
|
|||||||
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
|
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
|
||||||
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
|
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
|
||||||
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
|
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
|
||||||
|
{ label: 'E-Mail', key: 'email', span: true, placeholder: 'kunde@beispiel.de' },
|
||||||
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: 'Musterstraße 1' },
|
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: 'Musterstraße 1' },
|
||||||
{ label: 'PLZ', key: 'zip', span: false, placeholder: '12345' },
|
{ label: 'PLZ', key: 'zip', span: false, placeholder: '12345' },
|
||||||
{ label: 'Ort', key: 'city', span: false, placeholder: 'Musterstadt' },
|
{ label: 'Ort', key: 'city', span: false, placeholder: 'Musterstadt' },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||||
|
import type { EndCustomer } from '@/lib/types'
|
||||||
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
|
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
@@ -13,10 +14,20 @@ export default async function MyCustomersPage() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) redirect('/auth/login')
|
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
|
// 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> = {}
|
const countMap: Record<string, number> = {}
|
||||||
;(orderCounts ?? []).forEach(o => {
|
;(orderCounts ?? []).forEach(o => {
|
||||||
@@ -53,6 +64,12 @@ export default async function MyCustomersPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 */}
|
{/* Keine Kunden */}
|
||||||
{customers.length === 0 && (
|
{customers.length === 0 && (
|
||||||
<Card className="glass-dark border-white/10">
|
<Card className="glass-dark border-white/10">
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ export default async function MyOrdersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||||
<ShoppingBag className="w-8 h-8 text-primary" />
|
<ShoppingBag className="w-8 h-8 text-primary" />
|
||||||
Meine Bestellungen
|
Meine Anfragen
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-400 text-sm mt-1">
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
Alle Ihre Bestellungen auf einen Blick – inkl. aktuellem Status.
|
Alle Ihre Anfragen auf einen Blick – inkl. aktuellem Status.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,16 +55,16 @@ export default async function MyOrdersPage() {
|
|||||||
{/* Fehler */}
|
{/* Fehler */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
||||||
Fehler beim Laden der Bestellungen: {error.message}
|
Fehler beim Laden der Anfragen: {error.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Keine Bestellungen */}
|
{/* Keine Anfragen */}
|
||||||
{!error && (!orders || orders.length === 0) && (
|
{!error && (!orders || orders.length === 0) && (
|
||||||
<Card className="glass-dark border-white/10">
|
<Card className="glass-dark border-white/10">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||||
<Package className="w-12 h-12 text-slate-600" />
|
<Package className="w-12 h-12 text-slate-600" />
|
||||||
<p className="text-slate-400 text-lg">Sie haben noch keine Bestellungen aufgegeben.</p>
|
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
|
||||||
<Link href="/order">
|
<Link href="/order">
|
||||||
<Button>Jetzt konfigurieren</Button>
|
<Button>Jetzt konfigurieren</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -72,7 +72,7 @@ export default async function MyOrdersPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bestellliste */}
|
{/* Anfragenliste */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{(orders ?? []).map((order) => {
|
{(orders ?? []).map((order) => {
|
||||||
const o = order as Order
|
const o = order as Order
|
||||||
@@ -83,7 +83,7 @@ export default async function MyOrdersPage() {
|
|||||||
{/* Kopfzeile */}
|
{/* Kopfzeile */}
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Bestellnummer</p>
|
<p className="text-xs text-slate-500 uppercase tracking-wider">Anfragenummer</p>
|
||||||
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
|
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-right">
|
<div className="space-y-1 text-right">
|
||||||
@@ -113,7 +113,7 @@ export default async function MyOrdersPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<span
|
<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"
|
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
|
||||||
>
|
>
|
||||||
{item.product_name}
|
{item.product_name}
|
||||||
@@ -136,7 +136,7 @@ export default async function MyOrdersPage() {
|
|||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||||
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="w-3 h-3 mr-1" /> Rechnung ansehen
|
<ExternalLink className="w-3 h-3 mr-1" /> Anfrage ansehen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||||
import { OrderWizard } from '@/components/order-wizard'
|
import { OrderWizard } from '@/components/order-wizard'
|
||||||
@@ -31,19 +32,48 @@ async function OrderDataWrapper() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect('/auth/login')
|
redirect('/auth/login?next=/order')
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = await getProducts()
|
// Check user role
|
||||||
const categories = await getCategories()
|
const { data: userData } = await supabase
|
||||||
const endCustomers = await getEndCustomers()
|
.from('users')
|
||||||
|
.select('role')
|
||||||
// Fetch profile if exists
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', user.id)
|
.eq('id', user.id)
|
||||||
.single()
|
.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} />
|
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight } from 'lucide-react'
|
import { CheckCircle2, Download, ExternalLink, ShoppingBag, ArrowRight, Calendar } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
@@ -50,27 +50,27 @@ export default async function OrderSuccessPage({
|
|||||||
<CheckCircle2 className="w-12 h-12 text-green-400" />
|
<CheckCircle2 className="w-12 h-12 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||||
Bestellung eingegangen!
|
Anfrage eingegangen!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-400 text-lg">
|
<p className="text-slate-400 text-lg">
|
||||||
Ihre Bestellung wurde erfolgreich gespeichert.
|
Ihre Anfrage wurde erfolgreich gespeichert.
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-full px-5 py-2 font-mono text-lg font-bold text-primary">
|
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-full px-5 py-2 font-mono text-lg font-bold text-primary">
|
||||||
#{o.order_number}
|
#{o.order_number}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bestell-Details */}
|
{/* Anfrage-Details */}
|
||||||
<Card className="glass-dark border-white/10">
|
<Card className="glass-dark border-white/10">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-white">Bestellübersicht</CardTitle>
|
<CardTitle className="text-white">Anfrageübersicht</CardTitle>
|
||||||
<Badge
|
<Badge
|
||||||
className={
|
className={
|
||||||
o.status === 'active'
|
o.status === 'active'
|
||||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||||
: o.status === 'cancelled'
|
: o.status === 'cancelled'
|
||||||
? 'bg-red-500/20 text-red-400 border-red-500/30'
|
? 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{statusLabel[o.status] ?? o.status}
|
{statusLabel[o.status] ?? o.status}
|
||||||
@@ -79,8 +79,8 @@ export default async function OrderSuccessPage({
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Produkte */}
|
{/* Produkte */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((item, i) => (
|
{items.map((item) => (
|
||||||
<div key={i} className="space-y-1">
|
<div key={item.product_id} className="space-y-1">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="font-semibold text-white">{item.product_name}</span>
|
<span className="font-semibold text-white">{item.product_name}</span>
|
||||||
<span className="text-white">
|
<span className="text-white">
|
||||||
@@ -90,8 +90,8 @@ export default async function OrderSuccessPage({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{item.selected_modules.map((mod, j) => (
|
{item.selected_modules.map((mod) => (
|
||||||
<div key={j} className="flex justify-between text-xs pl-4 text-slate-400">
|
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||||
<span>+ {mod.module_name}</span>
|
<span>+ {mod.module_name}</span>
|
||||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +110,33 @@ export default async function OrderSuccessPage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{o.order_data?.price_multiplier !== null && o.order_data?.price_multiplier !== undefined && (
|
||||||
|
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-3 rounded-lg border border-green-500/20 space-y-1">
|
||||||
|
<p>Update-Rabatt angewendet:</p>
|
||||||
|
<p className="text-xs font-normal text-slate-300">
|
||||||
|
{o.order_data.price_multiplier === 0
|
||||||
|
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
|
||||||
|
: o.order_data.price_multiplier === 0.15
|
||||||
|
? "Update (13-14 Monate): 15% vom Listenpreis"
|
||||||
|
: o.order_data.price_multiplier === 0.30
|
||||||
|
? "Update (15-25 Monate): 30% vom Listenpreis"
|
||||||
|
: o.order_data.price_multiplier === 0.45
|
||||||
|
? "Update (26-37 Monate): 45% vom Listenpreis"
|
||||||
|
: o.order_data.price_multiplier === 0.60
|
||||||
|
? "Update (38-49 Monate): 60% vom Listenpreis"
|
||||||
|
: o.order_data.price_multiplier === 0.90
|
||||||
|
? "Neukauf (>= 50 Monate): 10% Rabatt"
|
||||||
|
: `Gebühr: ${o.order_data.price_multiplier * 100}%`}
|
||||||
|
</p>
|
||||||
|
{o.order_data.last_license_date && (
|
||||||
|
<p className="text-xs font-normal text-slate-400 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-primary" />
|
||||||
|
Letzte Lizenz vom: {new Date(o.order_data.last_license_date).toLocaleDateString('de-DE')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
|
|
||||||
{/* Kundendaten */}
|
{/* Kundendaten */}
|
||||||
@@ -124,7 +151,7 @@ export default async function OrderSuccessPage({
|
|||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
|
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
|
||||||
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="w-4 h-4 mr-2" /> Rechnung ansehen
|
<ExternalLink className="w-4 h-4 mr-2" /> Angebotsbestätigung ansehen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" asChild>
|
<Button variant="secondary" size="sm" asChild>
|
||||||
|
|||||||
@@ -1,108 +1,17 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import Link from 'next/link'
|
import { HomeClient } from '@/components/HomeClient'
|
||||||
import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function Home() {
|
export const dynamic = "force-dynamic"
|
||||||
return (
|
|
||||||
<div className="flex flex-col min-h-screen bg-[#020617] text-white">
|
|
||||||
<div className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-400 font-medium tracking-wide z-50">
|
|
||||||
⚠️ Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
|
|
||||||
</div>
|
|
||||||
<header className="px-4 lg:px-6 h-16 flex items-center border-b border-white/5 backdrop-blur-md sticky top-0 z-50">
|
|
||||||
<Link className="flex items-center justify-center gap-2" href="#">
|
|
||||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
|
||||||
<Rocket className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="font-bold text-xl tracking-tighter">CASPOS Store</span>
|
|
||||||
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
|
||||||
Demo
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<nav className="ml-auto flex gap-4 sm:gap-6 items-center">
|
|
||||||
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/order">
|
|
||||||
Bestellen
|
|
||||||
</Link>
|
|
||||||
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-customers">
|
|
||||||
Meine Kunden
|
|
||||||
</Link>
|
|
||||||
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/my-orders">
|
|
||||||
Meine Bestellungen
|
|
||||||
</Link>
|
|
||||||
<Link className="text-sm font-medium hover:text-primary transition-colors" href="/admin/products">
|
|
||||||
Admin
|
|
||||||
</Link>
|
|
||||||
<Button variant="outline" className="border-white/10 hover:bg-white/5">
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="flex-1">
|
|
||||||
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48 px-4 overflow-hidden relative">
|
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-primary/20 blur-[120px] rounded-full -z-10 opacity-30" />
|
|
||||||
<div className="container mx-auto">
|
|
||||||
<div className="flex flex-col items-center space-y-4 text-center">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="inline-block rounded-full bg-amber-500/10 px-3 py-1 text-sm text-amber-400 font-medium border border-amber-500/20 mb-4">
|
|
||||||
⚠️ Demo-Umgebung für Testbestellungen
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
|
|
||||||
CASPOS <span className="text-gradient">Software-Bestellung</span>
|
|
||||||
</h1>
|
|
||||||
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
|
|
||||||
Modulare Lösungen, einfaches Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-x-4 mt-8">
|
|
||||||
<Link href="/order">
|
|
||||||
<Button size="lg" className="h-12 px-8 text-lg font-semibold shadow-lg shadow-primary/20 transition-all hover:scale-105">
|
|
||||||
Jetzt bestellen <ArrowRight className="ml-2 h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/contact">
|
|
||||||
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
|
|
||||||
Contact Us
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]">
|
export default async function Home() {
|
||||||
<div className="container mx-auto px-4 md:px-6">
|
let user = null
|
||||||
<div className="grid gap-12 lg:grid-cols-3">
|
try {
|
||||||
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
|
const supabase = await createClient()
|
||||||
<div className="p-3 bg-primary/10 rounded-xl">
|
const { data } = await supabase.auth.getUser()
|
||||||
<Cpu className="h-8 w-8 text-primary" />
|
user = data.user
|
||||||
</div>
|
} catch (e) {
|
||||||
<h3 className="text-xl font-bold">Handel</h3>
|
console.error("Failed to fetch user in page.tsx:", e)
|
||||||
<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">
|
return <HomeClient initialUser={user} />
|
||||||
<div className="p-3 bg-primary/10 rounded-xl">
|
|
||||||
<Shield className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold">Gastronomie</h3>
|
|
||||||
<p className="text-slate-400">Egal ob mobiles Ordern, einer Küchen-Anzeige oder einen Pick-Up-Bord. Wir können es.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
|
|
||||||
<div className="p-3 bg-primary/10 rounded-xl">
|
|
||||||
<Rocket className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
|
|
||||||
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-white/5 bg-[#020617]">
|
|
||||||
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
|
||||||
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
|
|
||||||
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Impressum</Link>
|
|
||||||
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
34
shop/components/DemoWrapper.tsx
Normal file
34
shop/components/DemoWrapper.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
38
shop/components/HeaderWrapper.tsx
Normal file
38
shop/components/HeaderWrapper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
402
shop/components/HomeClient.tsx
Normal file
402
shop/components/HomeClient.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { type User } from '@supabase/supabase-js'
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
ShoppingCart,
|
||||||
|
Utensils,
|
||||||
|
Layers,
|
||||||
|
Users,
|
||||||
|
ClipboardList,
|
||||||
|
Zap,
|
||||||
|
ArrowUpRight,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
Activity
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface HomeClientProps {
|
||||||
|
initialUser: User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeClient({ initialUser }: HomeClientProps) {
|
||||||
|
const [user, setUser] = useState<User | null>(initialUser)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Listen to client-side auth state changes to stay in sync
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
setUser(session?.user ?? null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 20, opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
type: "spring" as const,
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||||
|
|
||||||
|
{/* Background Decorative Animated Blobs */}
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.15, 0.95, 1],
|
||||||
|
rotate: [0, 90, 180, 270, 360],
|
||||||
|
x: [0, 40, -30, 0],
|
||||||
|
y: [0, -20, 50, 0]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 20,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
scale: [1, 0.9, 1.1, 1],
|
||||||
|
rotate: [0, -90, -180, -270, -360],
|
||||||
|
x: [0, -30, 40, 0],
|
||||||
|
y: [0, 50, -30, 0]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 25,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={containerVariants}
|
||||||
|
className="container max-w-6xl mx-auto flex flex-col items-center text-center space-y-8"
|
||||||
|
>
|
||||||
|
{/* Badge */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-xs sm:text-sm text-blue-400 font-semibold tracking-wide backdrop-blur-md shadow-[0_0_15px_rgba(59,130,246,0.1)]"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<Zap className="w-3.5 h-3.5 text-blue-400 fill-blue-400/20" />
|
||||||
|
</motion.div>
|
||||||
|
CASPOS Lizenz-Order-Portal
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Headline */}
|
||||||
|
<div className="space-y-4 max-w-4xl">
|
||||||
|
<motion.h1
|
||||||
|
variants={itemVariants}
|
||||||
|
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight bg-clip-text text-transparent bg-gradient-to-b from-white via-slate-100 to-slate-400"
|
||||||
|
>
|
||||||
|
POS Software <span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-400 via-indigo-400 to-purple-500 font-black">Lizenzen & Module</span>
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
variants={itemVariants}
|
||||||
|
className="mx-auto max-w-[750px] text-slate-400 text-base sm:text-lg md:text-xl font-normal leading-relaxed"
|
||||||
|
>
|
||||||
|
Erwerben und verwalten Sie digitale Lizenzen für CASPOS Kassensysteme.
|
||||||
|
Sofortige Aktivierung und automatische Bereitstellung für Ihre Kunden.
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero CTAs */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="flex flex-col sm:flex-row items-center justify-center gap-4 w-full max-w-md pt-4"
|
||||||
|
>
|
||||||
|
<Link href="/order" className="w-full sm:w-auto">
|
||||||
|
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-bold transition-all duration-300 hover:scale-[1.02] bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-[0_0_25px_rgba(37,99,235,0.25)] hover:shadow-[0_0_35px_rgba(37,99,235,0.45)] border-none rounded-xl">
|
||||||
|
Lizenz bestellen <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer" className="w-full sm:w-auto">
|
||||||
|
<Button size="lg" variant="outline" className="w-full sm:w-auto h-12 px-8 text-base font-semibold transition-all duration-300 hover:bg-white/5 border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white rounded-xl backdrop-blur-sm">
|
||||||
|
Fragen? Kontakt
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Quick Access Actions Bar */}
|
||||||
|
{mounted && user && (
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="w-full max-w-3xl pt-12"
|
||||||
|
>
|
||||||
|
<div className="relative p-6 rounded-2xl border border-slate-900/80 bg-slate-950/40 backdrop-blur-xl shadow-2xl">
|
||||||
|
{/* Card border glow decoration */}
|
||||||
|
<div className="absolute inset-0 rounded-2xl border border-blue-500/10 pointer-events-none" />
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
|
||||||
|
Mein Dashboard Schnellzugriff
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Link href="/my-customers" className="group">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -4, scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||||
|
<Users className="w-5.5 h-5.5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Kundenliste</span>
|
||||||
|
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Kunden & Lizenzen verwalten</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/my-orders" className="group">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -4, scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||||
|
<ClipboardList className="w-5.5 h-5.5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Bestellungen</span>
|
||||||
|
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Aufträge & Status einsehen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Grid of modules */}
|
||||||
|
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50 relative">
|
||||||
|
<div className="container max-w-6xl mx-auto px-4 relative">
|
||||||
|
|
||||||
|
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="text-center space-y-3 mb-16">
|
||||||
|
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 border border-indigo-500/20 px-3 py-1 rounded-full">
|
||||||
|
<Sparkles className="w-3 h-3" /> System-Module
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||||
|
Verfügbare CASPOS-Kassen
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
|
||||||
|
Wählen Sie die passende Kasse für Ihren Kunden aus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Card 1 */}
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -6 }}
|
||||||
|
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Highlight line hover effect */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
<div className="space-y-4 relative">
|
||||||
|
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
|
||||||
|
<ShoppingCart className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Handel & ERP</h3>
|
||||||
|
<p className="text-slate-400 text-sm leading-relaxed">
|
||||||
|
Erweiterungen für Handelskassen, Bestandsführung und vollwertige Warenwirtschaftssysteme (ERP).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Card 2 */}
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -6 }}
|
||||||
|
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Highlight line hover effect */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
<div className="space-y-4 relative">
|
||||||
|
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
|
||||||
|
<Utensils className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Gastronomie</h3>
|
||||||
|
<p className="text-slate-400 text-sm leading-relaxed">
|
||||||
|
Lizenzen für mobiles Bestellen (Orderman), digitale Küchen-Monitore und interaktive Abhol-Boards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Card 3 */}
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -6 }}
|
||||||
|
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Highlight line hover effect */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||||
|
<div className="space-y-4 relative">
|
||||||
|
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
|
||||||
|
<Layers className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Hybride Systeme</h3>
|
||||||
|
<p className="text-slate-400 text-sm leading-relaxed">
|
||||||
|
Das Beste aus beiden Welten. Nahtlose Kombination von Handel- und Gastronomie-Funktionalitäten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stepper info */}
|
||||||
|
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
|
||||||
|
<div className="container max-w-5xl mx-auto px-4">
|
||||||
|
<div className="text-center space-y-3 mb-16">
|
||||||
|
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
|
||||||
|
<Activity className="w-3 h-3" /> Ablauf
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
|
||||||
|
Wie funktioniert die Bereitstellung?
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Connection line for Desktop */}
|
||||||
|
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
|
||||||
|
|
||||||
|
<div className="grid gap-8 md:grid-cols-3 relative z-10">
|
||||||
|
{/* Step 1 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||||
|
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Kunde wählen</h4>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||||
|
Gewünschten Kunden auswählen, der die Lizenz erhalten soll.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||||
|
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Lizenz wählen</h4>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||||
|
Gewünschte Lizenzen für den Kunden auswählen und den Bestellung abschließen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 3 */}
|
||||||
|
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||||
|
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Live gehen</h4>
|
||||||
|
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||||
|
Nach dem Erhalt der Lizenz Live gehen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Core values / Features list */}
|
||||||
|
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50">
|
||||||
|
<div className="container max-w-5xl mx-auto px-4">
|
||||||
|
<div className="grid gap-8 md:grid-cols-2 items-center">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" /> Sicherheit & Stabilität
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||||
|
Verlässliche Plattform für Lizenz-Aktivierungen
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-400 text-sm sm:text-base leading-relaxed">
|
||||||
|
Unsere Kassenlösungen sind GoBD-konform und offline-fähig. Über den Lizenz-Management-Server werden alle Lizenzen und Module verschlüsselt übertragen und sind innerhalb weniger Millisekunden auf der Kasse aktiv.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||||
|
<h5 className="font-bold text-sm text-slate-200">GoBD Konformität</h5>
|
||||||
|
<p className="text-xs text-slate-400">Konform nach allen aktuellen steuerrechtlichen Anforderungen.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||||
|
<h5 className="font-bold text-sm text-slate-200">Same-Day-Bearbeitung</h5>
|
||||||
|
<p className="text-xs text-slate-400">Bearbeitung und Erstellung Ihrer Lizenzen noch am selben Werktag.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||||
|
<h5 className="font-bold text-sm text-slate-200">Verschlüsselte Keys</h5>
|
||||||
|
<p className="text-xs text-slate-400">Sichere Lizenz-Authentifizierung direkt auf der Kasse.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||||
|
<h5 className="font-bold text-sm text-slate-200">Partner Support</h5>
|
||||||
|
<p className="text-xs text-slate-400">Schnelle Hilfe und kompetente Ansprechpartner für Händler.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
|
||||||
|
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
||||||
|
<nav className="flex gap-6">
|
||||||
|
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
|
||||||
|
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
shop/components/Navbar.tsx
Normal file
60
shop/components/Navbar.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
298
shop/components/NavbarClient.tsx
Normal file
298
shop/components/NavbarClient.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import { type User } from "@supabase/supabase-js";
|
||||||
|
import { Rocket, Menu, X, User as UserIcon, LogOut } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { signOut } from "@/lib/actions/auth";
|
||||||
|
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||||
|
import { DemoWrapper } from "./DemoWrapper";
|
||||||
|
|
||||||
|
interface NavbarClientProps {
|
||||||
|
user: User | null;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||||
|
const [userRole, setUserRole] = useState<string>(role);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pathname, setPathname] = useState("/");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPathname(window.location.pathname);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
setUserRole(role);
|
||||||
|
}, [user, role]);
|
||||||
|
|
||||||
|
// Supabase URL & Anon Key auslesen (für Browser-Client)
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Browser Client inline erstellen
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
|
supabaseAnonKey!,
|
||||||
|
{
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auf Auth-Statusänderungen reagieren
|
||||||
|
useEffect(() => {
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
|
setCurrentUser(session?.user ?? null);
|
||||||
|
if (session?.user) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("role")
|
||||||
|
.eq("id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (data) {
|
||||||
|
setUserRole(data.role);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUserRole("partner");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserRole("partner");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fehler beim Server-Signout:", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fehler beim Client-Signout:", e);
|
||||||
|
}
|
||||||
|
setCurrentUser(null);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
|
||||||
|
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||||
|
<span className="font-bold text-xl tracking-tighter text-white">CASPOS Shop</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>
|
||||||
|
<Link
|
||||||
|
href="/my-orders"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Bestellungen
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<hr className="my-1 border-slate-100 dark:border-white/5" />
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm font-bold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button asChild className="bg-primary hover:bg-primary/95 text-white dark:text-blue-500">
|
||||||
|
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Hamburger Trigger */}
|
||||||
|
<button
|
||||||
|
className="md:hidden p-2 text-slate-300 hover:text-white focus:outline-none"
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Menu Panel */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="md:hidden fixed inset-x-0 top-16 bg-[#020617] border-b border-white/5 z-40 py-4 px-6 flex flex-col gap-4 animate-in slide-in-from-top duration-200">
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/order"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Bestellen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="https://caspos.de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Wiki
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="https://caspos.de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Support
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-white/5 my-1" />
|
||||||
|
<div className="text-xs text-slate-500 px-1">Mein Account ({currentUser.email})</div>
|
||||||
|
{userRole === "admin" ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/admin/einstellungen"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/my-customers"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Kundenliste
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/my-orders"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Bestellungen
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="h-px bg-white/5 my-1" />
|
||||||
|
|
||||||
|
{currentUser ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" /> Abmelden
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white dark:text-blue-500" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
@@ -54,6 +55,7 @@ export function CategoryDialog({
|
|||||||
category?: Category
|
category?: Category
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const form = useForm<CategoryFormValues>({
|
const form = useForm<CategoryFormValues>({
|
||||||
resolver: zodResolver(categorySchema) as any,
|
resolver: zodResolver(categorySchema) as any,
|
||||||
@@ -75,6 +77,7 @@ export function CategoryDialog({
|
|||||||
}
|
}
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
form.reset()
|
form.reset()
|
||||||
|
router.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving category:', error)
|
console.error('Error saving category:', error)
|
||||||
}
|
}
|
||||||
@@ -96,10 +99,10 @@ export function CategoryDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</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>
|
<DialogHeader>
|
||||||
<DialogTitle>{category ? 'Kategorie bearbeiten' : 'Neue Kategorie erstellen'}</DialogTitle>
|
<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.
|
Definieren Sie eine Kategorie für Ihre Produkte.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -113,9 +116,9 @@ export function CategoryDialog({
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Name</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Name</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -126,9 +129,9 @@ export function CategoryDialog({
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Beschreibung</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -139,16 +142,16 @@ export function CategoryDialog({
|
|||||||
name="icon"
|
name="icon"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Icon</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Icon</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<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" />
|
<SelectValue placeholder="Icon wählen" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</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 }) => (
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
{name}
|
{name}
|
||||||
@@ -166,18 +169,18 @@ export function CategoryDialog({
|
|||||||
name="sort_order"
|
name="sort_order"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Reihenfolge im Bestellvorgang</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Reihenfolge im Bestellvorgang</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="0 = zuerst"
|
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}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<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>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -185,7 +188,7 @@ export function CategoryDialog({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="is_required"
|
name="is_required"
|
||||||
render={({ field }) => (
|
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>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
@@ -193,10 +196,10 @@ export function CategoryDialog({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="space-y-1 leading-none">
|
<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
|
Pflichtauswahl im Bestellvorgang
|
||||||
</FormLabel>
|
</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 aktiv, muss der Kunde aus dieser Kategorie einen Artikel wählen.
|
||||||
Wenn deaktiviert, ist die Kategorie optional.
|
Wenn deaktiviert, ist die Kategorie optional.
|
||||||
</p>
|
</p>
|
||||||
@@ -207,7 +210,7 @@ export function CategoryDialog({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<DialogFooter className="pt-4">
|
<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'}
|
{category ? 'Aktualisieren' : 'Erstellen'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ import { Edit2, Trash2, Cloud, Utensils, HardDrive, Package, ArrowUpDown } from
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { deleteCategory } from '@/lib/actions/products'
|
import { deleteCategory } from '@/lib/actions/products'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { CategoryDialog } from './category-dialog'
|
import { CategoryDialog } from './category-dialog'
|
||||||
|
|
||||||
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
|
export function CategoryList({ initialCategories }: { initialCategories: Category[] }) {
|
||||||
const [categories, setCategories] = useState(initialCategories)
|
const [categories, setCategories] = useState(initialCategories)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCategories(initialCategories)
|
||||||
|
}, [initialCategories])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
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.')) {
|
if (confirm('Möchten Sie diese Kategorie wirklich löschen? Alle zugehörigen Produkte werden nicht mehr dieser Kategorie zugeordnet sein.')) {
|
||||||
await deleteCategory(id)
|
await deleteCategory(id)
|
||||||
@@ -37,39 +41,39 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
<CardHeader>
|
||||||
<CardTitle>Kategorienübersicht</CardTitle>
|
<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.
|
Verwalten Sie die Hauptkategorien für Ihre Produkte.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-white/10 hover:bg-white/5">
|
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
|
||||||
<TableHead className="text-white font-bold w-10">Icon</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold w-10">Icon</TableHead>
|
||||||
<TableHead className="text-white font-bold">Name</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
|
||||||
<TableHead className="text-white font-bold">Beschreibung</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold">Beschreibung</TableHead>
|
||||||
<TableHead className="text-white font-bold w-28">
|
<TableHead className="text-slate-700 dark:text-white font-bold w-28">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<ArrowUpDown className="w-3 h-3" /> Reihenfolge
|
<ArrowUpDown className="w-3 h-3" /> Reihenfolge
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-white font-bold w-24">Typ</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold w-24">Typ</TableHead>
|
||||||
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
|
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{categories.map((category) => (
|
{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>
|
<TableCell>
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||||
{getIcon(category.icon)}
|
{getIcon(category.icon)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium text-white">{category.name}</TableCell>
|
<TableCell className="font-medium text-slate-900 dark:text-white">{category.name}</TableCell>
|
||||||
<TableCell className="text-slate-300">{category.description}</TableCell>
|
<TableCell className="text-slate-700 dark:text-slate-300">{category.description}</TableCell>
|
||||||
<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">
|
<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}
|
{category.sort_order}
|
||||||
@@ -77,15 +81,15 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{category.is_required ? (
|
{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>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<CategoryDialog category={category}>
|
<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" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CategoryDialog>
|
</CategoryDialog>
|
||||||
@@ -103,7 +107,7 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
|||||||
))}
|
))}
|
||||||
{categories.length === 0 && (
|
{categories.length === 0 && (
|
||||||
<TableRow>
|
<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.
|
Keine Kategorien gefunden.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { useForm, useFieldArray } from 'react-hook-form'
|
import { useForm, useFieldArray } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
@@ -39,12 +40,14 @@ import {
|
|||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
|
||||||
const moduleSchema = z.object({
|
const moduleSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
||||||
is_required: z.boolean().default(false),
|
is_required: z.boolean().default(false),
|
||||||
requirements: z.string().optional().default(''), // Will be converted to array
|
has_quantity: z.boolean().default(false),
|
||||||
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({
|
const productSchema = z.object({
|
||||||
@@ -53,13 +56,30 @@ const productSchema = z.object({
|
|||||||
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
|
||||||
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
|
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
|
||||||
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
|
billing_interval: z.enum(['one_time', 'monthly']).default('monthly'),
|
||||||
|
show_in_kauf: z.boolean().default(true),
|
||||||
|
show_in_abo: z.boolean().default(true),
|
||||||
|
requirements: z.array(z.string()).default([]),
|
||||||
|
exclusions: z.array(z.string()).default([]),
|
||||||
modules: z.array(moduleSchema).default([]),
|
modules: z.array(moduleSchema).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProductFormValues = z.infer<typeof productSchema>
|
type ProductFormValues = z.infer<typeof productSchema>
|
||||||
|
|
||||||
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
|
export function CreateProductDialog({
|
||||||
|
children,
|
||||||
|
categories,
|
||||||
|
product,
|
||||||
|
products = []
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
categories: Category[]
|
||||||
|
product?: Product
|
||||||
|
products?: Product[]
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const otherProducts = products.filter(p => p.id !== product?.id)
|
||||||
|
|
||||||
const form = useForm<ProductFormValues>({
|
const form = useForm<ProductFormValues>({
|
||||||
resolver: zodResolver(productSchema) as any,
|
resolver: zodResolver(productSchema) as any,
|
||||||
@@ -69,13 +89,19 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
base_price: product?.base_price || 0,
|
base_price: product?.base_price || 0,
|
||||||
category_id: product?.category_id || '',
|
category_id: product?.category_id || '',
|
||||||
billing_interval: product?.billing_interval || 'monthly',
|
billing_interval: product?.billing_interval || 'monthly',
|
||||||
|
show_in_kauf: product ? (product.show_in_kauf ?? true) : true,
|
||||||
|
show_in_abo: product ? (product.show_in_abo ?? true) : true,
|
||||||
|
requirements: product?.requirements || [],
|
||||||
|
exclusions: product?.exclusions || [],
|
||||||
modules: product?.modules?.map(m => ({
|
modules: product?.modules?.map(m => ({
|
||||||
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description || '',
|
description: m.description || '',
|
||||||
price: m.price,
|
price: m.price,
|
||||||
is_required: false,
|
is_required: false,
|
||||||
requirements: m.requirements?.join(', ') || '',
|
has_quantity: m.has_quantity ?? false,
|
||||||
exclusions: m.exclusions?.join(', ') || '',
|
requirements: m.requirements || [],
|
||||||
|
exclusions: m.exclusions || [],
|
||||||
})) || [],
|
})) || [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -90,8 +116,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
const { modules, ...productData } = values
|
const { modules, ...productData } = values
|
||||||
const formattedModules = modules.map(m => ({
|
const formattedModules = modules.map(m => ({
|
||||||
...m,
|
...m,
|
||||||
requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [],
|
id: m.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15)),
|
||||||
exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (product) {
|
if (product) {
|
||||||
@@ -102,6 +127,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
if (!product) form.reset()
|
if (!product) form.reset()
|
||||||
|
router.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving product:', error)
|
console.error('Error saving product:', error)
|
||||||
}
|
}
|
||||||
@@ -116,29 +142,28 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</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>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl font-bold">
|
<DialogTitle className="text-2xl font-bold">
|
||||||
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
|
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
|
||||||
</DialogTitle>
|
</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.
|
Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 flex-1 overflow-hidden flex flex-col">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 overflow-hidden flex-1 min-h-0">
|
||||||
<ScrollArea className="flex-1 max-h-[60vh] pr-4">
|
<div className="flex-1 overflow-y-auto pr-4 space-y-6 py-4 min-h-0">
|
||||||
<div className="space-y-6 py-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Produktname</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -149,9 +174,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
name="base_price"
|
name="base_price"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Basispreis (€)</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Basispreis (€)</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -164,16 +189,16 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
name="category_id"
|
name="category_id"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Kategorie</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<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="Kategorie wählen" className="text-white" />
|
<SelectValue placeholder="Kategorie wählen" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</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 => (
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -187,7 +212,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
name="billing_interval"
|
name="billing_interval"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<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">
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||||
{[
|
{[
|
||||||
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
|
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
|
||||||
@@ -199,8 +224,8 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
onClick={() => field.onChange(opt.value)}
|
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 ${
|
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||||
field.value === opt.value
|
field.value === opt.value
|
||||||
? 'border-primary bg-primary/10 text-white'
|
? 'border-primary bg-primary/10 text-primary dark:text-white'
|
||||||
: 'border-white/10 bg-white/5 text-slate-400 hover:bg-white/10'
|
: '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>
|
<span className="text-lg">{opt.icon}</span>
|
||||||
@@ -213,21 +238,154 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="show_in_kauf"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||||
|
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="show_in_abo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||||
|
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Beschreibung</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator className="bg-white/10" />
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Product Requirements */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requirements"
|
||||||
|
render={({ field: reqField }) => (
|
||||||
|
<FormItem className="flex flex-col gap-1.5">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-medium">Benötigte Artikel</FormLabel>
|
||||||
|
<ScrollArea className="h-32 border border-slate-200 dark:border-white/10 rounded p-3 bg-slate-50 dark:bg-white/5">
|
||||||
|
{otherProducts.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 italic">Keine anderen Artikel vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{otherProducts.map(other => {
|
||||||
|
const checked = (reqField.value || []).includes(other.id)
|
||||||
|
return (
|
||||||
|
<div key={other.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`prod-req-${other.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(checkedState) => {
|
||||||
|
const currentVal = reqField.value || []
|
||||||
|
if (checkedState) {
|
||||||
|
reqField.onChange([...currentVal, other.id])
|
||||||
|
} else {
|
||||||
|
reqField.onChange(currentVal.filter(id => id !== other.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`prod-req-${other.id}`}
|
||||||
|
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block"
|
||||||
|
title={other.name}
|
||||||
|
>
|
||||||
|
{other.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Product Exclusions */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="exclusions"
|
||||||
|
render={({ field: exclField }) => (
|
||||||
|
<FormItem className="flex flex-col gap-1.5">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-medium">Schließt aus</FormLabel>
|
||||||
|
<ScrollArea className="h-32 border border-slate-200 dark:border-white/10 rounded p-3 bg-slate-50 dark:bg-white/5">
|
||||||
|
{otherProducts.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 italic">Keine anderen Artikel vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{otherProducts.map(other => {
|
||||||
|
const checked = (exclField.value || []).includes(other.id)
|
||||||
|
return (
|
||||||
|
<div key={other.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`prod-excl-${other.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(checkedState) => {
|
||||||
|
const currentVal = exclField.value || []
|
||||||
|
if (checkedState) {
|
||||||
|
exclField.onChange([...currentVal, other.id])
|
||||||
|
} else {
|
||||||
|
exclField.onChange(currentVal.filter(id => id !== other.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`prod-excl-${other.id}`}
|
||||||
|
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block"
|
||||||
|
title={other.name}
|
||||||
|
>
|
||||||
|
{other.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-slate-200 dark:bg-white/10" />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -240,116 +398,211 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||||
onClick={() => append({ name: '', price: 0, is_required: false, description: '', requirements: '', exclusions: '' })}
|
onClick={() => append({
|
||||||
|
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||||
|
name: '',
|
||||||
|
price: 0,
|
||||||
|
is_required: false,
|
||||||
|
has_quantity: false,
|
||||||
|
description: '',
|
||||||
|
requirements: [],
|
||||||
|
exclusions: []
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.map((field, index) => (
|
{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">
|
const allModules = form.watch('modules') || []
|
||||||
<Button
|
const otherModules = allModules
|
||||||
type="button"
|
.map((m, idx) => ({
|
||||||
variant="ghost"
|
id: m.id || field.id,
|
||||||
size="icon"
|
name: m.name || `Modul ${idx + 1}`,
|
||||||
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)}
|
.filter((_, idx) => idx !== index)
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
return (
|
||||||
<FormField
|
<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">
|
||||||
control={form.control}
|
<Button
|
||||||
name={`modules.${index}.name`}
|
type="button"
|
||||||
render={({ field }) => (
|
variant="ghost"
|
||||||
<FormItem>
|
size="icon"
|
||||||
<FormLabel className="text-white">Modulname</FormLabel>
|
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
<FormControl>
|
onClick={() => remove(index)}
|
||||||
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
|
>
|
||||||
</FormControl>
|
<X className="h-4 w-4" />
|
||||||
<FormMessage />
|
</Button>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`modules.${index}.requirements`}
|
name={`modules.${index}.name`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Modulname</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`modules.${index}.exclusions`}
|
name={`modules.${index}.price`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Aufpreis (€)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<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">
|
||||||
<FormField
|
{/* Requirements Selection Matrix */}
|
||||||
control={form.control}
|
<FormField
|
||||||
name={`modules.${index}.is_required`}
|
control={form.control}
|
||||||
render={({ field }) => (
|
name={`modules.${index}.requirements`}
|
||||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
render={({ field: reqField }) => (
|
||||||
<FormControl>
|
<div className="space-y-2">
|
||||||
<Checkbox
|
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Benötigt Module</FormLabel>
|
||||||
checked={field.value}
|
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||||
onCheckedChange={field.onChange}
|
{otherModules.length === 0 ? (
|
||||||
/>
|
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||||
</FormControl>
|
) : (
|
||||||
<div className="space-y-1 leading-none">
|
<div className="space-y-1.5">
|
||||||
<FormLabel className="text-white">Erforderlich</FormLabel>
|
{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>
|
</div>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
|
{/* Exclusions Selection Matrix */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`modules.${index}.exclusions`}
|
||||||
|
render={({ field: exclField }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Schließt aus</FormLabel>
|
||||||
|
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||||
|
{otherModules.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{otherModules.map(other => {
|
||||||
|
const checked = (exclField.value || []).includes(other.id)
|
||||||
|
return (
|
||||||
|
<div key={other.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`excl-${index}-${other.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(checkedState) => {
|
||||||
|
const currentVal = exclField.value || []
|
||||||
|
if (checkedState) {
|
||||||
|
exclField.onChange([...currentVal, other.id])
|
||||||
|
} else {
|
||||||
|
exclField.onChange(currentVal.filter(id => id !== other.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`excl-${index}-${other.id}`}
|
||||||
|
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||||
|
title={other.name}
|
||||||
|
>
|
||||||
|
{other.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`modules.${index}.is_required`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">Erforderlich</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`modules.${index}.has_quantity`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer text-xs">Mengeneingabe erlauben</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{fields.length === 0 && (
|
{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.
|
Noch keine Module hinzugefügt.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<DialogFooter className="pt-4 border-t border-white/10">
|
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10">
|
||||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>Abbrechen</Button>
|
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">Abbrechen</Button>
|
||||||
<Button type="submit" className="bg-primary hover:bg-primary/90">Speichern</Button>
|
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">Speichern</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
51
shop/components/admin/logout-menu-item.tsx
Normal file
51
shop/components/admin/logout-menu-item.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
shop/components/admin/orders-table.tsx
Normal file
245
shop/components/admin/orders-table.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Download, ExternalLink, Search, Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { updateOrderStatus } from "@/lib/actions/orders";
|
||||||
|
|
||||||
|
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 [orders, setOrders] = useState(initialOrders);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrders(initialOrders);
|
||||||
|
}, [initialOrders]);
|
||||||
|
|
||||||
|
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled') => {
|
||||||
|
setUpdatingId(orderId);
|
||||||
|
try {
|
||||||
|
await updateOrderStatus(orderId, newStatus);
|
||||||
|
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: newStatus } : o));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert("Fehler beim Ändern des Status.");
|
||||||
|
} finally {
|
||||||
|
setUpdatingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredOrders = orders.filter((order) => {
|
||||||
|
const orderNum = (order.order_number || "").toLowerCase();
|
||||||
|
const company = (order.customer_data?.company_name || "").toLowerCase();
|
||||||
|
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
orderNum.includes(search.toLowerCase()) ||
|
||||||
|
company.includes(search.toLowerCase()) ||
|
||||||
|
contact.includes(search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filterleiste */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
|
||||||
|
<div className="relative w-full md:max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Bestellung, Firma oder Name suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "all" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("all")}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "pending" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("pending")}
|
||||||
|
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
|
||||||
|
>
|
||||||
|
Eingegangen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "active" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("active")}
|
||||||
|
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
|
||||||
|
>
|
||||||
|
In Bearbeitung
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "completed" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("completed")}
|
||||||
|
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
|
||||||
|
>
|
||||||
|
Abgeschlossen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "cancelled" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("cancelled")}
|
||||||
|
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
|
||||||
|
>
|
||||||
|
Storniert
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabelle */}
|
||||||
|
<div className="rounded-xl border border-white/5 bg-black/20 overflow-hidden backdrop-blur-xl">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
|
||||||
|
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Datum</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Kunde</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Auswahl</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Betrag</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Status</TableHead>
|
||||||
|
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredOrders.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
|
||||||
|
Keine Bestellungen gefunden.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredOrders.map((order) => {
|
||||||
|
const items = order.order_data?.items || [];
|
||||||
|
return (
|
||||||
|
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<TableCell className="font-mono text-xs text-primary font-semibold">
|
||||||
|
#{order.order_number || order.id.slice(0, 8)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-300 text-sm">
|
||||||
|
{new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold text-white text-sm">
|
||||||
|
{order.customer_data?.company_name || 'Privatkunde'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{order.customer_data?.first_name} {order.customer_data?.last_name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px]">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{items.map((item: any) => (
|
||||||
|
<span
|
||||||
|
key={item.product_id}
|
||||||
|
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
|
||||||
|
>
|
||||||
|
{item.product_name}
|
||||||
|
{item.selected_modules?.length > 0 && (
|
||||||
|
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-bold text-white text-sm">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild disabled={updatingId === order.id}>
|
||||||
|
<button className="focus:outline-none focus:ring-0 active:scale-95 disabled:pointer-events-none transition-transform duration-100">
|
||||||
|
<Badge className={`text-xs px-2 py-0.5 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
|
||||||
|
{updatingId === order.id ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||||
|
) : null}
|
||||||
|
{statusLabel[order.status] ?? order.status}
|
||||||
|
<span className="text-[9px] opacity-70">▼</span>
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[140px]">
|
||||||
|
{Object.entries(statusLabel).map(([key, label]) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={key}
|
||||||
|
disabled={updatingId === order.id}
|
||||||
|
onClick={() => handleStatusChange(order.id, key as any)}
|
||||||
|
className={`text-xs cursor-pointer hover:bg-white/10 transition-colors focus:bg-white/10 focus:text-white ${order.status === key ? 'font-bold text-primary' : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{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 Bestätigung</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,14 +11,61 @@ import {
|
|||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Edit2, Trash2, Layers } from 'lucide-react'
|
import { Edit2, Trash2, Layers, ArrowUpDown, Copy } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { deleteProduct } from '@/lib/actions/products'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { useState } from 'react'
|
import { deleteProduct, transferModules } from '@/lib/actions/products'
|
||||||
|
import { useState, useEffect, useMemo, Fragment } from 'react'
|
||||||
import { CreateProductDialog } from './create-product-dialog'
|
import { CreateProductDialog } from './create-product-dialog'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||||
const [products, setProducts] = useState(initialProducts)
|
const [products, setProducts] = useState(initialProducts)
|
||||||
|
const [isGroupedByCategory, setIsGroupedByCategory] = useState(false)
|
||||||
|
const [showKauf, setShowKauf] = useState(true)
|
||||||
|
const [showAbo, setShowAbo] = useState(true)
|
||||||
|
const [copySourceProduct, setCopySourceProduct] = useState<Product | null>(null)
|
||||||
|
const [copyTargetProductId, setCopyTargetProductId] = useState<string>('')
|
||||||
|
const [copyMode, setCopyMode] = useState<'overwrite' | 'append'>('overwrite')
|
||||||
|
const [isCopying, setIsCopying] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleCopyModules = async () => {
|
||||||
|
if (!copySourceProduct || !copyTargetProductId) return
|
||||||
|
setIsCopying(true)
|
||||||
|
try {
|
||||||
|
await transferModules(copySourceProduct.id, copyTargetProductId, copyMode)
|
||||||
|
setCopySourceProduct(null)
|
||||||
|
setCopyTargetProductId('')
|
||||||
|
router.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error copying modules:', err)
|
||||||
|
alert('Fehler beim Kopieren der Module.')
|
||||||
|
} finally {
|
||||||
|
setIsCopying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProducts(initialProducts)
|
||||||
|
}, [initialProducts])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
if (confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
||||||
@@ -27,88 +74,238 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const toggleGroupByCategory = () => {
|
||||||
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
|
setIsGroupedByCategory(!isGroupedByCategory)
|
||||||
<CardHeader>
|
}
|
||||||
<CardTitle>Produktübersicht</CardTitle>
|
|
||||||
<CardDescription className="text-slate-400">
|
const filteredProducts = useMemo(() => {
|
||||||
Alle konfigurierten Software-Lösungen und Erweiterungen.
|
return products.filter(p => {
|
||||||
</CardDescription>
|
const inKauf = p.show_in_kauf ?? true
|
||||||
</CardHeader>
|
const inAbo = p.show_in_abo ?? true
|
||||||
<CardContent>
|
if (showKauf && inKauf) return true
|
||||||
<Table>
|
if (showAbo && inAbo) return true
|
||||||
<TableHeader>
|
return false
|
||||||
<TableRow className="border-white/10 hover:bg-white/5">
|
})
|
||||||
<TableHead className="text-white font-bold">Name</TableHead>
|
}, [products, showKauf, showAbo])
|
||||||
<TableHead className="text-white font-bold">Basispreis</TableHead>
|
|
||||||
<TableHead className="text-white font-bold">Module</TableHead>
|
const groupedProducts = useMemo(() => {
|
||||||
<TableHead className="text-white font-bold">Status</TableHead>
|
const groups: Record<string, { category: Category | null; products: Product[] }> = {}
|
||||||
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
|
filteredProducts.forEach(p => {
|
||||||
</TableRow>
|
const catId = p.category_id || 'no-category'
|
||||||
</TableHeader>
|
if (!groups[catId]) {
|
||||||
<TableBody>
|
groups[catId] = {
|
||||||
{products.map((product) => (
|
category: p.category || null,
|
||||||
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
products: []
|
||||||
<TableCell className="font-medium text-white">
|
}
|
||||||
<div className="flex flex-col gap-1">
|
}
|
||||||
<div className="flex items-center gap-2">
|
groups[catId].products.push(p)
|
||||||
{product.name}
|
})
|
||||||
{product.category && (
|
return Object.values(groups).sort((a, b) => {
|
||||||
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary py-0 h-4">
|
const orderA = a.category?.sort_order ?? 9999
|
||||||
{product.category.name}
|
const orderB = b.category?.sort_order ?? 9999
|
||||||
</Badge>
|
return orderA - orderB
|
||||||
)}
|
})
|
||||||
{product.billing_interval === 'monthly' && (
|
}, [filteredProducts])
|
||||||
<Badge className="text-[10px] bg-blue-500/20 text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
|
|
||||||
)}
|
const renderProductRow = (product: Product) => (
|
||||||
{product.billing_interval === 'one_time' && (
|
<TableRow key={product.id} className="border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
|
||||||
<Badge className="text-[10px] bg-slate-500/20 text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
|
<TableCell className="font-medium text-slate-900 dark:text-white">
|
||||||
)}
|
<div className="flex flex-col gap-1">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-slate-300 font-normal">{product.description}</p>
|
{product.name}
|
||||||
</div>
|
{product.category && (
|
||||||
</TableCell>
|
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary py-0 h-4">
|
||||||
<TableCell className="text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
|
{product.category.name}
|
||||||
<TableCell>
|
</Badge>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Layers className="w-3 h-3 text-primary" />
|
|
||||||
<span className="text-slate-200">{product.modules?.length || 0} Module</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
|
|
||||||
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<CreateProductDialog categories={categories} product={product}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CreateProductDialog>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => handleDelete(product.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{products.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-center py-10 text-slate-400">
|
|
||||||
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
{product.billing_interval === 'monthly' && (
|
||||||
</Table>
|
<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>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
{product.billing_interval === 'one_time' && (
|
||||||
|
<Badge className="text-[10px] bg-slate-500/20 text-slate-600 dark:text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 font-normal">{product.description}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-900 dark:text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Layers className="w-3 h-3 text-primary" />
|
||||||
|
<span className="text-slate-700 dark:text-slate-200">{product.modules?.length || 0} Module</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={product.billing_interval === 'monthly' ? 'default' : 'secondary'}>
|
||||||
|
{product.billing_interval === 'monthly' ? 'Abo/Monat' : 'Einmalig'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
setCopySourceProduct(product)
|
||||||
|
setCopyTargetProductId('')
|
||||||
|
}}
|
||||||
|
title="Module kopieren"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<CreateProductDialog categories={categories} product={product} products={products}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CreateProductDialog>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => handleDelete(product.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="bg-white dark:bg-slate-900/50 border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden text-slate-900 dark:text-white">
|
||||||
|
<CardHeader className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Produktübersicht</CardTitle>
|
||||||
|
<CardDescription className="text-slate-500 dark:text-slate-400">
|
||||||
|
Alle konfigurierten Software-Lösungen und Erweiterungen.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
|
||||||
|
<Checkbox
|
||||||
|
checked={showKauf}
|
||||||
|
onCheckedChange={(checked) => setShowKauf(!!checked)}
|
||||||
|
/>
|
||||||
|
In Kaufauswahl
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 cursor-pointer select-none">
|
||||||
|
<Checkbox
|
||||||
|
checked={showAbo}
|
||||||
|
onCheckedChange={(checked) => setShowAbo(!!checked)}
|
||||||
|
/>
|
||||||
|
In Aboauswahl
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleGroupByCategory}
|
||||||
|
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
|
||||||
|
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="w-4 h-4 mr-2" />
|
||||||
|
{isGroupedByCategory ? 'Kategoriengruppierung aktiv' : 'Nach Kategorien gruppieren'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
|
||||||
|
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
|
||||||
|
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
|
||||||
|
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
|
||||||
|
<TableHead className="text-slate-700 dark:text-white font-bold">Status</TableHead>
|
||||||
|
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isGroupedByCategory ? (
|
||||||
|
groupedProducts.map((group) => (
|
||||||
|
<Fragment key={group.category?.id || 'no-category'}>
|
||||||
|
<TableRow className="bg-slate-100 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/40 border-b border-slate-200 dark:border-white/10">
|
||||||
|
<TableCell colSpan={5} className="text-primary font-bold py-2.5 text-xs uppercase tracking-wider">
|
||||||
|
{group.category ? group.category.name : 'Keine Kategorie'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{group.products.map((product) => renderProductRow(product))}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
filteredProducts.map((product) => renderProductRow(product))
|
||||||
|
)}
|
||||||
|
{filteredProducts.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
|
||||||
|
Keine Produkte gefunden. Passen Sie Ihre Filter an.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={!!copySourceProduct} onOpenChange={(open) => { if (!open) setCopySourceProduct(null) }}>
|
||||||
|
<DialogContent className="sm:max-w-[425px] bg-white dark:bg-slate-950 text-slate-900 dark:text-white border-slate-200 dark:border-white/10 shadow-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Module übertragen</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||||
|
Kopieren Sie alle Module von <strong>{copySourceProduct?.name}</strong> auf ein anderes Produkt.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="target-product">Ziel-Produkt</Label>
|
||||||
|
<Select value={copyTargetProductId} onValueChange={setCopyTargetProductId}>
|
||||||
|
<SelectTrigger id="target-product" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||||
|
<SelectValue placeholder="Ziel-Produkt auswählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||||
|
{products
|
||||||
|
.filter(p => p.id !== copySourceProduct?.id)
|
||||||
|
.map(p => (
|
||||||
|
<SelectItem key={p.id} value={p.id} className="focus:bg-slate-100 dark:focus:bg-white/5">
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Übertragungsmodus</Label>
|
||||||
|
<RadioGroup value={copyMode} onValueChange={(val: any) => setCopyMode(val)} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="overwrite" id="mode-overwrite" />
|
||||||
|
<Label htmlFor="mode-overwrite" className="font-normal cursor-pointer text-slate-700 dark:text-slate-300">
|
||||||
|
Bestehende Module überschreiben
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="append" id="mode-append" />
|
||||||
|
<Label htmlFor="mode-append" className="font-normal cursor-pointer text-slate-700 dark:text-slate-300">
|
||||||
|
Module anfügen (bestehende behalten)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCopySourceProduct(null)} disabled={isCopying} className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCopyModules} disabled={!copyTargetProductId || isCopying} className="bg-primary text-white hover:bg-primary/90">
|
||||||
|
{isCopying ? 'Wird kopiert...' : 'Module übertragen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { RevenueChart } from './revenue-chart'
|
||||||
|
|
||||||
type RecentOrder = {
|
type RecentOrder = {
|
||||||
id: string
|
id: string
|
||||||
@@ -34,15 +35,16 @@ const POLL_INTERVAL_MS = 30_000
|
|||||||
|
|
||||||
export function AdminRecentOrders() {
|
export function AdminRecentOrders() {
|
||||||
const [orders, setOrders] = useState<RecentOrder[]>([])
|
const [orders, setOrders] = useState<RecentOrder[]>([])
|
||||||
|
const [chartData, setChartData] = useState<{ name: string; value: number }[]>([])
|
||||||
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
|
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
|
||||||
const [lastChecked, setLastChecked] = useState<Date>(new Date())
|
const [lastChecked, setLastChecked] = useState<Date>(new Date())
|
||||||
const knownIds = useRef<Set<string>>(new Set())
|
const knownIds = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
|
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' });
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const { orders: fresh } = await res.json()
|
const { orders: fresh, chartData: freshChart } = await res.json()
|
||||||
|
|
||||||
// Neue Bestellungen erkennen (Highlight)
|
// Neue Bestellungen erkennen (Highlight)
|
||||||
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
|
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
|
||||||
@@ -53,6 +55,7 @@ export function AdminRecentOrders() {
|
|||||||
knownIds.current = freshIds
|
knownIds.current = freshIds
|
||||||
|
|
||||||
setOrders(fresh)
|
setOrders(fresh)
|
||||||
|
setChartData(freshChart || [])
|
||||||
setNewOrderIds(newOnes)
|
setNewOrderIds(newOnes)
|
||||||
setLastChecked(new Date())
|
setLastChecked(new Date())
|
||||||
|
|
||||||
@@ -75,12 +78,10 @@ export function AdminRecentOrders() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
<Card className="col-span-4 glass-dark border-white/5">
|
<Card className="col-span-4 glass-dark border-white/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Übersicht</CardTitle>
|
<CardTitle>Übersicht (letzte 6 Monate)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pl-2">
|
<CardContent className="pl-2">
|
||||||
<div className="h-[200px] flex items-center justify-center text-slate-400 italic">
|
<RevenueChart data={chartData} />
|
||||||
[Umsatz-Chart – folgt in nächstem Update]
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
168
shop/components/admin/revenue-chart.tsx
Normal file
168
shop/components/admin/revenue-chart.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
type ChartDataPoint = {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenueChart({ data }: { data: ChartDataPoint[] }) {
|
||||||
|
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-slate-500 italic">
|
||||||
|
Keine Umsatzdaten vorhanden.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 500
|
||||||
|
const height = 200
|
||||||
|
const paddingLeft = 55
|
||||||
|
const paddingRight = 15
|
||||||
|
const paddingTop = 25
|
||||||
|
const paddingBottom = 30
|
||||||
|
|
||||||
|
const chartWidth = width - paddingLeft - paddingRight
|
||||||
|
const chartHeight = height - paddingTop - paddingBottom
|
||||||
|
|
||||||
|
const maxVal = Math.max(...data.map(d => d.value), 100)
|
||||||
|
const maxValue = Math.ceil(maxVal * 1.15) // 15% headroom
|
||||||
|
|
||||||
|
const points = data.map((d, i) => {
|
||||||
|
const x = paddingLeft + (i / (data.length - 1)) * chartWidth
|
||||||
|
const y = paddingTop + chartHeight - (d.value / maxValue) * chartHeight
|
||||||
|
return { x, y, name: d.name, value: d.value }
|
||||||
|
})
|
||||||
|
|
||||||
|
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||||
|
const areaPath = points.length > 0
|
||||||
|
? `${linePath} L ${points[points.length - 1].x} ${paddingTop + chartHeight} L ${points[0].x} ${paddingTop + chartHeight} Z`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// 4 ticks for grid
|
||||||
|
const yTicks = [0, 0.33, 0.66, 1].map(ratio => Math.round(maxValue * ratio))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto overflow-visible select-none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
|
||||||
|
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Grid Lines */}
|
||||||
|
{yTicks.map((val, idx) => {
|
||||||
|
const y = paddingTop + chartHeight - (val / maxValue) * chartHeight
|
||||||
|
return (
|
||||||
|
<g key={idx} className="opacity-40">
|
||||||
|
<line
|
||||||
|
x1={paddingLeft}
|
||||||
|
y1={y}
|
||||||
|
x2={width - paddingRight}
|
||||||
|
y2={y}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
className="stroke-white/10"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={paddingLeft - 8}
|
||||||
|
y={y + 4}
|
||||||
|
className="fill-slate-400 text-[9px] text-right font-mono"
|
||||||
|
textAnchor="end"
|
||||||
|
>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(val)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* X Axis Labels */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={p.x}
|
||||||
|
y={height - 8}
|
||||||
|
className="fill-slate-400 text-[10px] font-semibold"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Area under the line */}
|
||||||
|
{areaPath && (
|
||||||
|
<path d={areaPath} fill="url(#areaGrad)" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
{linePath && (
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="drop-shadow-[0_2px_8px_rgba(59,130,246,0.4)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interactive Dots & Hover areas */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<g key={i}>
|
||||||
|
{/* Thick hover circle area */}
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={16}
|
||||||
|
fill="transparent"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onMouseEnter={() => setHoveredIdx(i)}
|
||||||
|
onMouseLeave={() => setHoveredIdx(null)}
|
||||||
|
/>
|
||||||
|
{/* Visual Point dot */}
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={hoveredIdx === i ? 6 : 4}
|
||||||
|
className="transition-all duration-200 fill-primary stroke-[#0b0f19] stroke-2"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
{hoveredIdx === i && (
|
||||||
|
<circle
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={12}
|
||||||
|
className="fill-primary/20 animate-ping"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* HTML Tooltip overlay */}
|
||||||
|
{hoveredIdx !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute bg-slate-950/95 border border-white/10 rounded-lg p-2.5 text-xs text-white shadow-xl backdrop-blur-md pointer-events-none transition-all duration-150 font-sans"
|
||||||
|
style={{
|
||||||
|
left: `${((points[hoveredIdx].x - paddingLeft) / chartWidth) * 80 + 10}%`,
|
||||||
|
top: `${(points[hoveredIdx].y / height) * 75 - 12}%`,
|
||||||
|
transform: 'translateX(-50%) translateY(-100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-slate-400">{points[hoveredIdx].name}</p>
|
||||||
|
<p className="font-bold text-sm text-primary mt-0.5">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(points[hoveredIdx].value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
@@ -25,30 +25,78 @@ import {
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { createUser } from '@/lib/actions/users'
|
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({
|
const userSchema = z.object({
|
||||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
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>
|
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 [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()
|
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>({
|
const form = useForm<UserFormValues>({
|
||||||
resolver: zodResolver(userSchema) as any,
|
resolver: zodResolver(userSchema) as any,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
role: 'partner',
|
||||||
|
company_id: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(values: UserFormValues) {
|
async function onSubmit(values: UserFormValues) {
|
||||||
try {
|
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)
|
setOpen(false)
|
||||||
form.reset()
|
form.reset()
|
||||||
router.refresh()
|
router.refresh()
|
||||||
@@ -58,17 +106,26 @@ export function UserDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
<DialogTrigger asChild>
|
||||||
<Button className="bg-primary hover:bg-primary/90">
|
<Button className="bg-primary hover:bg-primary/90">
|
||||||
<Plus className="mr-2 h-4 w-4" /> Benutzer neu anlegen
|
<Plus className="mr-2 h-4 w-4" /> Benutzer neu anlegen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</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>
|
<DialogHeader>
|
||||||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||||
<DialogDescription className="text-slate-300">
|
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||||
Geben Sie die E-Mail und optional ein Passwort für den neuen Benutzer ein.
|
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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -79,9 +136,56 @@ export function UserDialog() {
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">E-Mail</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">E-Mail</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -89,19 +193,144 @@ export function UserDialog() {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="company_id"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const selectedCompany = localCompanies.find((c) => c.id === field.value)
|
||||||
<FormLabel className="text-white">Passwort (optional)</FormLabel>
|
return (
|
||||||
<FormControl>
|
<FormItem className="w-full">
|
||||||
<Input type="password" placeholder="Passwort leer lassen für Zufall" className="bg-white/5 border-white/10 text-white" {...field} />
|
<FormLabel className="text-slate-900 dark:text-white">Firma</FormLabel>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
<FormMessage />
|
<div className="flex flex-col gap-2 w-full">
|
||||||
</FormItem>
|
<div className="relative w-full" ref={dropdownRef}>
|
||||||
)}
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(!dropdownOpen)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className="w-full text-left text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md px-3 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 truncate"
|
||||||
|
>
|
||||||
|
{selectedCompany ? selectedCompany.name : 'Keine Firma'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="absolute z-50 mt-1 left-0 w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-lg shadow-xl overflow-hidden">
|
||||||
|
<div className="p-2 border-b border-slate-200 dark:border-white/10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Firma suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded px-2 py-1.5 text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto flex flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange('')
|
||||||
|
setDropdownOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${!field.value ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Keine Firma
|
||||||
|
</button>
|
||||||
|
{localCompanies
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(c.id)
|
||||||
|
setDropdownOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors truncate ${field.value === c.id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNewCompanyInput ? (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 mt-1 w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Name der neuen Firma"
|
||||||
|
value={newCompanyName}
|
||||||
|
onChange={(e) => setNewCompanyName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs h-8 w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const trimmed = newCompanyName.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
setIsCreatingCompany(true)
|
||||||
|
try {
|
||||||
|
const created = await createCompany({ name: trimmed })
|
||||||
|
if (created) {
|
||||||
|
setLocalCompanies((prev) => [...prev, created])
|
||||||
|
field.onChange(created.id)
|
||||||
|
setShowNewCompanyInput(false)
|
||||||
|
setNewCompanyName('')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create company:', err)
|
||||||
|
} finally {
|
||||||
|
setIsCreatingCompany(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isCreatingCompany}
|
||||||
|
className="bg-primary text-xs h-8"
|
||||||
|
>
|
||||||
|
{isCreatingCompany ? 'Erstellt...' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewCompanyInput(false)
|
||||||
|
setNewCompanyName('')
|
||||||
|
}}
|
||||||
|
className="text-xs h-8 text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewCompanyInput(true)}
|
||||||
|
className="text-xs text-primary hover:underline mt-1 self-start font-medium"
|
||||||
|
>
|
||||||
|
+ Neue Firma anlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<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
|
Benutzer erstellen
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect, Fragment } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -11,24 +11,108 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
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 { 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'
|
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 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) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
|
if (confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
|
||||||
await deleteUser(id)
|
setLoadingId(id)
|
||||||
router.refresh()
|
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) => {
|
const handleRoleChange = async (id: string, newRole: Role) => {
|
||||||
await updateUserStatus(id, !isBanned)
|
setLoadingId(id + '-role')
|
||||||
router.refresh()
|
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) => {
|
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 (
|
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>
|
<CardHeader>
|
||||||
<CardTitle>Benutzerverwaltung</CardTitle>
|
<CardTitle>Benutzerverwaltung</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-500 dark:text-slate-400">
|
||||||
Hier können Sie Benutzer neu anlegen, sperren oder Passwörter zurücksetzen.
|
Rollen, Firmenzuweisungen, Passwörter und Benutzerkonten verwalten.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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>
|
<TableHeader>
|
||||||
<TableRow className="border-white/10 hover:bg-white/5">
|
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
|
||||||
<TableHead className="text-slate-200">E-Mail</TableHead>
|
<TableHead className="text-slate-700 dark:text-slate-200">E-Mail</TableHead>
|
||||||
<TableHead className="text-slate-200">Status</TableHead>
|
<TableHead className="text-slate-700 dark:text-slate-200">Rolle</TableHead>
|
||||||
<TableHead className="text-slate-200">Zuletzt angemeldet</TableHead>
|
<TableHead className="text-slate-700 dark:text-slate-200">Firma</TableHead>
|
||||||
<TableHead className="text-right text-slate-200">Aktionen</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{initialUsers.map((user) => {
|
{displayUsers.map((user: any) => {
|
||||||
const isBanned = !!user.banned_until
|
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 (
|
return (
|
||||||
<TableRow key={user.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
<Fragment key={user.id}>
|
||||||
<TableCell className="font-medium text-white">
|
{showGroupHeader && (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
||||||
<Mail className="w-4 h-4 text-slate-500" />
|
<TableCell colSpan={5} className="font-semibold text-primary py-2 px-4 text-xs tracking-wider uppercase">
|
||||||
{user.email}
|
Firma: {currentCompanyName}
|
||||||
</div>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
<TableCell>
|
)}
|
||||||
<Badge variant={isBanned ? "destructive" : "default"} className={isBanned ? "" : "bg-green-500/20 text-green-500 border-green-500/30"}>
|
<TableRow className="border-slate-150 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
|
||||||
{isBanned ? "Gesperrt" : "Aktiv"}
|
<TableCell className="font-medium text-slate-900 dark:text-white">
|
||||||
</Badge>
|
<div className="flex flex-col">
|
||||||
</TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<TableCell className="text-slate-400 text-sm">
|
<Mail className="w-4 h-4 text-slate-500" />
|
||||||
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString('de-DE') : 'Noch nie'}
|
<span>{user.email}</span>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-right">
|
<div className="flex gap-2 mt-1 pl-6">
|
||||||
<div className="flex justify-end gap-2">
|
<input
|
||||||
<Button
|
type="text"
|
||||||
variant="ghost"
|
defaultValue={user.first_name || ''}
|
||||||
size="icon"
|
placeholder="Vorname"
|
||||||
className="h-8 w-8 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10"
|
onBlur={async (e) => {
|
||||||
title="Passwort zurücksetzen"
|
const val = e.target.value.trim()
|
||||||
onClick={() => handleResetPassword(user.email)}
|
if (val !== (user.first_name || '')) {
|
||||||
>
|
try {
|
||||||
<Key className="h-4 w-4" />
|
await updateUserProfile(user.id, { first_name: val || null, last_name: user.last_name })
|
||||||
</Button>
|
router.refresh()
|
||||||
<Button
|
} catch (err) {
|
||||||
variant="ghost"
|
console.error('Failed to update first name:', err)
|
||||||
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)}
|
onKeyDown={async (e) => {
|
||||||
>
|
if (e.key === 'Enter') {
|
||||||
{isBanned ? <ShieldAlert className="h-4 w-4" /> : <UserX className="h-4 w-4" />}
|
(e.target as HTMLInputElement).blur()
|
||||||
</Button>
|
}
|
||||||
<Button
|
}}
|
||||||
variant="ghost"
|
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"
|
||||||
size="icon"
|
/>
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
<input
|
||||||
title="Löschen"
|
type="text"
|
||||||
onClick={() => handleDelete(user.id)}
|
defaultValue={user.last_name || ''}
|
||||||
>
|
placeholder="Nachname"
|
||||||
<Trash2 className="h-4 w-4" />
|
onBlur={async (e) => {
|
||||||
</Button>
|
const val = e.target.value.trim()
|
||||||
</div>
|
if (val !== (user.last_name || '')) {
|
||||||
</TableCell>
|
try {
|
||||||
</TableRow>
|
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 right-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>
|
</TableBody>
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ export async function AuthButton() {
|
|||||||
<Button asChild size="sm" variant={"outline"}>
|
<Button asChild size="sm" variant={"outline"}>
|
||||||
<Link href="/auth/login">Sign in</Link>
|
<Link href="/auth/login">Sign in</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant={"default"}>
|
|
||||||
<Link href="/auth/sign-up">Sign up</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { resetPassword } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -26,19 +26,14 @@ export function ForgotPasswordForm({
|
|||||||
|
|
||||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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
|
await resetPassword(email);
|
||||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
|
||||||
redirectTo: `${window.location.origin}/auth/update-password`,
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -49,34 +44,34 @@ export function ForgotPasswordForm({
|
|||||||
{success ? (
|
{success ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Check Your Email</CardTitle>
|
<CardTitle className="text-2xl">E-Mails prüfen</CardTitle>
|
||||||
<CardDescription>Password reset instructions sent</CardDescription>
|
<CardDescription>Link zum Zurücksetzen gesendet</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
If you registered using your email and password, you will receive
|
Wenn Sie sich mit E-Mail und Passwort registriert haben, erhalten Sie
|
||||||
a password reset email.
|
in Kürze eine E-Mail zum Zurücksetzen Ihres Passworts.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Reset Your Password</CardTitle>
|
<CardTitle className="text-2xl">Passwort zurücksetzen</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Type in your email and we'll send you a link to reset your
|
Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link,
|
||||||
password
|
um Ihr Passwort zurückzusetzen.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleForgotPassword}>
|
<form onSubmit={handleForgotPassword}>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="m@example.com"
|
placeholder="name@beispiel.de"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@@ -84,16 +79,16 @@ export function ForgotPasswordForm({
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
{isLoading ? "Sending..." : "Send reset email"}
|
{isLoading ? "Wird gesendet..." : "Link senden"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
Already have an account?{" "}
|
Bereits ein Konto vorhanden?{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/auth/login"
|
href="/auth/login"
|
||||||
className="underline underline-offset-4"
|
className="underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Login
|
Anmelden
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
68
shop/components/inactivity-tracker.tsx
Normal file
68
shop/components/inactivity-tracker.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -94,75 +94,139 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||||
<Document>
|
const items = orderSnapshot?.items ?? [];
|
||||||
<Page size="A4" style={styles.page}>
|
const subtotal = orderSnapshot?.subtotal ?? 0;
|
||||||
<View style={styles.header}>
|
const taxAmount = orderSnapshot?.tax_amount ?? 0;
|
||||||
<View>
|
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||||
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
|
const total = orderSnapshot?.total ?? 0;
|
||||||
<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>
|
|
||||||
|
|
||||||
<Text style={styles.title}>Bestellbestätigung / Rechnung</Text>
|
return (
|
||||||
|
<Document>
|
||||||
<View style={styles.section}>
|
<Page size="A4" style={styles.page}>
|
||||||
<Text style={styles.label}>Rechnungsempfänger</Text>
|
<View style={styles.header}>
|
||||||
<Text>{customer.company_name}</Text>
|
<View>
|
||||||
<Text>{customer.first_name} {customer.last_name}</Text>
|
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
|
||||||
<Text>{customer.address}</Text>
|
<Text>Software Solutions</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>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
<View style={styles.companyInfo}>
|
||||||
</View>
|
<Text>CASPOS Computerabrechnungssysteme GmbH</Text>
|
||||||
|
<Text>Alte Bundesstraße 16</Text>
|
||||||
<View style={styles.total}>
|
<Text>76846 Hauenstein</Text>
|
||||||
<View style={{ width: '40%' }}>
|
<Text>Deutschland</Text>
|
||||||
<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>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<Text style={styles.title}>Anfragebestätigung</Text>
|
||||||
<Text>Vielen Dank für Ihre Bestellung!</Text>
|
|
||||||
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
|
<View style={styles.section}>
|
||||||
</View>
|
<Text style={styles.label}>Anfragender Kunde</Text>
|
||||||
</Page>
|
<Text>{customer.company_name}</Text>
|
||||||
</Document>
|
<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}>Anfragedetails</Text>
|
||||||
|
<Text>Anfrage-Nummer: {order.order_number || order.id}</Text>
|
||||||
|
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{orderSnapshot?.price_multiplier !== null && orderSnapshot?.price_multiplier !== undefined && (
|
||||||
|
<View style={[styles.section, { backgroundColor: '#f9f9f9', padding: 8 }]}>
|
||||||
|
<Text style={styles.label}>Update-Konditionen</Text>
|
||||||
|
<Text style={{ fontWeight: 'bold' }}>
|
||||||
|
{orderSnapshot.price_multiplier === 0
|
||||||
|
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
|
||||||
|
: orderSnapshot.price_multiplier === 0.15
|
||||||
|
? "Update (13-14 Monate): 15% vom Listenpreis"
|
||||||
|
: orderSnapshot.price_multiplier === 0.30
|
||||||
|
? "Update (15-25 Monate): 30% vom Listenpreis"
|
||||||
|
: orderSnapshot.price_multiplier === 0.45
|
||||||
|
? "Update (26-37 Monate): 45% vom Listenpreis"
|
||||||
|
: orderSnapshot.price_multiplier === 0.60
|
||||||
|
? "Update (38-49 Monate): 60% vom Listenpreis"
|
||||||
|
: orderSnapshot.price_multiplier === 0.90
|
||||||
|
? "Neukauf (>= 50 Monate): 10% Rabatt auf Neukauf"
|
||||||
|
: `Gebühr: ${orderSnapshot.price_multiplier * 100}%`}
|
||||||
|
</Text>
|
||||||
|
{orderSnapshot.last_license_date && (
|
||||||
|
<Text style={{ marginTop: 2, color: '#666' }}>
|
||||||
|
Letzte Lizenz vom: {new Date(orderSnapshot.last_license_date).toLocaleDateString('de-DE')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.table}>
|
||||||
|
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||||
|
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||||
|
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{items.map((item: any, idx: number) => (
|
||||||
|
<View key={idx}>
|
||||||
|
<View style={styles.tableRow}>
|
||||||
|
<View style={styles.tableCol}>
|
||||||
|
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableColPrice}>
|
||||||
|
<Text>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||||
|
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{item.selected_modules?.map((mod: any, mIdx: number) => {
|
||||||
|
const qty = mod.quantity || 1;
|
||||||
|
const price = mod.total_price ?? (mod.price * qty);
|
||||||
|
const hasQty = qty > 1;
|
||||||
|
return (
|
||||||
|
<View key={mIdx} style={styles.tableRow}>
|
||||||
|
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
||||||
|
<Text style={{ color: '#666' }}>
|
||||||
|
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableColPrice}>
|
||||||
|
<Text style={{ color: '#666' }}>
|
||||||
|
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</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 (netto):</Text>
|
||||||
|
<Text style={styles.totalLabel}>MwSt.Betrag:</Text>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { signIn } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
@@ -24,24 +24,38 @@ export function LoginForm({
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [messageParam, setMessageParam] = useState<string | null>(null);
|
||||||
|
const [errorParam, setErrorParam] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
const res = await signIn(email, password);
|
||||||
email,
|
if (!res.success) {
|
||||||
password,
|
setError(res.error || "Ein Fehler ist aufgetreten.");
|
||||||
});
|
return;
|
||||||
if (error) throw error;
|
}
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
if (nextParam) {
|
||||||
router.push("/protected");
|
router.push(nextParam);
|
||||||
|
} else if (res.role === "admin") {
|
||||||
|
router.push("/admin/einstellungen");
|
||||||
|
} else {
|
||||||
|
router.push("/my-customers");
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,20 +65,20 @@ export function LoginForm({
|
|||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Login</CardTitle>
|
<CardTitle className="text-2xl">Anmelden</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter your email below to login to your account
|
Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleLogin}>
|
<form onSubmit={handleLogin}>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="m@example.com"
|
placeholder="name@beispiel.de"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@@ -72,12 +86,12 @@ export function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Passwort</Label>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/forgot-password"
|
href="/auth/forgot-password"
|
||||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Forgot your password?
|
Passwort vergessen?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -88,21 +102,22 @@ export function LoginForm({
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{errorParam === "gesperrt" && (
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
|
||||||
{isLoading ? "Logging in..." : "Login"}
|
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
)}
|
||||||
<div className="mt-4 text-center text-sm">
|
{messageParam === "concurrent" && (
|
||||||
Don't have an account?{" "}
|
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
||||||
<Link
|
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
||||||
href="/auth/sign-up"
|
</p>
|
||||||
className="underline underline-offset-4"
|
)}
|
||||||
>
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
Sign up
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
</Link>
|
{isLoading ? "Wird angemeldet..." : "Anmelden"}
|
||||||
</div>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { signOut } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
@@ -8,9 +8,9 @@ export function LogoutButton() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
const supabase = createClient();
|
await signOut();
|
||||||
await supabase.auth.signOut();
|
router.push("/");
|
||||||
router.push("/auth/login");
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Button onClick={logout}>Logout</Button>;
|
return <Button onClick={logout}>Logout</Button>;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { signUp } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -29,7 +29,6 @@ export function SignUpForm({
|
|||||||
|
|
||||||
const handleSignUp = async (e: React.FormEvent) => {
|
const handleSignUp = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -40,14 +39,7 @@ export function SignUpForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.auth.signUp({
|
await signUp(email, password);
|
||||||
email,
|
|
||||||
password,
|
|
||||||
options: {
|
|
||||||
emailRedirectTo: `${window.location.origin}/protected`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
|
||||||
router.push("/auth/sign-up-success");
|
router.push("/auth/sign-up-success");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
|||||||
41
shop/components/ui/switch.tsx
Normal file
41
shop/components/ui/switch.tsx
Normal 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 };
|
||||||
@@ -4,11 +4,11 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, containerClassName, ...props }: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="table-container"
|
data-slot="table-container"
|
||||||
className="relative w-full overflow-x-auto"
|
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
data-slot="table"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { updatePassword } from "@/lib/actions/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,23 +20,33 @@ export function UpdatePasswordForm({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentPropsWithoutRef<"div">) {
|
}: React.ComponentPropsWithoutRef<"div">) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const supabase = createClient();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
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 {
|
try {
|
||||||
const { error } = await supabase.auth.updateUser({ password });
|
await updatePassword(password);
|
||||||
if (error) throw error;
|
router.push("/");
|
||||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
|
||||||
router.push("/protected");
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "An error occurred");
|
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -44,30 +54,43 @@ export function UpdatePasswordForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card>
|
<Card className="glass-dark border-white/10 text-white shadow-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">Reset Your Password</CardTitle>
|
<CardTitle className="text-2xl">Neues Passwort festlegen</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-slate-300">
|
||||||
Please enter your new password below.
|
Bitte geben Sie Ihr neues Passwort ein und bestätigen Sie es.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleForgotPassword}>
|
<form onSubmit={handleForgotPassword}>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password">New password</Label>
|
<Label htmlFor="password">Neues Passwort</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="New password"
|
placeholder="Neues Passwort"
|
||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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>
|
</div>
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button type="submit" className="w-full bg-primary hover:bg-primary/90" disabled={isLoading}>
|
||||||
{isLoading ? "Saving..." : "Save new password"}
|
{isLoading ? "Speichern..." : "Neues Passwort speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
49
shop/lib/actions/admin.ts
Normal file
49
shop/lib/actions/admin.ts
Normal 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
120
shop/lib/actions/auth.ts
Normal 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
|
||||||
|
}
|
||||||
239
shop/lib/actions/companies.ts
Normal file
239
shop/lib/actions/companies.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
'use server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
|
import { revalidatePath, unstable_noStore as noStore } from 'next/cache'
|
||||||
|
|
||||||
|
export async function getCompanies() {
|
||||||
|
noStore()
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data, error } = await admin.from('companies').select('*')
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCompany(data: { name: string; street?: string; zip?: string; city?: string; email?: string }) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin.from('companies').insert({
|
||||||
|
name: data.name,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
email: data.email || null,
|
||||||
|
}).select().single()
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/companies')
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
return dbData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCompany(id: string, data: { name: string; street?: string; zip?: string; city?: string; email?: string }) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin.from('companies').update({
|
||||||
|
name: data.name,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
email: data.email || null,
|
||||||
|
}).eq('id', id).select().single()
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/companies')
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
return dbData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCompany(id: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { error } = await admin.from('companies').delete().eq('id', id)
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/companies')
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignCompanyToUser(userId: string, companyId: string | null) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { error } = await admin.from('users').update({ company_id: companyId }).eq('id', userId)
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEndCustomersByCompany(companyId: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
email?: string
|
||||||
|
}) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.insert([{
|
||||||
|
partner_id: data.partner_id,
|
||||||
|
company_name: data.company_name,
|
||||||
|
vat_id: data.vat_id || null,
|
||||||
|
first_name: data.first_name || null,
|
||||||
|
last_name: data.last_name || null,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
email: data.email || null,
|
||||||
|
}])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return dbData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdateEndCustomer(id: string, data: {
|
||||||
|
partner_id: string
|
||||||
|
company_name: string
|
||||||
|
vat_id?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
street?: string
|
||||||
|
zip?: string
|
||||||
|
city?: string
|
||||||
|
email?: string
|
||||||
|
}) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.update({
|
||||||
|
partner_id: data.partner_id,
|
||||||
|
company_name: data.company_name,
|
||||||
|
vat_id: data.vat_id || null,
|
||||||
|
first_name: data.first_name || null,
|
||||||
|
last_name: data.last_name || null,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
email: data.email || null,
|
||||||
|
})
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import type { EndCustomer } from '@/lib/types'
|
import type { EndCustomer } from '@/lib/types'
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export type EndCustomerFormData = {
|
|||||||
street?: string
|
street?: string
|
||||||
zip?: string
|
zip?: string
|
||||||
city?: string
|
city?: string
|
||||||
|
email?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lesen ───────────────────────────────────────────────────────────────────
|
// ─── Lesen ───────────────────────────────────────────────────────────────────
|
||||||
@@ -71,6 +73,7 @@ export async function createEndCustomer(formData: EndCustomerFormData): Promise<
|
|||||||
street: formData.street || null,
|
street: formData.street || null,
|
||||||
zip: formData.zip || null,
|
zip: formData.zip || null,
|
||||||
city: formData.city || null,
|
city: formData.city || null,
|
||||||
|
email: formData.email || null,
|
||||||
}])
|
}])
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
@@ -100,6 +103,7 @@ export async function updateEndCustomer(id: string, formData: EndCustomerFormDat
|
|||||||
street: formData.street || null,
|
street: formData.street || null,
|
||||||
zip: formData.zip || null,
|
zip: formData.zip || null,
|
||||||
city: formData.city || null,
|
city: formData.city || null,
|
||||||
|
email: formData.email || null,
|
||||||
})
|
})
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
|
|
||||||
@@ -129,10 +133,11 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
|
|||||||
company_name: '[GELÖSCHT]',
|
company_name: '[GELÖSCHT]',
|
||||||
vat_id: null,
|
vat_id: null,
|
||||||
first_name: '[GELÖSCHT]',
|
first_name: '[GELÖSCHT]',
|
||||||
last_name: null,
|
last_name: '[GELÖSCHT]',
|
||||||
street: null,
|
street: '[GELÖSCHT]',
|
||||||
zip: null,
|
zip: null,
|
||||||
city: null,
|
city: null,
|
||||||
|
email: null,
|
||||||
is_anonymized: true,
|
is_anonymized: true,
|
||||||
})
|
})
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
@@ -143,3 +148,15 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
|
|||||||
revalidatePath('/my-customers')
|
revalidatePath('/my-customers')
|
||||||
revalidatePath(`/my-customers/${id}`)
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { sendMail } from '@/utils/mail'
|
||||||
import { InvoicePDF } from '@/components/invoice-pdf'
|
import { InvoicePDF } from '@/components/invoice-pdf'
|
||||||
import { renderToBuffer } from '@react-pdf/renderer'
|
import { renderToBuffer } from '@react-pdf/renderer'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
||||||
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
||||||
|
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||||
|
|
||||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||||
* Format: RE-YYYY-NNNNN (z.B. RE-2026-84731)
|
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
|
||||||
* Kein Rückschluss auf Gesamtanzahl der Bestellungen möglich.
|
* Kein Rückschluss auf Gesamtanzahl der Bestellungen möglich.
|
||||||
*/
|
*/
|
||||||
function generateOrderNumber(): string {
|
function generateOrderNumber(): string {
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
const rand = Math.floor(10000 + Math.random() * 90000) // 5-stellig, niemals < 10000
|
const rand = Math.floor(10000 + Math.random() * 90000) // 5-stellig, niemals < 10000
|
||||||
return `RE-${year}-${rand}`
|
return `BE-${year}-${rand}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,22 +51,97 @@ function hashOrderSnapshot(snapshot: object): string {
|
|||||||
*/
|
*/
|
||||||
export async function submitOrder(params: {
|
export async function submitOrder(params: {
|
||||||
selections: WizardSelections
|
selections: WizardSelections
|
||||||
products: Product[]
|
moduleQuantities?: Record<string, number>
|
||||||
categories: Category[]
|
products?: Product[]
|
||||||
|
categories?: Category[]
|
||||||
customerProfile: Partial<Profile>
|
customerProfile: Partial<Profile>
|
||||||
endCustomerId?: string | null
|
endCustomerId?: string | null
|
||||||
endCustomer?: EndCustomer | null
|
endCustomer?: EndCustomer | null
|
||||||
|
billingInterval?: 'one_time' | 'monthly'
|
||||||
|
lastLicenseDate?: string | null
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params
|
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
if (!user) throw new Error('Not authenticated')
|
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.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product-level constraints validation
|
||||||
|
const selectedProductIds = Object.values(selections)
|
||||||
|
.map(s => s.productId)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
|
||||||
|
for (const prodId of selectedProductIds) {
|
||||||
|
const prod = dbProducts.find(p => p.id === prodId)
|
||||||
|
if (!prod) continue
|
||||||
|
|
||||||
|
// Product requirements check
|
||||||
|
if (prod.requirements && prod.requirements.length > 0) {
|
||||||
|
const missing = prod.requirements.filter(reqId => !selectedProductIds.includes(reqId))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const missingNames = missing
|
||||||
|
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
|
||||||
|
.join(', ')
|
||||||
|
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von: ${missingNames}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product exclusions check
|
||||||
|
if (prod.exclusions && prod.exclusions.length > 0) {
|
||||||
|
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
|
||||||
|
if (conflicting.length > 0) {
|
||||||
|
const conflictingNames = conflicting
|
||||||
|
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
|
||||||
|
.join(', ')
|
||||||
|
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Snapshots aufbauen
|
// 1. Snapshots aufbauen
|
||||||
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
||||||
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
||||||
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
|
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
|
||||||
|
|
||||||
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
||||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||||
@@ -127,7 +205,7 @@ export async function submitOrder(params: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 6. PDF in Supabase Storage hochladen
|
// 6. PDF in Supabase Storage hochladen
|
||||||
const fileName = `invoice_${order.id}.pdf`
|
const fileName = `ab_${order.id}.pdf`
|
||||||
const { error: uploadError } = await supabase
|
const { error: uploadError } = await supabase
|
||||||
.storage
|
.storage
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
@@ -140,11 +218,159 @@ export async function submitOrder(params: {
|
|||||||
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
||||||
order.pdf_url = publicUrlData.publicUrl
|
order.pdf_url = publicUrlData.publicUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. E-Mail mit PDF-Anhang an den Besteller senden
|
||||||
|
if (user.email) {
|
||||||
|
try {
|
||||||
|
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE')
|
||||||
|
const formattedTotal = order.total_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
to: user.email,
|
||||||
|
subject: `Anfragebestätigung ${orderNumber}`,
|
||||||
|
text: `Hallo,\n\nvielen Dank für Ihre Anfrage bei CASPOS Shop!\n\nAngebotsdetails:\n- Anfrage-Nummer: ${orderNumber}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n- Gesamtsumme: ${formattedTotal}\n\nIm Anhang dieser E-Mail finden Sie Ihre Anfragebestätigung als PDF-Dokument.\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0f172a; margin-bottom: 16px;">Vielen Dank für Ihre Anfrage!</h2>
|
||||||
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
||||||
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">wir freuen uns sehr über Ihre Anfrage bei CASPOS Shop. Ihre Anfrage wurde erfolgreich entgegengenommen und wird nun verarbeitet.</p>
|
||||||
|
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
||||||
|
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Anfrage-Details</h3>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Anfrage-Nummer:</td>
|
||||||
|
<td style="padding: 4px 0;">${orderNumber}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
|
||||||
|
<td style="padding: 4px 0;">${formattedDate}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
|
||||||
|
<td style="padding: 4px 0;">${customerSnapshot.company_name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold;">Gesamtsumme:</td>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold; color: #0f172a;">${formattedTotal}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre Anfragebestätigung als PDF-Dokument.</p>
|
||||||
|
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: `Angebotsbestaetigung_${orderNumber}.pdf`,
|
||||||
|
content: buffer,
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error('Failed to send order confirmation mail:', mailError)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (pdfError) {
|
} catch (pdfError) {
|
||||||
console.error('PDF Generation Error:', pdfError)
|
console.error('PDF Generation Error:', pdfError)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/protected')
|
revalidatePath('/my-customers')
|
||||||
revalidatePath('/my-orders')
|
revalidatePath('/my-orders')
|
||||||
return order as Order
|
return order as Order
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateOrderStatus(
|
||||||
|
orderId: string,
|
||||||
|
newStatus: 'pending' | 'active' | 'completed' | 'cancelled'
|
||||||
|
) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
|
||||||
|
// 1. Fetch current order
|
||||||
|
const { data: order, error: fetchError } = await admin
|
||||||
|
.from('orders')
|
||||||
|
.select('id, user_id, order_number, status')
|
||||||
|
.eq('id', orderId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError || !order) {
|
||||||
|
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldStatus = order.status
|
||||||
|
|
||||||
|
if (oldStatus === newStatus) {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update order status
|
||||||
|
const { data: updatedOrder, error: updateError } = await admin
|
||||||
|
.from('orders')
|
||||||
|
.update({ status: newStatus })
|
||||||
|
.eq('id', orderId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (updateError || !updatedOrder) {
|
||||||
|
throw new Error(`Fehler beim Aktualisieren des Status: ${updateError?.message || 'Unbekannt'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send email to the retailer (händler)
|
||||||
|
if (order.user_id) {
|
||||||
|
try {
|
||||||
|
const { data: { user }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
|
||||||
|
|
||||||
|
if (!userError && user && user.email) {
|
||||||
|
const orderNumber = order.order_number || order.id.slice(0, 8)
|
||||||
|
const statusLabelMap: Record<string, string> = {
|
||||||
|
pending: 'Eingegangen',
|
||||||
|
active: 'In Bearbeitung',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
cancelled: 'Storniert',
|
||||||
|
}
|
||||||
|
const oldLabel = statusLabelMap[oldStatus] || oldStatus
|
||||||
|
const newLabel = statusLabelMap[newStatus] || newStatus
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
to: user.email,
|
||||||
|
subject: `Statusänderung Ihrer Bestellung ${orderNumber}`,
|
||||||
|
text: `Hallo,\n\nder Status Ihrer Bestellung mit der Nummer ${orderNumber} hat sich geändert.\n\nNeuer Status: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||||
|
<h2 style="color: #0f172a; margin-bottom: 16px;">Status Ihrer Bestellung hat sich geändert</h2>
|
||||||
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
||||||
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status Ihrer Bestellung mit der Nummer <strong>${orderNumber}</strong> wurde aktualisiert.</p>
|
||||||
|
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Bestellnummer:</td>
|
||||||
|
<td style="padding: 4px 0;">${orderNumber}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold;">Alter Status:</td>
|
||||||
|
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold;">Neuer Status:</td>
|
||||||
|
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Ihr CASPOS Shop-Team</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error('Failed to send status update email:', mailError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/orders')
|
||||||
|
revalidatePath('/my-orders')
|
||||||
|
return updatedOrder as Order
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { Category, Product, ProductModule } from '../types'
|
import { Category, Product, ProductModule } from '../types'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
export async function getProducts() {
|
export async function getProducts() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -64,7 +65,10 @@ export async function deleteCategory(id: string) {
|
|||||||
revalidatePath('/order')
|
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()
|
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)
|
// Start a transaction-like approach (Supabase doesn't have cross-table transactions easily in one call via JS client without RPC)
|
||||||
@@ -77,7 +81,16 @@ export async function createProduct(product: Omit<Product, 'id' | 'created_at' |
|
|||||||
if (productError) throw productError
|
if (productError) throw productError
|
||||||
|
|
||||||
if (modules.length > 0) {
|
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 || [],
|
||||||
|
has_quantity: m.has_quantity ?? false,
|
||||||
|
}))
|
||||||
const { error: modulesError } = await supabase
|
const { error: modulesError } = await supabase
|
||||||
.from('product_modules')
|
.from('product_modules')
|
||||||
.insert(modulesWithId)
|
.insert(modulesWithId)
|
||||||
@@ -106,12 +119,14 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
|||||||
|
|
||||||
if (modules.length > 0) {
|
if (modules.length > 0) {
|
||||||
const modulesWithId = modules.map(m => ({
|
const modulesWithId = modules.map(m => ({
|
||||||
|
id: m.id || randomUUID(),
|
||||||
|
product_id: id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description || null,
|
description: m.description || null,
|
||||||
price: m.price,
|
price: m.price,
|
||||||
requirements: m.requirements || [],
|
requirements: m.requirements || [],
|
||||||
exclusions: m.exclusions || [],
|
exclusions: m.exclusions || [],
|
||||||
product_id: id
|
has_quantity: m.has_quantity ?? false,
|
||||||
}))
|
}))
|
||||||
const { error: modulesError } = await supabase
|
const { error: modulesError } = await supabase
|
||||||
.from('product_modules')
|
.from('product_modules')
|
||||||
@@ -120,6 +135,7 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
|
|||||||
if (modulesError) throw modulesError
|
if (modulesError) throw modulesError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
revalidatePath('/admin/products')
|
revalidatePath('/admin/products')
|
||||||
revalidatePath('/order')
|
revalidatePath('/order')
|
||||||
}
|
}
|
||||||
@@ -132,3 +148,61 @@ export async function deleteProduct(id: string) {
|
|||||||
revalidatePath('/admin/products')
|
revalidatePath('/admin/products')
|
||||||
revalidatePath('/order')
|
revalidatePath('/order')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function transferModules(
|
||||||
|
sourceProductId: string,
|
||||||
|
targetProductId: string,
|
||||||
|
mode: 'overwrite' | 'append'
|
||||||
|
) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
// 1. Fetch modules of source product
|
||||||
|
const { data: sourceModules, error: fetchError } = await supabase
|
||||||
|
.from('product_modules')
|
||||||
|
.select('*')
|
||||||
|
.eq('product_id', sourceProductId)
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError
|
||||||
|
if (!sourceModules || sourceModules.length === 0) return
|
||||||
|
|
||||||
|
// 2. If overwrite, delete existing modules of target product
|
||||||
|
if (mode === 'overwrite') {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from('product_modules')
|
||||||
|
.delete()
|
||||||
|
.eq('product_id', targetProductId)
|
||||||
|
if (deleteError) throw deleteError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Map IDs to new UUIDs and fix requirements/exclusions
|
||||||
|
const idMap: Record<string, string> = {}
|
||||||
|
sourceModules.forEach(m => {
|
||||||
|
idMap[m.id] = randomUUID()
|
||||||
|
})
|
||||||
|
|
||||||
|
const newModules = sourceModules.map(m => {
|
||||||
|
const newId = idMap[m.id]
|
||||||
|
const requirements = (m.requirements || []).map((id: string) => idMap[id] || id)
|
||||||
|
const exclusions = (m.exclusions || []).map((id: string) => idMap[id] || id)
|
||||||
|
return {
|
||||||
|
id: newId,
|
||||||
|
product_id: targetProductId,
|
||||||
|
name: m.name,
|
||||||
|
description: m.description,
|
||||||
|
price: m.price,
|
||||||
|
requirements,
|
||||||
|
exclusions,
|
||||||
|
has_quantity: m.has_quantity ?? false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Insert into DB
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('product_modules')
|
||||||
|
.insert(newModules)
|
||||||
|
|
||||||
|
if (insertError) throw insertError
|
||||||
|
|
||||||
|
revalidatePath('/admin/products')
|
||||||
|
revalidatePath('/order')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,50 +1,234 @@
|
|||||||
'use server'
|
'use server';
|
||||||
|
|
||||||
import { createAdminClient } from '@/lib/supabase/admin'
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath, unstable_noStore as noStore } from 'next/cache';
|
||||||
|
import { sendMail } from '@/utils/mail';
|
||||||
|
|
||||||
export async function getUsers() {
|
export async function getUsers() {
|
||||||
const admin = createAdminClient()
|
noStore();
|
||||||
const { data: { users }, error } = await admin.auth.admin.listUsers()
|
const admin = createAdminClient();
|
||||||
if (error) throw error
|
const { data: { users }, error } = await admin.auth.admin.listUsers();
|
||||||
return users
|
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 }) {
|
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 admin = createAdminClient();
|
||||||
const { data: { user }, error } = await admin.auth.admin.createUser({
|
const { data: { user }, error } = await admin.auth.admin.createUser({
|
||||||
email: data.email,
|
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,
|
email_confirm: data.email_confirm ?? true,
|
||||||
})
|
});
|
||||||
if (error) throw error
|
if (error) throw error;
|
||||||
revalidatePath('/admin/users')
|
if (!user) throw new Error('Benutzer konnte nicht erstellt werden.');
|
||||||
return user
|
|
||||||
|
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) {
|
export async function deleteUser(id: string) {
|
||||||
const admin = createAdminClient()
|
try {
|
||||||
const { error } = await admin.auth.admin.deleteUser(id)
|
const admin = createAdminClient();
|
||||||
if (error) throw error
|
|
||||||
revalidatePath('/admin/users')
|
// 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) {
|
export async function updateUserStatus(id: string, ban: boolean) {
|
||||||
const admin = createAdminClient()
|
const admin = createAdminClient();
|
||||||
const { error } = await admin.auth.admin.updateUserById(id, {
|
const { error } = await admin.auth.admin.updateUserById(id, {
|
||||||
ban_duration: ban ? '876000h' : '0h', // 100 years or none
|
ban_duration: ban ? '876000h' : '0h',
|
||||||
})
|
});
|
||||||
if (error) throw error
|
if (error) throw error;
|
||||||
revalidatePath('/admin/users')
|
revalidatePath('/admin/users');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetPassword(email: string) {
|
export async function resetPassword(email: string) {
|
||||||
const admin = createAdminClient()
|
const admin = createAdminClient();
|
||||||
// Generate a reset link or send email
|
const { data, error } = await admin.auth.admin.generateLink({
|
||||||
const { error } = await admin.auth.admin.generateLink({
|
|
||||||
type: 'recovery',
|
type: 'recovery',
|
||||||
email,
|
email,
|
||||||
})
|
});
|
||||||
if (error) throw error
|
if (error) throw error;
|
||||||
// Usually you'd send this link or let Supabase handle it via regular auth.resetPasswordForEmail
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,10 @@ export function buildCustomerSnapshot(
|
|||||||
export function buildOrderSnapshot(
|
export function buildOrderSnapshot(
|
||||||
selections: WizardSelections,
|
selections: WizardSelections,
|
||||||
products: Product[],
|
products: Product[],
|
||||||
categories: Category[]
|
categories: Category[],
|
||||||
|
billingInterval?: 'one_time' | 'monthly',
|
||||||
|
moduleQuantities?: Record<string, number>,
|
||||||
|
lastLicenseDate?: string | null
|
||||||
): OrderSnapshot {
|
): OrderSnapshot {
|
||||||
const items: OrderItem[] = []
|
const items: OrderItem[] = []
|
||||||
let subtotal = 0
|
let subtotal = 0
|
||||||
@@ -102,13 +105,18 @@ export function buildOrderSnapshot(
|
|||||||
const selectedModules = sel.moduleIds
|
const selectedModules = sel.moduleIds
|
||||||
.map((modId) => prod.modules?.find((m) => m.id === modId))
|
.map((modId) => prod.modules?.find((m) => m.id === modId))
|
||||||
.filter((m): m is NonNullable<typeof m> => !!m)
|
.filter((m): m is NonNullable<typeof m> => !!m)
|
||||||
.map((mod) => ({
|
.map((mod) => {
|
||||||
module_id: mod.id,
|
const qty = moduleQuantities?.[mod.id] || 1
|
||||||
module_name: mod.name,
|
return {
|
||||||
price: mod.price,
|
module_id: mod.id,
|
||||||
}))
|
module_name: mod.name,
|
||||||
|
price: mod.price,
|
||||||
|
quantity: qty,
|
||||||
|
total_price: mod.price * qty,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const moduleTotal = selectedModules.reduce((acc, m) => acc + m.price, 0)
|
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
|
||||||
const itemTotal = prod.base_price + moduleTotal
|
const itemTotal = prod.base_price + moduleTotal
|
||||||
subtotal += itemTotal
|
subtotal += itemTotal
|
||||||
|
|
||||||
@@ -124,14 +132,46 @@ export function buildOrderSnapshot(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wenn billingInterval übergeben, dieses nutzen; sonst automatisch ermitteln
|
||||||
|
const billingCycle = billingInterval || (items.every((i) => i.billing_interval === 'one_time')
|
||||||
|
? 'one_time'
|
||||||
|
: 'monthly')
|
||||||
|
|
||||||
|
let multiplier = 1
|
||||||
|
const hasOneTime = items.some(item => item.billing_interval === 'one_time')
|
||||||
|
if (lastLicenseDate && hasOneTime) {
|
||||||
|
const lastDate = new Date(lastLicenseDate)
|
||||||
|
const today = new Date()
|
||||||
|
const yearsDiff = today.getFullYear() - lastDate.getFullYear()
|
||||||
|
const monthsDiff = today.getMonth() - lastDate.getMonth()
|
||||||
|
const months = yearsDiff * 12 + monthsDiff
|
||||||
|
|
||||||
|
if (months <= 12) {
|
||||||
|
multiplier = 0
|
||||||
|
} else if (months <= 14) {
|
||||||
|
multiplier = 0.15
|
||||||
|
} else if (months <= 25) {
|
||||||
|
multiplier = 0.30
|
||||||
|
} else if (months <= 37) {
|
||||||
|
multiplier = 0.45
|
||||||
|
} else if (months <= 49) {
|
||||||
|
multiplier = 0.60
|
||||||
|
} else {
|
||||||
|
multiplier = 0.90
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiply only one_time item totals by multiplier
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.billing_interval === 'one_time') {
|
||||||
|
item.item_total = item.item_total * multiplier
|
||||||
|
}
|
||||||
|
})
|
||||||
|
subtotal = items.reduce((acc, item) => acc + item.item_total, 0)
|
||||||
|
}
|
||||||
|
|
||||||
const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100
|
const taxAmount = Math.round(subtotal * (dominantTaxRate / 100) * 100) / 100
|
||||||
const total = Math.round((subtotal + taxAmount) * 100) / 100
|
const total = Math.round((subtotal + taxAmount) * 100) / 100
|
||||||
|
|
||||||
// Wenn alle Items one_time sind → billing_cycle = 'one_time', sonst 'monthly'
|
|
||||||
const billingCycle = items.every((i) => i.billing_interval === 'one_time')
|
|
||||||
? 'one_time'
|
|
||||||
: 'monthly'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
billing_cycle: billingCycle,
|
billing_cycle: billingCycle,
|
||||||
@@ -140,6 +180,8 @@ export function buildOrderSnapshot(
|
|||||||
tax_rate: dominantTaxRate,
|
tax_rate: dominantTaxRate,
|
||||||
tax_amount: taxAmount,
|
tax_amount: taxAmount,
|
||||||
total,
|
total,
|
||||||
|
last_license_date: lastLicenseDate || null,
|
||||||
|
price_multiplier: lastLicenseDate && hasOneTime ? multiplier : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +198,8 @@ export function transformToLicenseBundle(
|
|||||||
selections: WizardSelections,
|
selections: WizardSelections,
|
||||||
products: Product[],
|
products: Product[],
|
||||||
categories: Category[],
|
categories: Category[],
|
||||||
customer: Partial<Profile>
|
customer: Partial<Profile>,
|
||||||
|
moduleQuantities?: Record<string, number>
|
||||||
): LicenseBundle {
|
): LicenseBundle {
|
||||||
const options: LicenseOption[] = []
|
const options: LicenseOption[] = []
|
||||||
let monthlyTotal = 0
|
let monthlyTotal = 0
|
||||||
@@ -185,6 +228,7 @@ export function transformToLicenseBundle(
|
|||||||
for (const modId of sel.moduleIds) {
|
for (const modId of sel.moduleIds) {
|
||||||
const mod = prod.modules?.find((m) => m.id === modId)
|
const mod = prod.modules?.find((m) => m.id === modId)
|
||||||
if (!mod) continue
|
if (!mod) continue
|
||||||
|
const qty = moduleQuantities?.[mod.id] || 1
|
||||||
|
|
||||||
options.push({
|
options.push({
|
||||||
key: `${prodKey}__${slugify(mod.name)}`,
|
key: `${prodKey}__${slugify(mod.name)}`,
|
||||||
@@ -193,10 +237,11 @@ export function transformToLicenseBundle(
|
|||||||
// Module erben das Billing-Intervall des übergeordneten Produkts
|
// Module erben das Billing-Intervall des übergeordneten Produkts
|
||||||
billing: prod.billing_interval,
|
billing: prod.billing_interval,
|
||||||
price_net: mod.price,
|
price_net: mod.price,
|
||||||
|
quantity: qty,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (prod.billing_interval === 'monthly') monthlyTotal += mod.price
|
if (prod.billing_interval === 'monthly') monthlyTotal += mod.price * qty
|
||||||
else oneTimeTotal += mod.price
|
else oneTimeTotal += mod.price * qty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,16 +282,19 @@ export function licenseBundleFromSnapshot(
|
|||||||
else oneTimeTotal += item.base_price
|
else oneTimeTotal += item.base_price
|
||||||
|
|
||||||
for (const mod of item.selected_modules) {
|
for (const mod of item.selected_modules) {
|
||||||
|
const qty = mod.quantity || 1
|
||||||
|
const modPrice = mod.total_price || (mod.price * qty)
|
||||||
options.push({
|
options.push({
|
||||||
key: `${prodKey}__${slugify(mod.module_name)}`,
|
key: `${prodKey}__${slugify(mod.module_name)}`,
|
||||||
label: mod.module_name,
|
label: mod.module_name,
|
||||||
active: true,
|
active: true,
|
||||||
billing: item.billing_interval,
|
billing: item.billing_interval,
|
||||||
price_net: mod.price,
|
price_net: mod.price,
|
||||||
|
quantity: qty,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (item.billing_interval === 'monthly') monthlyTotal += mod.price
|
if (item.billing_interval === 'monthly') monthlyTotal += modPrice
|
||||||
else oneTimeTotal += mod.price
|
else oneTimeTotal += modPrice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { createClient } from '@supabase/supabase-js'
|
|||||||
|
|
||||||
export function createAdminClient() {
|
export function createAdminClient() {
|
||||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
|
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(
|
return createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
supabaseUrl!,
|
||||||
serviceRoleKey,
|
serviceRoleKey,
|
||||||
{
|
{
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { createBrowserClient } from "@supabase/ssr";
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import { resolveSupabaseUrl } from "../utils";
|
||||||
|
|
||||||
export function createClient() {
|
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(
|
return createBrowserClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
supabaseAnonKey!,
|
||||||
|
{
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { hasEnvVars } from "../utils";
|
import { hasEnvVars, resolveSupabaseUrl } from "../utils";
|
||||||
|
|
||||||
export async function updateSession(request: NextRequest) {
|
export async function updateSession(request: NextRequest) {
|
||||||
let supabaseResponse = NextResponse.next({
|
let supabaseResponse = NextResponse.next({
|
||||||
@@ -13,11 +13,12 @@ export async function updateSession(request: NextRequest) {
|
|||||||
return supabaseResponse;
|
return supabaseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// With Fluid compute, don't put this client in a global environment
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
// variable. Always create a new one on each request.
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
const supabase = createServerClient(
|
const supabase = createServerClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
supabaseAnonKey!,
|
||||||
{
|
{
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
@@ -35,6 +36,9 @@ export async function updateSession(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ export async function updateSession(request: NextRequest) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
request.nextUrl.pathname !== "/" &&
|
request.nextUrl.pathname !== "/" &&
|
||||||
|
request.nextUrl.pathname !== "/impressum" &&
|
||||||
|
request.nextUrl.pathname !== "/datenschutz" &&
|
||||||
!user &&
|
!user &&
|
||||||
!request.nextUrl.pathname.startsWith("/login") &&
|
!request.nextUrl.pathname.startsWith("/login") &&
|
||||||
!request.nextUrl.pathname.startsWith("/auth")
|
!request.nextUrl.pathname.startsWith("/auth")
|
||||||
@@ -59,6 +65,49 @@ export async function updateSession(request: NextRequest) {
|
|||||||
return NextResponse.redirect(url);
|
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.
|
// IMPORTANT: You *must* return the supabaseResponse object as it is.
|
||||||
// If you're creating a new response object with NextResponse.next() make sure to:
|
// If you're creating a new response object with NextResponse.next() make sure to:
|
||||||
// 1. Pass the request in it, like so:
|
// 1. Pass the request in it, like so:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { resolveSupabaseUrl } from "../utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Especially important if using Fluid compute: Don't put this client in a
|
* 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() {
|
export async function createClient() {
|
||||||
const cookieStore = await cookies();
|
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(
|
return createServerClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
supabaseAnonKey!,
|
||||||
{
|
{
|
||||||
cookies: {
|
cookies: {
|
||||||
getAll() {
|
getAll() {
|
||||||
@@ -29,6 +33,9 @@ export async function createClient() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type Product = {
|
|||||||
category?: Category | null
|
category?: Category | null
|
||||||
modules?: ProductModule[]
|
modules?: ProductModule[]
|
||||||
created_at: string
|
created_at: string
|
||||||
|
show_in_kauf?: boolean
|
||||||
|
show_in_abo?: boolean
|
||||||
|
requirements?: string[]
|
||||||
|
exclusions?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Category = {
|
export type Category = {
|
||||||
@@ -34,6 +38,7 @@ export type ProductModule = {
|
|||||||
requirements: string[] // UUID[] anderer Module, die aktiv sein müssen
|
requirements: string[] // UUID[] anderer Module, die aktiv sein müssen
|
||||||
exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen
|
exclusions: string[] // UUID[] von Modulen, die nicht gleichzeitig aktiv sein dürfen
|
||||||
created_at: string
|
created_at: string
|
||||||
|
has_quantity?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Profile = {
|
export type Profile = {
|
||||||
@@ -62,6 +67,7 @@ export type EndCustomer = {
|
|||||||
street: string | null
|
street: string | null
|
||||||
zip: string | null
|
zip: string | null
|
||||||
city: string | null
|
city: string | null
|
||||||
|
email?: string | null
|
||||||
is_anonymized: boolean
|
is_anonymized: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -100,6 +106,8 @@ export type OrderModuleSnapshot = {
|
|||||||
module_id: string
|
module_id: string
|
||||||
module_name: string
|
module_name: string
|
||||||
price: number
|
price: number
|
||||||
|
quantity?: number
|
||||||
|
total_price?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrderItem = {
|
export type OrderItem = {
|
||||||
@@ -124,6 +132,8 @@ export type OrderSnapshot = {
|
|||||||
tax_rate: number
|
tax_rate: number
|
||||||
tax_amount: number
|
tax_amount: number
|
||||||
total: number
|
total: number
|
||||||
|
last_license_date?: string | null
|
||||||
|
price_multiplier?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
|
// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
|
||||||
@@ -151,6 +161,7 @@ export type LicenseOption = {
|
|||||||
active: boolean
|
active: boolean
|
||||||
billing: BillingInterval
|
billing: BillingInterval
|
||||||
price_net: number
|
price_net: number
|
||||||
|
quantity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseBundle = {
|
export type LicenseBundle = {
|
||||||
|
|||||||
@@ -7,5 +7,39 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
|
|
||||||
// This check can be removed, it is just for tutorial purposes
|
// This check can be removed, it is just for tutorial purposes
|
||||||
export const hasEnvVars =
|
export const hasEnvVars =
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL &&
|
(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL) &&
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
(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;
|
||||||
|
}
|
||||||
|
|||||||
93
shop/migrate.js
Normal file
93
shop/migrate.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -1,7 +1,30 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
cacheComponents: false,
|
cacheComponents: false,
|
||||||
|
poweredByHeader: false,
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'X-Frame-Options',
|
||||||
|
value: 'SAMEORIGIN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-Content-Type-Options',
|
||||||
|
value: 'nosniff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.supabase.net wss://*.supabase.net; frame-ancestors 'self'; form-action 'self';",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
217
shop/package-lock.json
generated
217
shop/package-lock.json
generated
@@ -19,12 +19,16 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@supabase/ssr": "latest",
|
"@supabase/ssr": "latest",
|
||||||
"@supabase/supabase-js": "latest",
|
"@supabase/supabase-js": "latest",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -34,7 +38,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@@ -3113,6 +3119,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz",
|
||||||
"integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==",
|
"integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.105.1",
|
"@supabase/auth-js": "2.105.1",
|
||||||
"@supabase/functions-js": "2.105.1",
|
"@supabase/functions-js": "2.105.1",
|
||||||
@@ -3144,6 +3151,16 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/adm-zip": {
|
||||||
|
"version": "0.5.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
|
||||||
|
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -3174,12 +3191,34 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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/pg": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3190,6 +3229,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -3248,6 +3288,7 @@
|
|||||||
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.1",
|
"@typescript-eslint/scope-manager": "8.59.1",
|
||||||
"@typescript-eslint/types": "8.59.1",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
@@ -3766,6 +3807,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3783,6 +3825,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -4246,6 +4297,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -4959,6 +5011,7 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5132,6 +5185,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -6424,6 +6478,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -6895,6 +6950,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -7166,6 +7230,96 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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",
|
||||||
|
"peer": true,
|
||||||
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -7243,6 +7397,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -7407,6 +7562,45 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -7614,6 +7808,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -7623,6 +7818,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -7635,6 +7831,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
|
||||||
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
|
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -8178,6 +8375,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -8602,6 +8808,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -8758,6 +8965,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -9101,6 +9309,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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -21,12 +21,16 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@supabase/ssr": "latest",
|
"@supabase/ssr": "latest",
|
||||||
"@supabase/supabase-js": "latest",
|
"@supabase/supabase-js": "latest",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -36,7 +40,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|||||||
@@ -16,13 +16,4 @@ ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
|
|||||||
-- Policies
|
-- Policies
|
||||||
CREATE POLICY "Allow public read access on categories" ON public.categories FOR SELECT USING (true);
|
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%';
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 = 'BE-' || 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';
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
195
shop/supabase/migrations/20260624000000_integrity_functions.sql
Normal file
195
shop/supabase/migrations/20260624000000_integrity_functions.sql
Normal 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;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Migration: Add email column to end_customers table
|
||||||
|
ALTER TABLE public.end_customers
|
||||||
|
ADD COLUMN IF NOT EXISTS email TEXT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add show_in_kauf and show_in_abo columns to products table
|
||||||
|
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS show_in_kauf BOOLEAN DEFAULT true;
|
||||||
|
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS show_in_abo BOOLEAN DEFAULT true;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add has_quantity column to product_modules table
|
||||||
|
ALTER TABLE public.product_modules ADD COLUMN IF NOT EXISTS has_quantity BOOLEAN DEFAULT false;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add requirements and exclusions columns to products table
|
||||||
|
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS requirements UUID[] DEFAULT '{}';
|
||||||
|
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS exclusions UUID[] DEFAULT '{}';
|
||||||
72
shop/utils/mail.ts
Normal file
72
shop/utils/mail.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// 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 Plain‑text body
|
||||||
|
* @param html Optional HTML body
|
||||||
|
*/
|
||||||
|
export async function sendMail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
attachments,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html?: string;
|
||||||
|
attachments?: {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer | string;
|
||||||
|
contentType?: 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,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user