Compare commits
111 Commits
develop
...
2f96552fe8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
446
shop/app/admin/companies/[id]/customers/page.tsx
Normal file
446
shop/app/admin/companies/[id]/customers/page.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
Briefcase,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
getCompanyDetails,
|
||||||
|
getEndCustomersByCompanyWithPartners,
|
||||||
|
getCompanyPartners,
|
||||||
|
adminCreateEndCustomer,
|
||||||
|
adminUpdateEndCustomer,
|
||||||
|
adminDeleteEndCustomer
|
||||||
|
} from '@/lib/actions/companies'
|
||||||
|
|
||||||
|
export default function CompanyCustomersPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const companyId = params.id as string
|
||||||
|
|
||||||
|
const [company, setCompany] = useState<any>(null)
|
||||||
|
const [customers, setCustomers] = useState<any[]>([])
|
||||||
|
const [partners, setPartners] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingAction, setLoadingAction] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Dialog State
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [editingCustomer, setEditingCustomer] = useState<any>(null)
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [companyName, setCompanyName] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [street, setStreet] = useState('')
|
||||||
|
const [zip, setZip] = useState('')
|
||||||
|
const [city, setCity] = useState('')
|
||||||
|
const [vatId, setVatId] = useState('')
|
||||||
|
const [partnerId, setPartnerId] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [compDetails, custData, partData] = await Promise.all([
|
||||||
|
getCompanyDetails(companyId),
|
||||||
|
getEndCustomersByCompanyWithPartners(companyId),
|
||||||
|
getCompanyPartners(companyId)
|
||||||
|
])
|
||||||
|
setCompany(compDetails)
|
||||||
|
setCustomers(custData)
|
||||||
|
setPartners(partData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Daten:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (companyId) {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}, [companyId])
|
||||||
|
|
||||||
|
const handleOpenDialog = (customer: any = null) => {
|
||||||
|
setEditingCustomer(customer)
|
||||||
|
setCompanyName(customer ? customer.company_name || '' : '')
|
||||||
|
setFirstName(customer ? customer.first_name || '' : '')
|
||||||
|
setLastName(customer ? customer.last_name || '' : '')
|
||||||
|
setStreet(customer ? customer.street || '' : '')
|
||||||
|
setZip(customer ? customer.zip || '' : '')
|
||||||
|
setCity(customer ? customer.city || '' : '')
|
||||||
|
setVatId(customer ? customer.vat_id || '' : '')
|
||||||
|
setPartnerId(customer ? customer.partner_id || '' : (partners.length > 0 ? partners[0].id : ''))
|
||||||
|
setError(null)
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!partnerId) {
|
||||||
|
setError('Bitte weisen Sie einen Partner zu.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoadingAction(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
partner_id: partnerId,
|
||||||
|
company_name: companyName.trim(),
|
||||||
|
first_name: firstName.trim() || undefined,
|
||||||
|
last_name: lastName.trim() || undefined,
|
||||||
|
street: street.trim() || undefined,
|
||||||
|
zip: zip.trim() || undefined,
|
||||||
|
city: city.trim() || undefined,
|
||||||
|
vat_id: vatId.trim() || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingCustomer) {
|
||||||
|
await adminUpdateEndCustomer(editingCustomer.id, payload)
|
||||||
|
} else {
|
||||||
|
await adminCreateEndCustomer(payload)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Speichern des Endkunden.')
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
if (!confirm(`Endkunden "${name}" wirklich löschen?`)) return
|
||||||
|
setLoadingAction(true)
|
||||||
|
try {
|
||||||
|
await adminDeleteEndCustomer(id)
|
||||||
|
loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Löschen des Endkunden:', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCustomers = customers.filter((c) => {
|
||||||
|
const q = searchQuery.toLowerCase()
|
||||||
|
const nameMatch = c.company_name?.toLowerCase().includes(q)
|
||||||
|
const personMatch = `${c.first_name || ''} ${c.last_name || ''}`.toLowerCase().includes(q)
|
||||||
|
const addressMatch = `${c.street || ''} ${c.zip || ''} ${c.city || ''}`.toLowerCase().includes(q)
|
||||||
|
const partnerMatch = c.partner_name?.toLowerCase().includes(q)
|
||||||
|
return nameMatch || personMatch || addressMatch || partnerMatch
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||||
|
{/* Back Link */}
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href="/admin/companies"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Zurück zur Firmenverwaltung
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||||
|
<Briefcase className="w-8 h-8 text-indigo-500" />
|
||||||
|
Endkunden von {company ? company.name : '...'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||||
|
Hier verwalten Sie alle Endkunden, die Partnern dieser Firma zugeordnet sind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
disabled={loading || partners.length === 0}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold"
|
||||||
|
title={partners.length === 0 ? 'Es müssen zuerst Partner für diese Firma existieren' : ''}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> Endkunde anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning if no partners exist */}
|
||||||
|
{!loading && partners.length === 0 && (
|
||||||
|
<div className="p-4 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900/30 rounded-xl flex gap-3 text-amber-800 dark:text-amber-300 text-sm">
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Keine Partner vorhanden</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
Diese Firma hat aktuell keine zugewiesenen Partner (Benutzer mit der Rolle "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">USt-ID</th>
|
||||||
|
<th className="pb-3">Zugeordneter Partner</th>
|
||||||
|
<th className="pb-3 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||||
|
{filteredCustomers.map((customer) => (
|
||||||
|
<tr key={customer.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="font-semibold text-slate-900 dark:text-white">
|
||||||
|
{customer.company_name}
|
||||||
|
</div>
|
||||||
|
{(customer.first_name || customer.last_name) && (
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 mt-0.5">
|
||||||
|
<User className="w-3 h-3 text-slate-400 shrink-0" />
|
||||||
|
<span>{customer.first_name || ''} {customer.last_name || ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{customer.street ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{customer.street}, {customer.zip} {customer.city}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400 dark:text-slate-600">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{customer.vat_id || <span className="text-slate-400 dark:text-slate-600">–</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-semibold bg-indigo-50 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-300">
|
||||||
|
{customer.partner_name || 'Unbekannt'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleOpenDialog(customer)}
|
||||||
|
className="h-8 w-8 text-indigo-500 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||||
|
title="Endkunde bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={loadingAction}
|
||||||
|
onClick={() => handleDelete(customer.id, customer.company_name)}
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
title="Endkunde löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog: Endkunde anlegen / bearbeiten */}
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingCustomer ? 'Endkunden bearbeiten' : 'Neuen Endkunden anlegen'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||||
|
Geben Sie die Firmendaten und optional Kontaktdaten und Adresse des Endkunden an.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-company">Firmenname *</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-company"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Endkunde GmbH"
|
||||||
|
value={companyName}
|
||||||
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-firstname">Vorname</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-firstname"
|
||||||
|
type="text"
|
||||||
|
placeholder="Max"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-lastname">Nachname</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-lastname"
|
||||||
|
type="text"
|
||||||
|
placeholder="Mustermann"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-street">Straße & Hausnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-street"
|
||||||
|
type="text"
|
||||||
|
placeholder="Beispielstraße 12"
|
||||||
|
value={street}
|
||||||
|
onChange={(e) => setStreet(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2 col-span-1">
|
||||||
|
<Label htmlFor="cust-zip">PLZ</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-zip"
|
||||||
|
type="text"
|
||||||
|
placeholder="12345"
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<Label htmlFor="cust-city">Ort</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-city"
|
||||||
|
type="text"
|
||||||
|
placeholder="Musterort"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-vat">USt-ID (VAT-ID)</Label>
|
||||||
|
<Input
|
||||||
|
id="cust-vat"
|
||||||
|
type="text"
|
||||||
|
placeholder="DE123456789"
|
||||||
|
value={vatId}
|
||||||
|
onChange={(e) => setVatId(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cust-partner">Zugeordneter Partner *</Label>
|
||||||
|
<select
|
||||||
|
id="cust-partner"
|
||||||
|
required
|
||||||
|
value={partnerId}
|
||||||
|
onChange={(e) => setPartnerId(e.target.value)}
|
||||||
|
className="flex h-9 w-full rounded-md border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<option value="" disabled className="bg-white dark:bg-slate-950 text-slate-900 dark:text-white">Partner auswählen...</option>
|
||||||
|
{partners.map((p) => (
|
||||||
|
<option key={p.id} value={p.id} className="bg-white dark:bg-slate-950 text-slate-900 dark:text-white">
|
||||||
|
{p.name} ({p.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={loadingAction}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loadingAction} className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold">
|
||||||
|
{loadingAction ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
{editingCustomer ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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 Partner werden getrennt.`)) return
|
||||||
|
setDeletingId(id)
|
||||||
|
try {
|
||||||
|
await deleteCompany(id)
|
||||||
|
loadCompanies()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Löschen:', err)
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCompanies = companies.filter((c) => {
|
||||||
|
const nameMatch = c.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
const addressMatch = `${c.street || ''} ${c.zip || ''} ${c.city || ''}`.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
const emailMatch = c.email?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
return nameMatch || addressMatch || emailMatch
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||||
|
<Building2 className="w-8 h-8 text-blue-500" />
|
||||||
|
Firmenverwaltung
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||||
|
Verwalten Sie Firmen, deren Lieferadresse und weisen Sie Partner zu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Firmen-Tabelle */}
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
|
||||||
|
<div className="relative mb-6 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Firmen suchen (Name, Adresse, E-Mail)..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||||
|
<span className="ml-3 text-slate-400 text-sm">Lade Firmen...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredCompanies.length === 0 ? (
|
||||||
|
<div className="text-center py-16 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-xl text-slate-500">
|
||||||
|
<Building2 className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
Keine Firmen gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-white/10 text-slate-400 text-xs font-semibold uppercase">
|
||||||
|
<th className="pb-3">Firmenname</th>
|
||||||
|
<th className="pb-3">Lieferadresse</th>
|
||||||
|
<th className="pb-3">E-Mail</th>
|
||||||
|
<th className="pb-3">Erstellt am</th>
|
||||||
|
<th className="pb-3 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||||
|
{filteredCompanies.map((company) => (
|
||||||
|
<tr key={company.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||||
|
<td className="py-4 font-semibold text-slate-900 dark:text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-blue-500 shrink-0" />
|
||||||
|
{company.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{company.street ? `${company.street}, ${company.zip} ${company.city}` : '–'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{company.email ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||||
|
<span>{company.email}</span>
|
||||||
|
</div>
|
||||||
|
) : '–'}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{company.created_at ? new Date(company.created_at).toLocaleDateString('de-DE') : '–'}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
asChild
|
||||||
|
className="h-8 w-8 text-indigo-500 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||||
|
title="Endkunden dieser Firma verwalten"
|
||||||
|
>
|
||||||
|
<Link href={`/admin/companies/${company.id}/customers`}>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleOpenDialog(company)}
|
||||||
|
className="h-8 w-8 text-blue-500 hover:text-blue-400 hover:bg-blue-500/10"
|
||||||
|
title="Firma bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={deletingId === company.id}
|
||||||
|
onClick={() => handleDelete(company.id, company.name)}
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
title="Firma löschen"
|
||||||
|
>
|
||||||
|
{deletingId === company.id
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: <Trash2 className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog: Firma anlegen / bearbeiten */}
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||||
|
Geben Sie die Firmendetails und die vollständige Lieferadresse ein. Alle Felder sind Pflichtfelder.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-name">Firmenname</Label>
|
||||||
|
<Input
|
||||||
|
id="company-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Muster GmbH"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-street">Straße & Hausnummer</Label>
|
||||||
|
<Input
|
||||||
|
id="company-street"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Musterstraße 123"
|
||||||
|
value={street}
|
||||||
|
onChange={(e) => setStreet(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2 col-span-1">
|
||||||
|
<Label htmlFor="company-zip">PLZ</Label>
|
||||||
|
<Input
|
||||||
|
id="company-zip"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="12345"
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<Label htmlFor="company-city">Ort</Label>
|
||||||
|
<Input
|
||||||
|
id="company-city"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Musterstadt"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company-email">E-Mail für Lieferadresse</Label>
|
||||||
|
<Input
|
||||||
|
id="company-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="lieferung@musterfirma.de"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{createError}</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsOpen(false)} disabled={creating}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={creating} className="bg-blue-600 hover:bg-blue-700 text-white font-semibold">
|
||||||
|
{creating ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
{editingCompany ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
shop/app/admin/einstellungen/page.tsx
Normal file
44
shop/app/admin/einstellungen/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/* Admin Settings without Partner Management */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
export default function AdminSettings() {
|
||||||
|
const [demoActive, setDemoActive] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||||
|
setDemoActive(state);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDemo = (checked: boolean) => {
|
||||||
|
setDemoActive(checked);
|
||||||
|
localStorage.setItem('demo_banner_disabled', (!checked).toString());
|
||||||
|
window.dispatchEvent(new Event('storage_demo_changed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||||
|
Verwalten Sie globale Shopeinstellungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allgemeine Einstellungen */}
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-4">
|
||||||
|
<h2 className="text-lg font-bold">Allgemeine Einstellungen</h2>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-lg border border-slate-100 dark:border-white/5">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm text-slate-900 dark:text-white">Demo-Warnmeldungen</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">Schaltet gelbe Banner ein oder aus.</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,19 +1,13 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
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 & Rechnungen</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 PDFs zu.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,75 +28,8 @@ async function OrdersData() {
|
|||||||
.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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,7 +36,10 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<InactivityTracker />
|
||||||
|
<HeaderWrapper navbar={<Navbar />}>
|
||||||
{children}
|
{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");
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,66 +1,38 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Rocket, Shield, Cpu, ArrowRight } from 'lucide-react'
|
import { Shield, Cpu, ArrowRight, Key, Zap, CheckCircle2 } from 'lucide-react'
|
||||||
|
import { Navbar } from '@/components/Navbar'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
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">
|
{/* Hero Section: Zielgerichtet auf Lizenzen */}
|
||||||
⚠️ 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">
|
<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="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full -z-10 opacity-30" />
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<div className="flex flex-col items-center space-y-4 text-center">
|
<div className="flex flex-col items-center space-y-4 text-center">
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="inline-block rounded-full bg-blue-500/10 px-3 py-1 text-sm text-blue-600 dark:text-blue-400 font-medium border border-blue-500/20 mb-4">
|
||||||
⚠️ Demo-Umgebung für Testbestellungen
|
CASPOS Lizenz-Management-Server
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-7xl/none">
|
<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>
|
POS Software <span className="text-blue-600 dark:text-blue-500">Lizenzen & Module</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto max-w-[700px] text-slate-400 md:text-xl dark:text-zinc-400 mt-6">
|
<p className="mx-auto max-w-[700px] text-slate-600 dark:text-slate-400 md:text-xl mt-6">
|
||||||
Modulare Lösungen, einfaches Deployment und ein intuitives Interface für Ihre Business-Anforderungen.
|
Erwerben und verwalten Sie digitale Lizenzen für CASPOS Kassensysteme. Sofortige Aktivierung und automatische Bereitstellung für Ihre Kunden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* High-Contrast Buttons (Light/Dark Ready) */}
|
||||||
<div className="space-x-4 mt-8">
|
<div className="space-x-4 mt-8">
|
||||||
<Link href="/order">
|
<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">
|
<Button size="lg" className="h-12 px-8 text-lg font-bold transition-all hover:scale-105 bg-slate-900 text-white hover:bg-slate-800 border border-slate-200 dark:bg-white dark:text-black dark:hover:bg-slate-200 dark:border-none">
|
||||||
Jetzt bestellen <ArrowRight className="ml-2 h-5 w-5" />
|
Lizenz bestellen <ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/contact">
|
<Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer">
|
||||||
<Button size="lg" variant="outline" className="h-12 px-8 text-lg border-white/10 hover:bg-white/5">
|
<Button size="lg" variant="outline" className="h-12 px-8 text-lg font-semibold bg-transparent border-slate-400 text-slate-900 hover:bg-slate-100 dark:border-white/40 dark:text-white dark:hover:bg-white/10">
|
||||||
Contact Us
|
Fragen? Kontaktieren Sie uns
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,41 +40,71 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-white/5 bg-white/[0.02]">
|
{/* Feature Grid: Aufgeteilt nach echten POS-Modulen */}
|
||||||
|
<section className="w-full py-12 md:py-24 lg:py-32 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-white/[0.02]">
|
||||||
<div className="container mx-auto px-4 md:px-6">
|
<div className="container mx-auto px-4 md:px-6">
|
||||||
<div className="grid gap-12 lg:grid-cols-3">
|
<div className="grid gap-12 lg:grid-cols-3">
|
||||||
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl glass-dark">
|
|
||||||
<div className="p-3 bg-primary/10 rounded-xl">
|
{/* Modul 1 */}
|
||||||
<Cpu className="h-8 w-8 text-primary" />
|
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
|
||||||
|
<div className="p-3 bg-blue-600/10 rounded-xl">
|
||||||
|
<Cpu className="h-8 w-8 text-blue-600 dark:text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Handel</h3>
|
<h3 className="text-xl font-bold">Handel & ERP</h3>
|
||||||
<p className="text-slate-400">Von der einfachen Handelskasse bis hin zur vollwertigen ERP. Wir können es.</p>
|
<p className="text-slate-600 dark:text-slate-400">Erweiterungen für Handelskassen, Bestandsführung und vollwertige Warenwirtschaftssysteme (ERP).</p>
|
||||||
</div>
|
</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">
|
{/* Modul 2 */}
|
||||||
<Shield className="h-8 w-8 text-primary" />
|
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
|
||||||
|
<div className="p-3 bg-blue-600/10 rounded-xl">
|
||||||
|
<Zap className="h-8 w-8 text-blue-600 dark:text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Gastronomie</h3>
|
<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>
|
<p className="text-slate-600 dark:text-slate-400">Lizenzen für mobiles Bestellen (Orderman), digitale Küchen-Monitore und interaktive Abhol-Boards.</p>
|
||||||
</div>
|
</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">
|
{/* Modul 3 */}
|
||||||
<Rocket className="h-8 w-8 text-primary" />
|
<div className="flex flex-col items-center space-y-4 text-center p-6 rounded-2xl border border-slate-200 dark:border-white/5 bg-white dark:bg-slate-900/50 shadow-sm">
|
||||||
|
<div className="p-3 bg-blue-600/10 rounded-xl">
|
||||||
|
<Shield className="h-8 w-8 text-blue-600 dark:text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold">Hybrid Systeme</h3>
|
<h3 className="text-xl font-bold">Hybride Systeme</h3>
|
||||||
<p className="text-slate-400">Sie benötigen eine Lösung aus beiden Welten? Sprechen Sie uns an.</p>
|
<p className="text-slate-600 dark:text-slate-400">Das beste aus beiden Welten. Handel mit Gastronomie und umgekehrt. </p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Kurze Info-Sektion für Partner / Händler */}
|
||||||
|
<section className="w-full py-12 md:py-24 border-t border-slate-200 dark:border-white/5">
|
||||||
|
<div className="container mx-auto px-4 max-w-4xl text-center">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl mb-6">Wie funktioniert die Bereitstellung?</h2>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-3 text-left">
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>1. Lizenz wählen:</strong> Gewünschtes POS-Modul im Shop selektieren.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>2. Kunde zuweisen:</strong> Lizenz direkt einem Kunden-Account zuordnen.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-blue-600 dark:text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400"><strong>3. Live gehen:</strong> Lizenz-Key (`.key`) wird sofort serverseitig aktiviert.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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]">
|
{/* Footer */}
|
||||||
|
<footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-[#020617]">
|
||||||
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
<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">
|
<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="https://caspos.de/impressum/" target="_blank" rel="noopener noreferrer">Impressum</Link>
|
||||||
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="#">Datenschutz</Link>
|
<Link className="text-xs hover:underline underline-offset-4 text-slate-500" href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer">Datenschutz</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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} />;
|
||||||
|
}
|
||||||
284
shop/components/NavbarClient.tsx
Normal file
284
shop/components/NavbarClient.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import { type User } from "@supabase/supabase-js";
|
||||||
|
import { Rocket, Menu, X, User as UserIcon, LogOut } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { signOut } from "@/lib/actions/auth";
|
||||||
|
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||||
|
import { DemoWrapper } from "./DemoWrapper";
|
||||||
|
|
||||||
|
interface NavbarClientProps {
|
||||||
|
user: User | null;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||||
|
const [userRole, setUserRole] = useState<string>(role);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pathname, setPathname] = useState("/");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPathname(window.location.pathname);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
setUserRole(role);
|
||||||
|
}, [user, role]);
|
||||||
|
|
||||||
|
// Supabase URL & Anon Key auslesen (für Browser-Client)
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Browser Client inline erstellen
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
resolveSupabaseUrl(supabaseUrl)!,
|
||||||
|
supabaseAnonKey!,
|
||||||
|
{
|
||||||
|
cookieOptions: {
|
||||||
|
name: "webshop-auth-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auf Auth-Statusänderungen reagieren
|
||||||
|
useEffect(() => {
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
|
setCurrentUser(session?.user ?? null);
|
||||||
|
if (session?.user) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("role")
|
||||||
|
.eq("id", session.user.id)
|
||||||
|
.single();
|
||||||
|
if (data) {
|
||||||
|
setUserRole(data.role);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUserRole("partner");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserRole("partner");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fehler beim Server-Signout:", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fehler beim Client-Signout:", e);
|
||||||
|
}
|
||||||
|
setCurrentUser(null);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
|
||||||
|
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||||
|
<span className="font-bold text-xl tracking-tighter text-white">CASPOS Store</span>
|
||||||
|
<DemoWrapper>
|
||||||
|
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
||||||
|
Demo
|
||||||
|
</span>
|
||||||
|
</DemoWrapper>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex gap-6 items-center">
|
||||||
|
<Link
|
||||||
|
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
|
||||||
|
href="https://wiki.caspos.de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Zulip
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
|
||||||
|
href="https://wiki.caspos.de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Wiki
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
|
||||||
|
href="https://support.caspos.de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Support
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-white/10 mx-2" />
|
||||||
|
|
||||||
|
{currentUser ? (
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="border-white/10 hover:bg-white/5 gap-2 text-white"
|
||||||
|
>
|
||||||
|
<UserIcon className="w-4 h-4 text-primary" />
|
||||||
|
<span className="max-w-[120px] truncate">{currentUser.email}</span>
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10">
|
||||||
|
<div className="px-3 py-1.5 text-[11px] truncate border-b text-slate-400 border-slate-100 dark:border-white/5 mb-1">
|
||||||
|
{currentUser.email}
|
||||||
|
</div>
|
||||||
|
{userRole === "admin" ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/admin/einstellungen"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/account-kunden"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Account Einstellungen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/my-customers"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block px-3 py-2 text-sm rounded-lg font-medium hover:bg-slate-100 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Kundenliste
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<hr className="my-1 border-slate-100 dark:border-white/5" />
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm font-bold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button asChild className="bg-primary hover:bg-primary/95 text-white dark:text-blue-500">
|
||||||
|
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Hamburger Trigger */}
|
||||||
|
<button
|
||||||
|
className="md:hidden p-2 text-slate-300 hover:text-white focus:outline-none"
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Menu Panel */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="md:hidden fixed inset-x-0 top-16 bg-[#020617] border-b border-white/5 z-40 py-4 px-6 flex flex-col gap-4 animate-in slide-in-from-top duration-200">
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/order"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Bestellen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="https://caspos.de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Wiki
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="https://caspos.de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Support
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-white/5 my-1" />
|
||||||
|
<div className="text-xs text-slate-500 px-1">Mein Account ({currentUser.email})</div>
|
||||||
|
{userRole === "admin" ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/admin/einstellungen"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
className="text-lg font-medium text-slate-300 hover:text-white transition-colors py-2"
|
||||||
|
href="/my-customers"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Kundenliste
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="h-px bg-white/5 my-1" />
|
||||||
|
|
||||||
|
{currentUser ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full flex items-center justify-center gap-2"
|
||||||
|
onClick={handleSignOut}
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" /> Abmelden
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white dark:text-blue-500" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,13 @@ 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
|
requirements: z.array(z.string()).default([]),
|
||||||
exclusions: z.string().optional().default(''), // Will be converted to array
|
exclusions: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const productSchema = z.object({
|
const productSchema = z.object({
|
||||||
@@ -60,6 +62,7 @@ type ProductFormValues = z.infer<typeof productSchema>
|
|||||||
|
|
||||||
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
|
export function CreateProductDialog({ children, categories, product }: { children?: React.ReactNode, categories: Category[], product?: Product }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const form = useForm<ProductFormValues>({
|
const form = useForm<ProductFormValues>({
|
||||||
resolver: zodResolver(productSchema) as any,
|
resolver: zodResolver(productSchema) as any,
|
||||||
@@ -70,12 +73,13 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
category_id: product?.category_id || '',
|
category_id: product?.category_id || '',
|
||||||
billing_interval: product?.billing_interval || 'monthly',
|
billing_interval: product?.billing_interval || 'monthly',
|
||||||
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(', ') || '',
|
requirements: m.requirements || [],
|
||||||
exclusions: m.exclusions?.join(', ') || '',
|
exclusions: m.exclusions || [],
|
||||||
})) || [],
|
})) || [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -90,8 +94,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 +105,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,12 +120,12 @@ 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>
|
||||||
@@ -136,9 +140,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
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 +153,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 +168,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 +191,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 +203,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>
|
||||||
@@ -218,16 +222,16 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
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" />
|
<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,14 +244,31 @@ 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,
|
||||||
|
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') || []
|
||||||
|
const otherModules = allModules
|
||||||
|
.map((m, idx) => ({
|
||||||
|
id: m.id || field.id,
|
||||||
|
name: m.name || `Modul ${idx + 1}`,
|
||||||
|
}))
|
||||||
|
.filter((_, idx) => idx !== index)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.id} className="p-4 rounded-lg bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 space-y-4 relative group">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -264,9 +285,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
name={`modules.${index}.name`}
|
name={`modules.${index}.name`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Modulname</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Modulname</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...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>
|
||||||
@@ -277,9 +298,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
name={`modules.${index}.price`}
|
name={`modules.${index}.price`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Aufpreis (€)</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Aufpreis (€)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" step="0.01" className="bg-white/10 border-white/10 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>
|
||||||
@@ -287,31 +308,94 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4 border border-slate-200 dark:border-white/10 rounded-lg p-3 bg-slate-100 dark:bg-black/20">
|
||||||
|
{/* Requirements Selection Matrix */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`modules.${index}.requirements`}
|
name={`modules.${index}.requirements`}
|
||||||
render={({ field }) => (
|
render={({ field: reqField }) => (
|
||||||
<FormItem>
|
<div className="space-y-2">
|
||||||
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Benötigt Module</FormLabel>
|
||||||
<FormControl>
|
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
{otherModules.length === 0 ? (
|
||||||
</FormControl>
|
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||||
<FormMessage />
|
) : (
|
||||||
</FormItem>
|
<div className="space-y-1.5">
|
||||||
|
{otherModules.map(other => {
|
||||||
|
const checked = (reqField.value || []).includes(other.id)
|
||||||
|
return (
|
||||||
|
<div key={other.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`req-${index}-${other.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(checkedState) => {
|
||||||
|
const currentVal = reqField.value || []
|
||||||
|
if (checkedState) {
|
||||||
|
reqField.onChange([...currentVal, other.id])
|
||||||
|
} else {
|
||||||
|
reqField.onChange(currentVal.filter(id => id !== other.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`req-${index}-${other.id}`}
|
||||||
|
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||||
|
title={other.name}
|
||||||
|
>
|
||||||
|
{other.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Exclusions Selection Matrix */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`modules.${index}.exclusions`}
|
name={`modules.${index}.exclusions`}
|
||||||
render={({ field }) => (
|
render={({ field: exclField }) => (
|
||||||
<FormItem>
|
<div className="space-y-2">
|
||||||
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Schließt aus</FormLabel>
|
||||||
<FormControl>
|
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||||
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
|
{otherModules.length === 0 ? (
|
||||||
</FormControl>
|
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||||
<FormMessage />
|
) : (
|
||||||
</FormItem>
|
<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>
|
||||||
@@ -329,17 +413,18 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="space-y-1 leading-none">
|
<div className="space-y-1 leading-none">
|
||||||
<FormLabel className="text-white">Erforderlich</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">Erforderlich</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</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>
|
||||||
)}
|
)}
|
||||||
@@ -347,9 +432,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
shop/components/admin/orders-table.tsx
Normal file
197
shop/components/admin/orders-table.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Download, ExternalLink, Search } from "lucide-react";
|
||||||
|
|
||||||
|
interface OrdersTableProps {
|
||||||
|
initialOrders: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
pending: 'Eingegangen',
|
||||||
|
active: 'In Bearbeitung',
|
||||||
|
completed: 'Abgeschlossen',
|
||||||
|
cancelled: 'Storniert',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClass: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-500/20 text-amber-400 border border-amber-500/30',
|
||||||
|
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||||
|
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||||
|
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
const filteredOrders = initialOrders.filter((order) => {
|
||||||
|
const orderNum = (order.order_number || "").toLowerCase();
|
||||||
|
const company = (order.customer_data?.company_name || "").toLowerCase();
|
||||||
|
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
orderNum.includes(search.toLowerCase()) ||
|
||||||
|
company.includes(search.toLowerCase()) ||
|
||||||
|
contact.includes(search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filterleiste */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
|
||||||
|
<div className="relative w-full md:max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Bestellung, Firma oder Name suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "all" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("all")}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "pending" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("pending")}
|
||||||
|
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
|
||||||
|
>
|
||||||
|
Eingegangen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "active" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("active")}
|
||||||
|
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
|
||||||
|
>
|
||||||
|
In Bearbeitung
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "completed" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("completed")}
|
||||||
|
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
|
||||||
|
>
|
||||||
|
Abgeschlossen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={statusFilter === "cancelled" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter("cancelled")}
|
||||||
|
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
|
||||||
|
>
|
||||||
|
Storniert
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabelle */}
|
||||||
|
<div className="rounded-xl border border-white/5 bg-black/20 overflow-hidden backdrop-blur-xl">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
|
||||||
|
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Datum</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Kunde</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Auswahl</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Betrag</TableHead>
|
||||||
|
<TableHead className="text-slate-200">Status</TableHead>
|
||||||
|
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredOrders.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
|
||||||
|
Keine Bestellungen gefunden.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredOrders.map((order) => {
|
||||||
|
const items = order.order_data?.items || [];
|
||||||
|
return (
|
||||||
|
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<TableCell className="font-mono text-xs text-primary font-semibold">
|
||||||
|
#{order.order_number || order.id.slice(0, 8)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-300 text-sm">
|
||||||
|
{new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-semibold text-white text-sm">
|
||||||
|
{order.customer_data?.company_name || 'Privatkunde'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
{order.customer_data?.first_name} {order.customer_data?.last_name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px]">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{items.map((item: any) => (
|
||||||
|
<span
|
||||||
|
key={item.product_id}
|
||||||
|
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
|
||||||
|
>
|
||||||
|
{item.product_name}
|
||||||
|
{item.selected_modules?.length > 0 && (
|
||||||
|
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-bold text-white text-sm">
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={`text-xs px-2 py-0.5 rounded ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
|
||||||
|
{statusLabel[order.status] ?? order.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{order.pdf_url ? (
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||||
|
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
|
||||||
|
<a href={order.pdf_url} download>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500 italic">Keine Rechnung</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,12 +14,16 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Edit2, Trash2, Layers } from 'lucide-react'
|
import { Edit2, Trash2, Layers } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { deleteProduct } from '@/lib/actions/products'
|
import { deleteProduct } from '@/lib/actions/products'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { CreateProductDialog } from './create-product-dialog'
|
import { CreateProductDialog } from './create-product-dialog'
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
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?')) {
|
||||||
await deleteProduct(id)
|
await deleteProduct(id)
|
||||||
@@ -28,28 +32,28 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>Produktübersicht</CardTitle>
|
<CardTitle>Produktübersicht</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-500 dark:text-slate-400">
|
||||||
Alle konfigurierten Software-Lösungen und Erweiterungen.
|
Alle konfigurierten Software-Lösungen und Erweiterungen.
|
||||||
</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">Name</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
|
||||||
<TableHead className="text-white font-bold">Basispreis</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
|
||||||
<TableHead className="text-white font-bold">Module</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
|
||||||
<TableHead className="text-white font-bold">Status</TableHead>
|
<TableHead className="text-slate-700 dark:text-white font-bold">Status</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>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
<TableRow key={product.id} className="border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
|
||||||
<TableCell className="font-medium text-white">
|
<TableCell className="font-medium text-slate-900 dark:text-white">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{product.name}
|
{product.name}
|
||||||
@@ -59,20 +63,20 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{product.billing_interval === 'monthly' && (
|
{product.billing_interval === 'monthly' && (
|
||||||
<Badge className="text-[10px] bg-blue-500/20 text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
|
<Badge className="text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
|
||||||
)}
|
)}
|
||||||
{product.billing_interval === 'one_time' && (
|
{product.billing_interval === 'one_time' && (
|
||||||
<Badge className="text-[10px] bg-slate-500/20 text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
|
<Badge className="text-[10px] bg-slate-500/20 text-slate-600 dark:text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-300 font-normal">{product.description}</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400 font-normal">{product.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
|
<TableCell className="text-slate-900 dark:text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Layers className="w-3 h-3 text-primary" />
|
<Layers className="w-3 h-3 text-primary" />
|
||||||
<span className="text-slate-200">{product.modules?.length || 0} Module</span>
|
<span className="text-slate-700 dark:text-slate-200">{product.modules?.length || 0} Module</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -83,7 +87,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<CreateProductDialog categories={categories} product={product}>
|
<CreateProductDialog categories={categories} product={product}>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-white">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CreateProductDialog>
|
</CreateProductDialog>
|
||||||
@@ -101,7 +105,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
|||||||
))}
|
))}
|
||||||
{products.length === 0 && (
|
{products.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center py-10 text-slate-400">
|
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
|
||||||
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
|
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export function AdminRecentOrders() {
|
|||||||
|
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||||
|
const res = await fetch(`${baseUrl}/api/admin/recent-orders`, { cache: 'no-store' });
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const { orders: fresh } = await res.json()
|
const { orders: fresh } = await res.json()
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 @@ 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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -89,19 +160,176 @@ export function UserDialog() {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="last_name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-white">Passwort (optional)</FormLabel>
|
<FormLabel className="text-slate-900 dark:text-white">Nachname</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="password" placeholder="Passwort leer lassen für Zufall" className="bg-white/5 border-white/10 text-white" {...field} />
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white">Rolle</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
className="flex h-10 w-full rounded-md border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-slate-950/80 px-3 py-2 text-sm text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||||
|
{...field}
|
||||||
|
>
|
||||||
|
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
|
||||||
|
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="company_id"
|
||||||
|
render={({ field }) => {
|
||||||
|
const selectedCompany = localCompanies.find((c) => c.id === field.value)
|
||||||
|
return (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-slate-900 dark:text-white">Firma</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-slate-500 shrink-0" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDropdownOpen(!dropdownOpen)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className="w-full text-left text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md px-3 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 max-w-full truncate"
|
||||||
|
>
|
||||||
|
{selectedCompany ? selectedCompany.name : 'Keine Firma'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="absolute z-50 mt-1 left-0 w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-lg shadow-xl overflow-hidden">
|
||||||
|
<div className="p-2 border-b border-slate-200 dark:border-white/10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Firma suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded px-2 py-1.5 text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange('')
|
||||||
|
setDropdownOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||||
|
!field.value ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Keine Firma
|
||||||
|
</button>
|
||||||
|
{localCompanies
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(c.id)
|
||||||
|
setDropdownOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}}
|
||||||
|
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||||
|
field.value === c.id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNewCompanyInput ? (
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Name der neuen Firma"
|
||||||
|
value={newCompanyName}
|
||||||
|
onChange={(e) => setNewCompanyName(e.target.value)}
|
||||||
|
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs h-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const trimmed = newCompanyName.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
setIsCreatingCompany(true)
|
||||||
|
try {
|
||||||
|
const created = await createCompany({ name: trimmed })
|
||||||
|
if (created) {
|
||||||
|
setLocalCompanies((prev) => [...prev, created])
|
||||||
|
field.onChange(created.id)
|
||||||
|
setShowNewCompanyInput(false)
|
||||||
|
setNewCompanyName('')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create company:', err)
|
||||||
|
} finally {
|
||||||
|
setIsCreatingCompany(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isCreatingCompany}
|
||||||
|
className="bg-primary text-xs h-8"
|
||||||
|
>
|
||||||
|
{isCreatingCompany ? 'Erstellt...' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewCompanyInput(false)
|
||||||
|
setNewCompanyName('')
|
||||||
|
}}
|
||||||
|
className="text-xs h-8 text-slate-500 dark:text-slate-400"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewCompanyInput(true)}
|
||||||
|
className="text-xs text-primary hover:underline mt-1 self-start font-medium"
|
||||||
|
>
|
||||||
|
+ Neue Firma anlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<DialogFooter>
|
<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?')) {
|
||||||
|
setLoadingId(id)
|
||||||
|
try {
|
||||||
await deleteUser(id)
|
await deleteUser(id)
|
||||||
router.refresh()
|
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')
|
||||||
|
try {
|
||||||
|
await updateUserRole(id, newRole)
|
||||||
router.refresh()
|
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,39 +124,243 @@ 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 && (
|
||||||
|
<TableRow className="bg-slate-50 dark:bg-white/5 border-y border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/5">
|
||||||
|
<TableCell colSpan={5} className="font-semibold text-primary py-2 px-4 text-xs tracking-wider uppercase">
|
||||||
|
Firma: {currentCompanyName}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
<TableRow className="border-slate-150 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
|
||||||
|
<TableCell className="font-medium text-slate-900 dark:text-white">
|
||||||
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Mail className="w-4 h-4 text-slate-500" />
|
<Mail className="w-4 h-4 text-slate-500" />
|
||||||
{user.email}
|
<span>{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-1 pl-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={user.first_name || ''}
|
||||||
|
placeholder="Vorname"
|
||||||
|
onBlur={async (e) => {
|
||||||
|
const val = e.target.value.trim()
|
||||||
|
if (val !== (user.first_name || '')) {
|
||||||
|
try {
|
||||||
|
await updateUserProfile(user.id, { first_name: val || null, last_name: user.last_name })
|
||||||
|
router.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update first name:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-transparent border-b border-transparent hover:border-slate-300 dark:hover:border-white/20 focus:border-primary focus:outline-none text-xs text-slate-600 dark:text-slate-400 w-20 px-1 py-0.5 transition-all placeholder:text-slate-500 placeholder:italic"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={user.last_name || ''}
|
||||||
|
placeholder="Nachname"
|
||||||
|
onBlur={async (e) => {
|
||||||
|
const val = e.target.value.trim()
|
||||||
|
if (val !== (user.last_name || '')) {
|
||||||
|
try {
|
||||||
|
await updateUserProfile(user.id, { first_name: user.first_name, last_name: val || null })
|
||||||
|
router.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update last name:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-transparent border-b border-transparent hover:border-slate-300 dark:hover:border-white/20 focus:border-primary focus:outline-none text-xs text-slate-600 dark:text-slate-400 w-24 px-1 py-0.5 transition-all placeholder:text-slate-500 placeholder:italic"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={isBanned ? "destructive" : "default"} className={isBanned ? "" : "bg-green-500/20 text-green-500 border-green-500/30"}>
|
<div className="flex items-center gap-2">
|
||||||
{isBanned ? "Gesperrt" : "Aktiv"}
|
{isLoadingRole ? (
|
||||||
</Badge>
|
<Loader2 className="h-4 w-4 animate-spin text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => handleRoleChange(user.id, e.target.value as Role)}
|
||||||
|
className={`text-xs font-semibold rounded px-3 py-1.5 cursor-pointer focus:outline-none focus:ring-2 transition-colors bg-white dark:bg-slate-950/80 ${
|
||||||
|
role === 'admin'
|
||||||
|
? 'text-purple-600 dark:text-purple-300 border border-purple-500/40 focus:ring-purple-500/50'
|
||||||
|
: role === 'gesperrt'
|
||||||
|
? 'text-red-600 dark:text-red-300 border border-red-500/40 focus:ring-red-500/50'
|
||||||
|
: 'text-blue-600 dark:text-blue-300 border border-blue-500/40 focus:ring-blue-500/50'
|
||||||
|
}`}
|
||||||
|
style={{ appearance: 'auto' }}
|
||||||
|
>
|
||||||
|
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
|
||||||
|
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
|
||||||
|
<option value="gesperrt" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Gesperrt</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="relative" ref={companyDropdownOpen === user.id ? dropdownRef : undefined}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-slate-500 shrink-0" />
|
||||||
|
{isLoadingCompany ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCompanyDropdownOpen(companyDropdownOpen === user.id ? null : user.id)
|
||||||
|
setCompanySearch('')
|
||||||
|
}}
|
||||||
|
className="text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded px-3 py-1.5 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer focus:outline-none focus:ring-1 focus:ring-primary/50 max-w-[180px] truncate text-left"
|
||||||
|
>
|
||||||
|
{user.company_name || 'Keine Firma'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{companyDropdownOpen === user.id && (
|
||||||
|
<div className="absolute z-50 mt-1 left-0 w-56 bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-lg shadow-xl overflow-hidden">
|
||||||
|
<div className="p-2 border-b border-slate-200 dark:border-white/10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Firma suchen..."
|
||||||
|
value={companySearch}
|
||||||
|
onChange={(e) => setCompanySearch(e.target.value)}
|
||||||
|
className="w-full text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded px-2 py-1.5 text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { handleCompanyChange(user.id, ''); setCompanyDropdownOpen(null); setCompanySearch(''); }}
|
||||||
|
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||||
|
!user.company_id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Keine Firma
|
||||||
|
</button>
|
||||||
|
{companies
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase()))
|
||||||
|
.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { handleCompanyChange(user.id, c.id); setCompanyDropdownOpen(null); setCompanySearch(''); }}
|
||||||
|
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||||
|
user.company_id === c.id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-slate-400 text-sm">
|
<TableCell className="text-slate-400 text-sm">
|
||||||
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString('de-DE') : 'Noch nie'}
|
{user.last_sign_in_at ? new Date(user.last_sign_in_at).toLocaleString('de-DE') : 'Noch nie'}
|
||||||
@@ -91,11 +379,12 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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'}`}
|
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={isBanned ? "Entsperren" : "Sperren"}
|
title={role === 'gesperrt' ? 'Entsperren' : 'Sperren'}
|
||||||
onClick={() => handleToggleBan(user.id, isBanned)}
|
onClick={() => handleToggleLock(user.id, role)}
|
||||||
|
disabled={loadingId === user.id + '-lock'}
|
||||||
>
|
>
|
||||||
{isBanned ? <ShieldAlert className="h-4 w-4" /> : <UserX className="h-4 w-4" />}
|
{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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -103,12 +392,14 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
|||||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
title="Löschen"
|
title="Löschen"
|
||||||
onClick={() => handleDelete(user.id)}
|
onClick={() => handleDelete(user.id)}
|
||||||
|
disabled={isLoadingDelete}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
{isLoadingDelete ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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,16 +26,11 @@ 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 : "An error occurred");
|
||||||
|
|||||||
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,7 +94,14 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||||
|
const items = orderSnapshot?.items ?? [];
|
||||||
|
const subtotal = orderSnapshot?.subtotal ?? 0;
|
||||||
|
const taxAmount = orderSnapshot?.tax_amount ?? 0;
|
||||||
|
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||||
|
const total = orderSnapshot?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="A4" style={styles.page}>
|
<Page size="A4" style={styles.page}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -123,38 +130,58 @@ export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
|||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.label}>Bestelldetails</Text>
|
<Text style={styles.label}>Bestelldetails</Text>
|
||||||
<Text>Bestellnummer: {order.id}</Text>
|
<Text>Bestellnummer: {order.order_number || order.id}</Text>
|
||||||
<Text>Datum: {new Date().toLocaleDateString('de-DE')}</Text>
|
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.table}>
|
<View style={styles.table}>
|
||||||
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||||
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||||
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (mtl.)</Text></View>
|
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{items.map((item: any, idx: number) => (
|
||||||
|
<View key={idx}>
|
||||||
<View style={styles.tableRow}>
|
<View style={styles.tableRow}>
|
||||||
<View style={styles.tableCol}><Text>{product.name} (Basispaket)</Text></View>
|
<View style={styles.tableCol}>
|
||||||
<View style={styles.tableColPrice}><Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</Text></View>
|
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.tableColPrice}>
|
||||||
{modules.map((mod: any) => (
|
<Text>
|
||||||
<View key={mod.id} style={styles.tableRow}>
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||||
<View style={styles.tableCol}><Text>{mod.name}</Text></View>
|
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||||
<View style={styles.tableColPrice}><Text>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.additional_price)}</Text></View>
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{item.selected_modules?.map((mod: any, mIdx: number) => (
|
||||||
|
<View key={mIdx} style={styles.tableRow}>
|
||||||
|
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
||||||
|
<Text style={{ color: '#666' }}>+ {mod.module_name}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.tableColPrice}>
|
||||||
|
<Text style={{ color: '#666' }}>
|
||||||
|
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.total}>
|
<View style={styles.total}>
|
||||||
<View style={{ width: '40%' }}>
|
<View style={{ width: '50%' }}>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<Text>Zwischensumme:</Text>
|
<Text>Netto-Zwischensumme:</Text>
|
||||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
|
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(subtotal)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5 }}>
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
<Text style={styles.totalLabel}>Gesamtbetrag:</Text>
|
<Text>zzgl. {taxRate}% USt:</Text>
|
||||||
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_amount)}</Text>
|
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(taxAmount)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
|
||||||
|
<Text style={styles.totalLabel}>Gesamtbetrag (brutto):</Text>
|
||||||
|
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(total)}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -166,3 +193,4 @@ export const InvoicePDF = ({ order, product, modules, customer }: any) => (
|
|||||||
</Page>
|
</Page>
|
||||||
</Document>
|
</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,22 +24,36 @@ 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 || "An error occurred");
|
||||||
});
|
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 : "An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,20 +102,21 @@ export function LoginForm({
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{errorParam === "gesperrt" && (
|
||||||
|
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
|
||||||
|
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messageParam === "concurrent" && (
|
||||||
|
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
||||||
|
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{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 ? "Logging in..." : "Login"}
|
{isLoading ? "Logging in..." : "Login"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-center text-sm">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link
|
|
||||||
href="/auth/sign-up"
|
|
||||||
className="underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -48,12 +48,18 @@ function toggleModuleInList(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const isSelected = currentIds.includes(toggleId)
|
const isSelected = currentIds.includes(toggleId)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
const next = currentIds.filter(id => id !== toggleId)
|
// Recursive removal: repeatedly filter until no more dependent modules are removed
|
||||||
// cascade: remove modules that required this one
|
let next = currentIds.filter(id => id !== toggleId)
|
||||||
return next.filter(mId => {
|
let changed = true
|
||||||
|
while (changed) {
|
||||||
|
const beforeLength = next.length
|
||||||
|
next = next.filter(mId => {
|
||||||
const m = modules.find(mod => mod.id === mId)
|
const m = modules.find(mod => mod.id === mId)
|
||||||
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId)
|
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId))
|
||||||
}).filter(id => id !== toggleId)
|
})
|
||||||
|
changed = next.length !== beforeLength
|
||||||
|
}
|
||||||
|
return next
|
||||||
} else {
|
} else {
|
||||||
const module = modules.find(m => m.id === toggleId)
|
const module = modules.find(m => m.id === toggleId)
|
||||||
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
|
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
|
||||||
@@ -118,20 +124,29 @@ export function OrderWizard({
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Total price across all selections
|
// Total price across all selections
|
||||||
const totalPrice = useMemo(() => {
|
// Total price calculations (split by billing interval)
|
||||||
let total = 0
|
const { monthlyTotal, oneTimeTotal } = useMemo(() => {
|
||||||
|
let monthly = 0
|
||||||
|
let oneTime = 0
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
const sel = selections[cat.id]
|
const sel = selections[cat.id]
|
||||||
if (!sel?.productId) continue
|
if (!sel?.productId) continue
|
||||||
const prod = products.find(p => p.id === sel.productId)
|
const prod = products.find(p => p.id === sel.productId)
|
||||||
if (!prod) continue
|
if (!prod) continue
|
||||||
total += Number(prod.base_price)
|
const base = Number(prod.base_price)
|
||||||
|
let modulesSum = 0
|
||||||
sel.moduleIds.forEach(mId => {
|
sel.moduleIds.forEach(mId => {
|
||||||
const mod = prod.modules?.find(m => m.id === mId)
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
if (mod) total += Number(mod.price)
|
if (mod) modulesSum += Number(mod.price)
|
||||||
})
|
})
|
||||||
|
const totalItem = base + modulesSum
|
||||||
|
if (prod.billing_interval === 'one_time') {
|
||||||
|
oneTime += totalItem
|
||||||
|
} else {
|
||||||
|
monthly += totalItem
|
||||||
}
|
}
|
||||||
return total
|
}
|
||||||
|
return { monthlyTotal: monthly, oneTimeTotal: oneTime }
|
||||||
}, [selections, products, categories])
|
}, [selections, products, categories])
|
||||||
|
|
||||||
// Helper: update product selection for a category (resets modules)
|
// Helper: update product selection for a category (resets modules)
|
||||||
@@ -425,12 +440,38 @@ export function OrderWizard({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
|
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-base font-semibold text-white">
|
||||||
|
<span className="text-slate-400">Einmalig gesamt:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-base font-semibold text-white">
|
||||||
|
<span className="text-slate-400">Monatlich gesamt:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-xs font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : oneTimeTotal > 0 ? (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
|
<span>Gesamt (einmalig):</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
<span>Gesamt:</span>
|
<span>Gesamt:</span>
|
||||||
<span>
|
<span>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{!allCategoriesFilled && (
|
{!allCategoriesFilled && (
|
||||||
<p className="text-xs text-destructive flex items-center gap-1">
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3 h-3" />
|
||||||
@@ -669,15 +710,43 @@ export function OrderWizard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||||
<span>Gesamtbetrag:</span>
|
<div className="space-y-2 border-t border-white/10 pt-4">
|
||||||
|
<div className="flex justify-between text-lg font-bold text-white">
|
||||||
|
<span className="text-slate-400">Einmaliger Gesamtbetrag:</span>
|
||||||
<span>
|
<span>
|
||||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between text-lg font-bold text-white">
|
||||||
|
<span className="text-slate-400">Monatlicher Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : oneTimeTotal > 0 ? (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
|
||||||
|
<span>Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient border-t border-white/10 pt-4">
|
||||||
|
<span>Gesamtbetrag:</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTotal)}
|
||||||
|
{' '}<span className="text-sm font-normal text-slate-400">/ Monat</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-slate-500 italic">
|
<p className="text-xs text-slate-500 italic">
|
||||||
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
|
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
|
||||||
Datenschutzerklärung.
|
<a href="https://caspos.de/datenschutz/" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</a>.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-4">
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
231
shop/lib/actions/companies.ts
Normal file
231
shop/lib/actions/companies.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
'use server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export async function getCompanies() {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data, error } = await admin.from('companies').select('*')
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCompany(data: { name: string; street?: string; zip?: string; city?: string; email?: string }) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin.from('companies').insert({
|
||||||
|
name: data.name,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
email: data.email || null,
|
||||||
|
}).select().single()
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/companies')
|
||||||
|
return dbData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCompany(id: string, data: { name: string; street?: string; zip?: string; city?: string; email?: string }) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin.from('companies').update({
|
||||||
|
name: data.name,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
email: data.email || null,
|
||||||
|
}).eq('id', id).select().single()
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/companies')
|
||||||
|
return dbData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCompany(id: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { error } = await admin.from('companies').delete().eq('id', id)
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/companies')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignCompanyToUser(userId: string, companyId: string | null) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { error } = await admin.from('users').update({ company_id: companyId }).eq('id', userId)
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/admin/users')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEndCustomersByCompany(companyId: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
|
||||||
|
// 1. Get all partners of this company
|
||||||
|
const { data: partners, error: partnersError } = await admin
|
||||||
|
.from('users')
|
||||||
|
.select('id')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
if (partnersError) throw partnersError
|
||||||
|
|
||||||
|
if (!partners || partners.length === 0) return []
|
||||||
|
|
||||||
|
const partnerIds = partners.map((p) => p.id)
|
||||||
|
|
||||||
|
// 2. Get all end customers belonging to these partners
|
||||||
|
const { data: customers, error: customersError } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.select('*')
|
||||||
|
.in('partner_id', partnerIds)
|
||||||
|
if (customersError) throw customersError
|
||||||
|
|
||||||
|
return customers || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanyDetails(companyId: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data, error } = await admin.from('companies').select('*').eq('id', companyId).single()
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEndCustomersByCompanyWithPartners(companyId: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
|
||||||
|
// 1. Get all partners of this company
|
||||||
|
const { data: partners, error: partnersError } = await admin
|
||||||
|
.from('users')
|
||||||
|
.select('id')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
if (partnersError) throw partnersError
|
||||||
|
|
||||||
|
if (!partners || partners.length === 0) return []
|
||||||
|
|
||||||
|
const partnerIds = partners.map((p) => p.id)
|
||||||
|
|
||||||
|
// 2. Get all end customers belonging to these partners
|
||||||
|
const { data: customers, error: customersError } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.select('*')
|
||||||
|
.in('partner_id', partnerIds)
|
||||||
|
if (customersError) throw customersError
|
||||||
|
|
||||||
|
// 3. Get profiles of all these partners to resolve their names
|
||||||
|
const { data: profiles, error: profilesError } = await admin
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, first_name, last_name')
|
||||||
|
.in('id', partnerIds)
|
||||||
|
if (profilesError) throw profilesError
|
||||||
|
|
||||||
|
// Get user emails
|
||||||
|
const { data: { users }, error: authError } = await admin.auth.admin.listUsers()
|
||||||
|
const emailMap = new Map((users || []).map(u => [u.id, u.email || '']))
|
||||||
|
|
||||||
|
const profileMap = new Map((profiles || []).map((p) => [p.id, p]))
|
||||||
|
|
||||||
|
return customers.map((c) => {
|
||||||
|
const prof = profileMap.get(c.partner_id)
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
partner_name: prof ? `${prof.first_name || ''} ${prof.last_name || ''}`.trim() || emailMap.get(c.partner_id) || 'Unbekannt' : emailMap.get(c.partner_id) || 'Unbekannt',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanyPartners(companyId: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: usersData, error: usersError } = await admin
|
||||||
|
.from('users')
|
||||||
|
.select('id, role')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
if (usersError) throw usersError
|
||||||
|
|
||||||
|
if (!usersData || usersData.length === 0) return []
|
||||||
|
|
||||||
|
const userIds = usersData.map(u => u.id)
|
||||||
|
|
||||||
|
const { data: profiles, error: profilesError } = await admin
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, first_name, last_name')
|
||||||
|
.in('id', userIds)
|
||||||
|
if (profilesError) throw profilesError
|
||||||
|
|
||||||
|
const { data: { users }, error: authError } = await admin.auth.admin.listUsers()
|
||||||
|
const emailMap = new Map((users || []).map(u => [u.id, u.email || '']))
|
||||||
|
|
||||||
|
const profileMap = new Map((profiles || []).map(p => [p.id, p]))
|
||||||
|
|
||||||
|
return usersData.map(u => {
|
||||||
|
const prof = profileMap.get(u.id)
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
email: emailMap.get(u.id) || '',
|
||||||
|
name: prof ? `${prof.first_name || ''} ${prof.last_name || ''}`.trim() || emailMap.get(u.id) || 'Unbekannt' : emailMap.get(u.id) || 'Unbekannt',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCreateEndCustomer(data: {
|
||||||
|
partner_id: string
|
||||||
|
company_name: string
|
||||||
|
vat_id?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
street?: string
|
||||||
|
zip?: string
|
||||||
|
city?: string
|
||||||
|
}) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.insert([{
|
||||||
|
partner_id: data.partner_id,
|
||||||
|
company_name: data.company_name,
|
||||||
|
vat_id: data.vat_id || null,
|
||||||
|
first_name: data.first_name || null,
|
||||||
|
last_name: data.last_name || null,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
}])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return dbData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdateEndCustomer(id: string, data: {
|
||||||
|
partner_id: string
|
||||||
|
company_name: string
|
||||||
|
vat_id?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
street?: string
|
||||||
|
zip?: string
|
||||||
|
city?: string
|
||||||
|
}) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: dbData, error } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.update({
|
||||||
|
partner_id: data.partner_id,
|
||||||
|
company_name: data.company_name,
|
||||||
|
vat_id: data.vat_id || null,
|
||||||
|
first_name: data.first_name || null,
|
||||||
|
last_name: data.last_name || null,
|
||||||
|
street: data.street || null,
|
||||||
|
zip: data.zip || null,
|
||||||
|
city: data.city || null,
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
if (error) throw error
|
||||||
|
return dbData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteEndCustomer(id: string) {
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { error } = await admin
|
||||||
|
.from('end_customers')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
if (error) throw error
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -143,3 +144,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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,22 +49,62 @@ function hashOrderSnapshot(snapshot: object): string {
|
|||||||
*/
|
*/
|
||||||
export async function submitOrder(params: {
|
export async function submitOrder(params: {
|
||||||
selections: WizardSelections
|
selections: WizardSelections
|
||||||
products: Product[]
|
products?: Product[]
|
||||||
categories: Category[]
|
categories?: Category[]
|
||||||
customerProfile: Partial<Profile>
|
customerProfile: Partial<Profile>
|
||||||
endCustomerId?: string | null
|
endCustomerId?: string | null
|
||||||
endCustomer?: EndCustomer | null
|
endCustomer?: EndCustomer | null
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params
|
const { selections, customerProfile, endCustomerId, endCustomer } = 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.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -144,7 +185,7 @@ export async function submitOrder(params: {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 @@ 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 || [],
|
||||||
|
}))
|
||||||
const { error: modulesError } = await supabase
|
const { error: modulesError } = await supabase
|
||||||
.from('product_modules')
|
.from('product_modules')
|
||||||
.insert(modulesWithId)
|
.insert(modulesWithId)
|
||||||
@@ -106,12 +118,13 @@ 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
|
|
||||||
}))
|
}))
|
||||||
const { error: modulesError } = await supabase
|
const { error: modulesError } = await supabase
|
||||||
.from('product_modules')
|
.from('product_modules')
|
||||||
@@ -120,6 +133,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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,233 @@
|
|||||||
'use server'
|
'use server';
|
||||||
|
|
||||||
import { createAdminClient } from '@/lib/supabase/admin'
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { sendMail } from '@/utils/mail';
|
||||||
|
|
||||||
export async function getUsers() {
|
export async function getUsers() {
|
||||||
const admin = createAdminClient()
|
const admin = createAdminClient();
|
||||||
const { data: { users }, error } = await admin.auth.admin.listUsers()
|
const { data: { users }, error } = await admin.auth.admin.listUsers();
|
||||||
if (error) throw error
|
if (error) throw error;
|
||||||
return users
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(data: { email: string; password?: string; email_confirm?: boolean }) {
|
const profileMap: Record<string, { first_name: string | null; last_name: string | null }> = {};
|
||||||
const admin = createAdminClient()
|
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; role?: 'admin' | 'partner'; email_confirm?: boolean; company_id?: string; first_name?: string; last_name?: string }) {
|
||||||
|
const admin = createAdminClient();
|
||||||
const { data: { user }, error } = await admin.auth.admin.createUser({
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -59,6 +63,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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { updateSession } from "@/lib/supabase/proxy";
|
import { updateSession } from "@/lib/supabase/proxy";
|
||||||
import { type NextRequest } from "next/server";
|
import { type NextRequest } from "next/server";
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
return await updateSession(request);
|
return await updateSession(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
cacheComponents: false,
|
cacheComponents: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
167
shop/package-lock.json
generated
167
shop/package-lock.json
generated
@@ -19,12 +19,15 @@
|
|||||||
"@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",
|
||||||
"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",
|
||||||
@@ -3174,6 +3177,15 @@
|
|||||||
"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/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",
|
||||||
@@ -6895,6 +6907,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 +7187,95 @@
|
|||||||
"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",
|
||||||
|
"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",
|
||||||
@@ -7407,6 +7517,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",
|
||||||
@@ -8178,6 +8327,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",
|
||||||
@@ -9101,6 +9259,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"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,15 @@
|
|||||||
"@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",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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 = 'RE-' || EXTRACT(YEAR FROM created_at)::text || '-' || floor(10000 + random() * 90000)::text
|
||||||
|
WHERE order_number IS NULL;
|
||||||
|
|
||||||
|
-- Now add unique constraint and make it not null
|
||||||
|
ALTER TABLE public.orders
|
||||||
|
ALTER COLUMN order_number SET NOT NULL,
|
||||||
|
ADD CONSTRAINT orders_order_number_key UNIQUE (order_number);
|
||||||
|
|
||||||
|
-- Notify PostgREST to reload schema cache
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
@@ -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;
|
||||||
65
shop/utils/mail.ts
Normal file
65
shop/utils/mail.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// utils/mail.ts – simple wrapper around nodemailer
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email using database-configured SMTP settings (or environment variable fallback).
|
||||||
|
* @param to Recipient address
|
||||||
|
* @param subject Subject line
|
||||||
|
* @param text Plain‑text body
|
||||||
|
* @param html Optional HTML body
|
||||||
|
*/
|
||||||
|
export async function sendMail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html?: string;
|
||||||
|
}) {
|
||||||
|
// Query custom SMTP settings from database
|
||||||
|
const supabase = createAdminClient();
|
||||||
|
const { data: dbSettings } = await supabase
|
||||||
|
.from('settings')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', 'smtp')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
// Resolve config from DB or environment variables
|
||||||
|
const host = dbSettings?.host || process.env.SMTP_HOST;
|
||||||
|
const port = dbSettings?.port ? Number(dbSettings.port) : Number(process.env.SMTP_PORT);
|
||||||
|
const secure = dbSettings
|
||||||
|
? !!dbSettings.secure
|
||||||
|
: (process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1');
|
||||||
|
const user = dbSettings?.user || process.env.SMTP_USER;
|
||||||
|
const pass = dbSettings?.pass || process.env.SMTP_PASS;
|
||||||
|
|
||||||
|
if (!host || !user) {
|
||||||
|
throw new Error('SMTP host and user must be configured (either in settings database table or environment variables).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create transporter dynamically on send request
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: user,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user