refactor: subdivide monolithic order wizard into clean modular step components
All checks were successful
Staging Build / build (push) Successful in 2m54s

This commit is contained in:
DanielS
2026-07-10 15:11:03 +02:00
parent 4e82146ab7
commit 17d76f55e6
9 changed files with 1665 additions and 1321 deletions

View File

@@ -0,0 +1,299 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Pencil, Trash2, UserPlus, ChevronRight, AlertCircle, Check } from 'lucide-react'
import { Category, Product, CategorySelection } from '@/lib/types'
interface SummarySidebarProps {
visibleCategories: Category[]
selections: Record<string, CategorySelection>
products: Product[]
moduleQuantities: Record<string, number>
billingLabel: (interval: string) => string
oneTimeTotal: number
monthlyTotal: number
oneTimeNet: number
oneTimeTax: number
oneTimeGross: number
monthlyNet: number
monthlyTax: number
monthlyGross: number
updatePriceModifier: { label?: string }
allCategoriesFilled: boolean
productValidationErrors: string[]
basketItems: any[]
editingIdx: number | null
editBasketItem: (idx: number) => void
deleteBasketItem: (idx: number) => void
deviceName: string
setDeviceName: (name: string) => void
addToBasket: () => void
isNextStepDisabled: boolean
hasActiveSelection: boolean
nextStep: () => void
prevStep: () => void
}
export function SummarySidebar({
visibleCategories,
selections,
products,
moduleQuantities,
billingLabel,
oneTimeTotal,
monthlyTotal,
oneTimeNet,
oneTimeTax,
oneTimeGross,
monthlyNet,
monthlyTax,
monthlyGross,
updatePriceModifier,
allCategoriesFilled,
productValidationErrors,
basketItems,
editingIdx,
editBasketItem,
deleteBasketItem,
deviceName,
setDeviceName,
addToBasket,
isNextStepDisabled,
hasActiveSelection,
nextStep,
prevStep,
}: SummarySidebarProps) {
return (
<div className="space-y-6">
<Card className="glass-dark border-primary/20 sticky top-[20vh] backdrop-blur-lg bg-slate-950/75">
<CardHeader>
<CardTitle className="text-white">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{visibleCategories.map(cat => {
const sel = selections[cat.id]
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
sel.productIds.forEach(pId => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel?.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) return (
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className="w-3 h-3 inline-block" />
)}
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
</div>
)
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-400 font-medium">{cat.name}</span>
</div>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
return (
<div key={prod.id} className="space-y-0.5 pl-2">
<div className="flex justify-between text-sm">
<span className="text-white">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return mod ? (
<div key={mId} className="flex justify-between text-xs pl-2">
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
</span>
</div>
) : null
})}
</div>
)
})}
</div>
)
})}
<Separator className="bg-white/10" />
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmalig:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-sm font-bold text-white">
<span>Gesamt (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
<div className="space-y-1 pt-2 border-t border-white/10">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlich:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-sm font-bold text-white">
<span>Gesamt (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
) : (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
)}
{updatePriceModifier.label && (
<div className="text-xs text-green-400 bg-green-500/10 p-2.5 rounded-lg border border-green-500/20 space-y-1">
<p className="font-semibold flex items-center gap-1">
<Check className="w-3.5 h-3.5" />
Update-Rabatt aktiv
</p>
<p>{updatePriceModifier.label}</p>
</div>
)}
{!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Bitte aus jeder Pflichtkategorie einen Artikel wählen.
</p>
)}
{productValidationErrors.map((err, errIdx) => (
<p key={errIdx} className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3 text-destructive" />
{err}
</p>
))}
{basketItems.length > 0 && (
<div className="pt-4 border-t border-white/10 space-y-2">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Warenkorb ({basketItems.length}):</p>
<div className="space-y-1.5">
{basketItems.map((item, idx) => (
<div key={idx} className={`flex justify-between items-center text-xs bg-white/5 p-2 rounded border transition-all duration-300 ${idx === editingIdx ? 'border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]' : 'border-white/10'}`}>
<div className="flex flex-col">
<span className="text-white font-medium">{item.deviceName}</span>
<span className="text-[10px] text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
</div>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="icon"
className="w-7 h-7 hover:bg-white/10 text-slate-400 hover:text-white"
onClick={() => editBasketItem(idx)}
title="Kasse bearbeiten"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="w-7 h-7 hover:bg-white/10 text-destructive hover:text-red-400"
onClick={() => deleteBasketItem(idx)}
title="Kasse löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-3">
<div className="w-full space-y-2">
<Label htmlFor="device-name" className="text-xs text-slate-400">Kassenname (optional)</Label>
<Input
id="device-name"
placeholder="z.B. Hauptkasse, Theke"
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-9 rounded-lg"
/>
</div>
<Button
type="button"
variant="outline"
className="w-full border-primary/30 text-white hover:bg-primary/10 gap-2 h-10 text-sm"
onClick={addToBasket}
disabled={isNextStepDisabled}
>
<UserPlus className="w-4 h-4" /> {editingIdx !== null ? '💾 Änderungen an Kasse speichern' : (hasActiveSelection ? 'Kasse speichern' : 'Kasse anlegen')}
</Button>
<Separator className="bg-white/10 my-1" />
<Button
className="w-full h-12 text-lg"
onClick={nextStep}
disabled={basketItems.length === 0 && isNextStepDisabled}
>
Weiter <ChevronRight className="ml-2 w-5 h-5" />
</Button>
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
Zurück zum Abrechnungsmodell
</Button>
</CardFooter>
</Card>
</div>
)
}