Angebote bearbeiten fuer Admin und Ersteller implementiert
All checks were successful
Staging Build / build (push) Successful in 3m51s

This commit is contained in:
DanielS
2026-07-03 00:03:48 +02:00
parent 023c789baf
commit a4414cc0e6
5 changed files with 359 additions and 42 deletions

View File

@@ -132,6 +132,13 @@ export default async function MyOrdersPage() {
Details ansehen Details ansehen
</Button> </Button>
</Link> </Link>
{o.status === 'pending' && (
<Link href={`/order?id=${o.id}`}>
<Button variant="outline" size="sm" className="border-amber-500/20 hover:bg-amber-500/10 hover:border-amber-500/40 text-amber-400 text-xs">
Bearbeiten
</Button>
</Link>
)}
{o.pdf_url && ( {o.pdf_url && (
<> <>
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white"> <Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">

View File

@@ -6,7 +6,12 @@ import { OrderWizard } from '@/components/order-wizard'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { Suspense } from 'react' import { Suspense } from 'react'
export default async function OrderPage() { interface PageProps {
searchParams: Promise<{ id?: string }>
}
export default async function OrderPage({ searchParams }: PageProps) {
const params = await searchParams
return ( return (
<div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30"> <div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30">
<div className="container mx-auto py-10"> <div className="container mx-auto py-10">
@@ -20,19 +25,19 @@ export default async function OrderPage() {
</div> </div>
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}> <Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
<OrderDataWrapper /> <OrderDataWrapper orderId={params.id} />
</Suspense> </Suspense>
</div> </div>
</div> </div>
) )
} }
async function OrderDataWrapper() { async function OrderDataWrapper({ orderId }: { orderId?: string }) {
const supabase = await createClient() const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) { if (!user) {
redirect('/auth/login?next=/order') redirect(`/auth/login?next=/order${orderId ? `?id=${orderId}` : ''}`)
} }
// Check user role // Check user role
@@ -42,6 +47,32 @@ async function OrderDataWrapper() {
.eq('id', user.id) .eq('id', user.id)
.single() .single()
let initialOrder = null
if (orderId) {
const adminClient = createAdminClient()
const { data: orderData, error } = await adminClient
.from('orders')
.select('*')
.eq('id', orderId)
.single()
if (error || !orderData) {
redirect('/order')
}
const isAdmin = userData?.role === 'admin'
const isOwner = orderData.user_id === user.id
if (!isAdmin && !isOwner) {
redirect('/order')
}
if (orderData.status !== 'pending' && !isAdmin) {
redirect('/order')
}
initialOrder = orderData
}
const products = await getProducts().catch(() => []) const products = await getProducts().catch(() => [])
const categories = await getCategories().catch(() => []) const categories = await getCategories().catch(() => [])
@@ -75,5 +106,5 @@ async function OrderDataWrapper() {
console.error('Fehler beim Laden des Profils:', 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} initialOrder={initialOrder} />
} }

View File

@@ -216,22 +216,29 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{order.pdf_url ? ( <div className="flex justify-end gap-1.5">
<div className="flex justify-end gap-1.5"> <Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs">
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs"> <a href={`/order?id=${order.id}`}>
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer"> Bearbeiten
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF </a>
</a> </Button>
</Button> {order.pdf_url ? (
<Button variant="secondary" size="sm" asChild className="h-8 text-xs"> <>
<a href={`/api/admin/orders/${order.id}/download`}> <Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
<Download className="w-3.5 h-3.5" /> <a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
</a> <ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF
</Button> </a>
</div> </Button>
) : ( <Button variant="secondary" size="sm" asChild className="h-8 text-xs">
<span className="text-xs text-slate-500 italic">Keine Bestätigung</span> <a href={`/api/admin/orders/${order.id}/download`}>
)} <Download className="w-3.5 h-3.5" />
</a>
</Button>
</>
) : (
<span className="text-xs text-slate-500 italic self-center px-2">Keine Bestätigung</span>
)}
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@@ -3,7 +3,7 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Product, ProductModule, Profile, EndCustomer } from '@/lib/types' import { Product, ProductModule, Profile, EndCustomer, Order } from '@/lib/types'
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
@@ -12,7 +12,7 @@ import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2, UserPlus, Building2, CreditCard, Calendar, Search } from 'lucide-react' import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2, UserPlus, Building2, CreditCard, Calendar, Search } from 'lucide-react'
import { submitOrder } from '@/lib/actions/orders' import { submitOrder, updateOrder } from '@/lib/actions/orders'
import { createEndCustomer } from '@/lib/actions/end-customers' import { createEndCustomer } from '@/lib/actions/end-customers'
import { Category } from '@/lib/types' import { Category } from '@/lib/types'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -80,11 +80,13 @@ export function OrderWizard({
categories, categories,
initialProfile, initialProfile,
initialEndCustomers, initialEndCustomers,
initialOrder,
}: { }: {
products: Product[] products: Product[]
categories: Category[] categories: Category[]
initialProfile: Profile | null initialProfile: Profile | null
initialEndCustomers: EndCustomer[] initialEndCustomers: EndCustomer[]
initialOrder?: Order | null
}) { }) {
const router = useRouter() const router = useRouter()
const [step, setStep] = useState(1) const [step, setStep] = useState(1)
@@ -96,7 +98,9 @@ export function OrderWizard({
// Endkunden-State // Endkunden-State
const [endCustomers, setEndCustomers] = useState<EndCustomer[]>(initialEndCustomers) const [endCustomers, setEndCustomers] = useState<EndCustomer[]>(initialEndCustomers)
const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>( const [selectedEndCustomerId, setSelectedEndCustomerId] = useState<string | null>(
initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null initialOrder
? initialOrder.end_customer_id
: (initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null)
) )
const selectedEndCustomer = endCustomers.find(c => c.id === selectedEndCustomerId) ?? null const selectedEndCustomer = endCustomers.find(c => c.id === selectedEndCustomerId) ?? null
@@ -134,13 +138,26 @@ export function OrderWizard({
}) })
// Abrechnungsmodell-Auswahl ('one_time' = Kauf, 'monthly' = Abo) // Abrechnungsmodell-Auswahl ('one_time' = Kauf, 'monthly' = Abo)
const [selectedBillingInterval, setSelectedBillingInterval] = useState<'one_time' | 'monthly'>('monthly') const [selectedBillingInterval, setSelectedBillingInterval] = useState<'one_time' | 'monthly'>(
initialOrder?.order_data?.billing_cycle ?? 'monthly'
)
// Modulmengen (moduleId -> Menge) // Modulmengen (moduleId -> Menge)
const [moduleQuantities, setModuleQuantities] = useState<Record<string, number>>({}) const [moduleQuantities, setModuleQuantities] = useState<Record<string, number>>(() => {
if (!initialOrder) return {}
const quantities: Record<string, number> = {}
initialOrder.order_data?.items?.forEach(item => {
item.selected_modules?.forEach(mod => {
quantities[mod.module_id] = mod.quantity ?? 1
})
})
return quantities
})
// Datum der letzten Lizenz (Bestandskunden-Update-Logik) // Datum der letzten Lizenz (Bestandskunden-Update-Logik)
const [lastLicenseDate, setLastLicenseDate] = useState<string>("") const [lastLicenseDate, setLastLicenseDate] = useState<string>(
initialOrder?.order_data?.last_license_date ?? ""
)
const lastLicenseMonths = useMemo(() => { const lastLicenseMonths = useMemo(() => {
if (!lastLicenseDate || customerMode !== 'select' || !selectedEndCustomerId) return null if (!lastLicenseDate || customerMode !== 'select' || !selectedEndCustomerId) return null
@@ -174,6 +191,16 @@ export function OrderWizard({
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => { const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
const init: Record<string, CategorySelection> = {} const init: Record<string, CategorySelection> = {}
categories.forEach(cat => { categories.forEach(cat => {
if (initialOrder) {
const item = initialOrder.order_data?.items?.find(i => i.category_id === cat.id)
if (item) {
init[cat.id] = {
productId: item.product_id,
moduleIds: item.selected_modules?.map(m => m.module_id) ?? []
}
return
}
}
const first = products.find(p => p.category_id === cat.id && p.show_in_abo !== false) const first = products.find(p => p.category_id === cat.id && p.show_in_abo !== false)
init[cat.id] = { productId: first?.id ?? null, moduleIds: [] } init[cat.id] = { productId: first?.id ?? null, moduleIds: [] }
}) })
@@ -335,21 +362,34 @@ export function OrderWizard({
if (isSubmitting) return // Doppelklick-Guard if (isSubmitting) return // Doppelklick-Guard
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const order = await submitOrder({ let order
selections, if (initialOrder) {
moduleQuantities, order = await updateOrder(initialOrder.id, {
products, selections,
categories, moduleQuantities,
customerProfile: customerData, customerProfile: customerData,
endCustomerId: selectedEndCustomerId, endCustomerId: selectedEndCustomerId,
endCustomer: selectedEndCustomer, endCustomer: selectedEndCustomer,
billingInterval: selectedBillingInterval, billingInterval: selectedBillingInterval,
lastLicenseDate: lastLicenseDate || null, lastLicenseDate: lastLicenseDate || null,
}) })
} else {
order = await submitOrder({
selections,
moduleQuantities,
products,
categories,
customerProfile: customerData,
endCustomerId: selectedEndCustomerId,
endCustomer: selectedEndCustomer,
billingInterval: selectedBillingInterval,
lastLicenseDate: lastLicenseDate || null,
})
}
router.push(`/order/success?id=${order.id}`) router.push(`/order/success?id=${order.id}`)
} catch (error) { } catch (error: any) {
console.error(error) console.error(error)
alert('Fehler bei der Bestellung. Bitte versuchen Sie es erneut.') alert(error?.message || (initialOrder ? 'Fehler beim Aktualisieren des Angebots.' : 'Fehler bei der Bestellung. Bitte versuchen Sie es erneut.'))
setIsSubmitting(false) // Nur bei Fehler wieder entsperren setIsSubmitting(false) // Nur bei Fehler wieder entsperren
} }
} }
@@ -1106,9 +1146,13 @@ export function OrderWizard({
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? ( {isSubmitting ? (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Bestellung wird verarbeitet...</> initialOrder ? (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Angebot wird aktualisiert...</>
) : (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Bestellung wird verarbeitet...</>
)
) : ( ) : (
'Kostenpflichtig bestellen' initialOrder ? 'Angebot aktualisieren' : 'Kostenpflichtig bestellen'
)} )}
</Button> </Button>
<Button variant="ghost" className="w-full text-white" onClick={prevStep}> <Button variant="ghost" className="w-full text-white" onClick={prevStep}>

View File

@@ -374,3 +374,231 @@ export async function updateOrderStatus(
revalidatePath('/my-orders') revalidatePath('/my-orders')
return updatedOrder as Order return updatedOrder as Order
} }
export async function updateOrder(
orderId: string,
params: {
selections: WizardSelections
moduleQuantities?: Record<string, number>
customerProfile: Partial<Profile>
endCustomerId?: string | null
endCustomer?: EndCustomer | null
billingInterval?: 'one_time' | 'monthly'
lastLicenseDate?: string | null
}
): Promise<Order> {
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = params
const supabase = await createClient()
const admin = createAdminClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// 1. Fetch current order & verify permissions
const { data: existingOrder, error: fetchError } = await admin
.from('orders')
.select('*')
.eq('id', orderId)
.single()
if (fetchError || !existingOrder) {
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
}
const { data: dbUser } = await admin
.from('users')
.select('role')
.eq('id', user.id)
.single()
const isAdmin = dbUser?.role === 'admin'
const isOwner = existingOrder.user_id === user.id
if (!isAdmin && !isOwner) {
throw new Error('Keine Berechtigung dieses Angebot zu bearbeiten.')
}
if (existingOrder.status !== 'pending' && !isAdmin) {
throw new Error('Nur Angebote im Status "Eingegangen" können bearbeitet werden.')
}
// Fetch catalog directly from DB to prevent client tampering
const dbProducts = await getProducts()
const dbCategories = await getCategories()
// ─── Constraint Validation ──────────────────────────────────────────────────
for (const cat of dbCategories) {
const sel = selections[cat.id]
if (cat.is_required && (!sel || !sel.productId)) {
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
}
if (sel?.productId) {
const prod = dbProducts.find(p => p.id === sel.productId)
if (!prod) {
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
}
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) {
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
}
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const missing = mod.requirements.filter(reqId => !sel.moduleIds.includes(reqId))
if (missing.length > 0) {
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung anderer Module voraus.`)
}
}
// Exclusions check
if (mod.exclusions && mod.exclusions.length > 0) {
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
if (conflicting.length > 0) {
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
}
}
}
}
}
// Product-level constraints validation
const selectedProductIds = Object.values(selections)
.map(s => s.productId)
.filter((id): id is string => !!id)
for (const prodId of selectedProductIds) {
const prod = dbProducts.find(p => p.id === prodId)
if (!prod) continue
// Product requirements check
if (prod.requirements && prod.requirements.length > 0) {
const missing = prod.requirements.filter(reqId => !selectedProductIds.includes(reqId))
if (missing.length > 0) {
const missingNames = missing
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
.join(', ')
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von: ${missingNames}.`)
}
}
// Product exclusions check
if (prod.exclusions && prod.exclusions.length > 0) {
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
if (conflicting.length > 0) {
const conflictingNames = conflicting
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
.join(', ')
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
}
}
}
// 1. Snapshots aufbauen
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
const orderHash = hashOrderSnapshot(orderSnapshot)
// 2. Bestellung in DB aktualisieren
const { data: order, error: orderError } = await admin
.from('orders')
.update({
end_customer_id: endCustomerId ?? null,
total_price: orderSnapshot.total,
customer_data: customerSnapshot,
order_data: orderSnapshot,
order_hash: orderHash,
})
.eq('id', orderId)
.select()
.single()
if (orderError || !order) throw orderError || new Error('Fehler beim Aktualisieren der Bestellung.')
// 3. PDF generieren und überschreiben
try {
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order,
customer: customerSnapshot,
orderSnapshot,
})
)
// PDF hochladen (upsert: true, um das alte zu ersetzen)
const fileName = `ab_${order.id}.pdf`
const { error: uploadError } = await supabase
.storage
.from('invoices')
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
if (uploadError) {
console.error('PDF Upload Error:', uploadError)
} else {
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
await admin.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
order.pdf_url = publicUrlData.publicUrl
}
// 4. E-Mail mit PDF-Anhang an den Besteller senden (Info über Änderung)
const orderUserEmail = user.email || (await admin.auth.admin.getUserById(existingOrder.user_id).then(res => res.data.user?.email))
if (orderUserEmail) {
try {
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE')
const formattedTotal = order.total_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
await sendMail({
to: orderUserEmail,
subject: `Angebotsänderung ${order.order_number}`,
text: `Hallo,\n\nihr Angebot wurde erfolgreich aktualisiert!\n\nNeue Angebotsdetails:\n- Anfrage-Nummer: ${order.order_number}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n- Gesamtsumme: ${formattedTotal}\n\nIm Anhang dieser E-Mail finden Sie Ihr aktualisiertes Angebot als PDF-Dokument.\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">Ihr Angebot wurde aktualisiert!</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;">Ihr Angebot mit der Nummer ${order.order_number} wurde erfolgreich aktualisiert.</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Neue Anfrage-Details</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Anfrage-Nummer:</td>
<td style="padding: 4px 0;">${order.order_number}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
<td style="padding: 4px 0;">${formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
<td style="padding: 4px 0;">${customerSnapshot.company_name}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Gesamtsumme:</td>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a;">${formattedTotal}</td>
</tr>
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre aktualisierte Anfragebestätigung als PDF-Dokument.</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
</div>
`,
attachments: [
{
filename: `Angebotsbestaetigung_${order.order_number}.pdf`,
content: buffer,
contentType: 'application/pdf',
}
]
})
} catch (mailError) {
console.error('Failed to send order update confirmation mail:', mailError)
}
}
} catch (pdfError) {
console.error('PDF Re-generation Error:', pdfError)
}
revalidatePath('/my-customers')
revalidatePath('/my-orders')
revalidatePath('/admin/orders')
return order as Order
}