refactor(wizard): optimize ui ux with no-page-scroll and hub flow
All checks were successful
Staging Build / build (push) Successful in 3m16s

This commit is contained in:
DanielS
2026-09-07 22:26:13 +02:00
parent 2a1897141e
commit 694e8dfbc1
6 changed files with 1523 additions and 1022 deletions

View File

@@ -2,20 +2,20 @@
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'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
ChevronRight,
ChevronLeft,
AlertCircle,
Check,
ShoppingCart,
Save,
Plus,
} from 'lucide-react'
import { Category, Product, CategorySelection } from '@/lib/types'
interface SummarySidebarProps {
visibleCategories: Category[]
@@ -34,19 +34,15 @@ interface SummarySidebarProps {
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
licenseNumber: string
setLicenseNumber: (num: string) => void
addToBasket: () => void
onSaveAndProceed: () => void
isNextStepDisabled: boolean
hasActiveSelection: boolean
showValidationWarning?: boolean
nextStep: () => void
prevStep: () => void
}
@@ -67,40 +63,56 @@ export function SummarySidebar({
updatePriceModifier,
allCategoriesFilled,
productValidationErrors,
basketItems,
editingIdx,
editBasketItem,
deleteBasketItem,
deviceName,
setDeviceName,
licenseNumber,
setLicenseNumber,
addToBasket,
onSaveAndProceed,
isNextStepDisabled,
hasActiveSelection,
showValidationWarning = false,
nextStep,
prevStep,
}: SummarySidebarProps) {
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
const isEditing = editingIdx !== null
const currentDeviceTitle = isEditing
? `Kasse ${editingIdx + 1}`
: (deviceName.trim() || 'Neue Kasse')
const isSaveDisabled = !allCategoriesFilled || productValidationErrors.length > 0
return (
<div className="h-full flex flex-col">
<Card className="bg-slate-900/80 backdrop-blur-xl border border-white/10 shadow-2xl rounded-2xl overflow-hidden flex flex-col h-full">
<CardHeader className="shrink-0 pb-3 border-b border-white/5">
<CardTitle className="text-white text-base font-bold flex items-center justify-between">
<span>Zusammenfassung</span>
{basketItems.length > 0 && (
<span className="text-xs bg-sky-500/20 text-sky-300 border border-sky-500/30 px-2 py-0.5 rounded-full font-semibold">
{basketItems.length} {basketItems.length === 1 ? 'Kasse' : 'Kassen'}
</span>
)}
<Card className="glass-dark border-white/10 shadow-2xl rounded-2xl overflow-hidden flex flex-col h-full bg-slate-900/90 backdrop-blur-xl">
{/* ─── 1. TOP HEADER (shrink-0) ─── */}
<CardHeader className="shrink-0 pb-3 pt-4 px-4 border-b border-white/10 bg-slate-950/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-white text-sm font-bold flex items-center gap-2">
<ShoppingCart className="w-4 h-4 text-primary" />
<span>Kassen-Konfiguration</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-3 flex-1 flex flex-col overflow-hidden gap-3">
{/* Active Selections & Price - scrollable */}
<div className="space-y-3 flex-1 overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
<Badge
variant="outline"
className={`text-[10px] px-2 py-0.5 font-semibold ${
isEditing
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-primary/40 bg-primary/10 text-primary'
}`}
>
{isEditing ? `Bearbeite #${editingIdx + 1}` : 'Neuer Eintrag'}
</Badge>
</div>
</CardHeader>
{/* ─── 2. SCROLLABLE MIDDLE (flex-1 min-h-0 overflow-y-auto) ─── */}
<CardContent className="p-4 flex-1 min-h-0 overflow-y-auto space-y-4 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
{/* Aktuelle Kassen-Auswahl */}
<div className="space-y-3">
<div className="flex items-center justify-between text-xs font-bold text-slate-300 uppercase tracking-wider">
<span>Gewählte Positionen</span>
</div>
{/* Selected Categories List */}
<div className="space-y-2">
{visibleCategories.map(cat => {
const sel = selections[cat.id]
const selectedProds: Product[] = []
@@ -115,13 +127,13 @@ export function SummarySidebar({
}
if (selectedProds.length === 0) return (
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1.5">
<div key={cat.id} className="text-[11px] text-slate-500 italic flex items-center gap-1.5">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive shrink-0" />
) : (
<span className="w-3 h-3 inline-block shrink-0" />
)}
<span>{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}</span>
<span>{cat.name}: {cat.is_required ? 'nicht gewählt (Pflicht)' : 'nicht gewählt (optional)'}</span>
</div>
)
@@ -129,27 +141,26 @@ export function SummarySidebar({
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{cat.name}</p>
<div key={cat.id} className="space-y-0.5 text-xs">
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wider">{cat.name}</p>
{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 tabular-nums">
<div key={prod.id} className="pl-1.5 space-y-0.5">
<div className="flex justify-between text-white text-xs">
<span className="truncate pr-2">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Inkl.)</span>}</span>
<span className="tabular-nums shrink-0 font-medium">
{fmt(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 tabular-nums">
<div key={mId} className="flex justify-between text-[11px] text-slate-400 pl-2">
<span className="truncate pr-2">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="tabular-nums shrink-0 font-medium text-slate-300">
{fmt(mod.price * qty)}
</span>
</div>
@@ -161,150 +172,93 @@ export function SummarySidebar({
</div>
)
})}
</div>
{/* Pricing block */}
<Separator className="bg-white/10" />
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-3 bg-slate-900/90 border border-white/10 rounded-xl p-3 shadow-inner">
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Einmalig:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span className="tabular-nums">{fmt(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span className="tabular-nums">{fmt(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-sm font-extrabold text-white pt-1">
<span>Brutto:</span>
<span className="tabular-nums text-sky-400">{fmt(oneTimeGross)}</span>
</div>
{/* Pricing Box */}
<div className="p-3 rounded-xl bg-slate-950/80 border border-white/10 space-y-2 text-xs">
{oneTimeTotal > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-slate-400">
<span>Kaufpreis Kasse (netto):</span>
<span className="tabular-nums font-mono">{fmt(oneTimeNet)}</span>
</div>
<div className="space-y-1 pt-2 border-t border-white/10">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Monatlich:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span className="tabular-nums">{fmt(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span className="tabular-nums">{fmt(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-sm font-extrabold text-white pt-1">
<span>Brutto:</span>
<span className="tabular-nums text-sky-400">{fmt(monthlyGross)} / mtl.</span>
</div>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="space-y-1 bg-slate-900/90 border border-white/10 rounded-xl p-3 shadow-inner">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span className="tabular-nums">{fmt(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span className="tabular-nums">{fmt(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-base font-extrabold text-sky-400 pt-1">
<span>Gesamt (brutto):</span>
<span className="tabular-nums">{fmt(oneTimeGross)}</span>
</div>
</div>
) : (
<div className="space-y-1 bg-slate-900/90 border border-white/10 rounded-xl p-3 shadow-inner">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span className="tabular-nums">{fmt(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span className="tabular-nums">{fmt(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-base font-extrabold text-sky-400 pt-1">
<span>Gesamt (brutto):</span>
<span className="tabular-nums">{fmt(monthlyGross)} / mtl.</span>
<div className="flex justify-between font-bold text-white">
<span>Kaufpreis Kasse (brutto):</span>
<span className="tabular-nums font-mono text-primary">{fmt(oneTimeGross)}</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 && showValidationWarning && (
<div className="text-xs text-red-400 flex items-start gap-2 bg-red-500/10 p-3 rounded-xl border border-red-500/40 animate-eye-catcher">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400 mt-0.5" />
<div>
<p className="font-extrabold uppercase tracking-wider text-[11px] text-red-300">Pflichtkategorie fehlt!</p>
<p className="text-[11px] text-slate-300 leading-relaxed">Bitte wählen Sie aus allen rot markierten Pflichtkategorien einen Artikel aus.</p>
{monthlyTotal > 0 && (
<div className={`space-y-1 ${oneTimeTotal > 0 ? 'pt-2 border-t border-white/10' : ''}`}>
<div className="flex justify-between text-slate-400">
<span>Mietpreis Kasse (netto):</span>
<span className="tabular-nums font-mono">{fmt(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between font-bold text-white">
<span>Mietpreis Kasse (brutto):</span>
<span className="tabular-nums font-mono text-primary">{fmt(monthlyGross)} / mtl.</span>
</div>
</div>
)}
{!allCategoriesFilled && !showValidationWarning && (
<div className="text-xs text-slate-400 flex items-start gap-2 bg-slate-900/50 p-2.5 rounded-xl border border-white/5">
<AlertCircle className="w-4 h-4 shrink-0 text-slate-500 mt-0.5" />
<div>
<p className="font-semibold text-[11px] text-slate-300">Pflichtkategorien beachten</p>
<p className="text-[11px] text-slate-400">Bitte wählen Sie für die Rot markierten Kategorien ein Produkt aus.</p>
</div>
</div>
)}
{productValidationErrors.map((err, errIdx) => (
<p key={errIdx} className="text-xs text-red-400 flex items-center gap-1 bg-red-500/10 p-2 rounded-lg border border-red-500/20">
<AlertCircle className="w-3.5 h-3.5 shrink-0 text-red-400" />
{err}
</p>
))}
</div> {/* Kassenname & Lizenznummer Direkt-Eingabe */}
<div className="pt-3 border-t border-white/10 space-y-2.5">
<p className="text-xs font-bold text-white uppercase tracking-wider">
{editingIdx !== null ? `Kasse ${editingIdx + 1} bearbeiten` : `Kassenbezeichnung`}
</p>
<div className="space-y-1.5">
<Label htmlFor="device-name-sidebar" className="text-[11px] text-slate-400">Kassenname (optional)</Label>
<Input
id="device-name-sidebar"
placeholder={`z.B. Kasse ${basketItems.length + 1}, Theke`}
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="license-number-sidebar" className="text-[11px] text-slate-400">Lizenznummer (optional)</Label>
<Input
id="license-number-sidebar"
placeholder="z.B. LIZ-12345 (eindeutig)"
value={licenseNumber}
onChange={e => setLicenseNumber(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg"
/>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2 shrink-0 p-4 pt-2 border-t border-white/10 bg-slate-950/80">
<Button
className="w-full h-11 text-sm font-bold bg-gradient-to-r from-blue-600 via-sky-500 to-cyan-400 hover:opacity-90 shadow-[0_0_20px_rgba(56,189,248,0.3)] transition-all"
onClick={nextStep}
disabled={isNextStepDisabled && basketItems.length === 0}
>
{editingIdx !== null ? (
<>Änderungen für &quot;{basketItems[editingIdx]?.deviceName || `Kasse ${editingIdx + 1}`}&quot; übernehmen</>
) : (
<>Weiter zu Schritt 4 <ChevronRight className="ml-1 w-4 h-4" /></>
)}
</Button>
<Button variant="ghost" className="w-full text-slate-400 hover:text-white h-7 text-xs" onClick={prevStep}>
Zurück zum Abrechnungsmodell
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
{/* Gerätename & Lizenznummer Eingabe */}
<div className="pt-2 border-t border-white/10 space-y-2.5">
<p className="text-xs font-bold text-white uppercase tracking-wider">
{isEditing ? `Kasse anpassen` : `Kassenbezeichnung`}
</p>
<div className="space-y-1">
<Label htmlFor="device-name-sidebar" className="text-[11px] text-slate-400">Kassenname (optional)</Label>
<Input
id="device-name-sidebar"
placeholder="z. B. Kasse 1, Hauptkasse, Theke"
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg"
/>
</div>
<div className="space-y-1">
<Label htmlFor="license-number-sidebar" className="text-[11px] text-slate-400">Lizenznummer (optional)</Label>
<Input
id="license-number-sidebar"
placeholder="z. B. LIZ-12345"
value={licenseNumber}
onChange={e => setLicenseNumber(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg font-mono uppercase"
/>
</div>
</div>
</CardContent>
{/* ─── 3. FIXED BOTTOM ACTIONS (shrink-0) ─── */}
<CardFooter className="flex flex-col gap-2 shrink-0 p-4 border-t border-white/10 bg-slate-950/90">
{/* Primary Action Button: Save & Proceed immediately to Step 4 */}
<Button
className="w-full h-11 text-xs font-bold bg-primary hover:bg-primary/90 text-white shadow-lg shadow-primary/20 gap-2"
onClick={onSaveAndProceed}
disabled={isSaveDisabled}
>
{isEditing ? (
<>
<Save className="w-4 h-4" /> Änderungen speichern & zur Übersicht <ChevronRight className="w-4 h-4 ml-0.5" />
</>
) : (
<>
<Plus className="w-4 h-4" /> Kasse zum Auftrag hinzufügen <ChevronRight className="w-4 h-4 ml-0.5" />
</>
)}
</Button>
{/* Back Button */}
<Button
variant="ghost"
className="w-full text-slate-400 hover:text-white h-7 text-xs"
onClick={prevStep}
>
<ChevronLeft className="w-3 h-3 mr-1" /> Zurück zum Abrechnungsmodell
</Button>
</CardFooter>
</Card>
)
}