Files
webshop/shop/components/wizard/step-software.tsx

417 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ShoppingCart, Check, AlertCircle, Lock, X, Info } from 'lucide-react'
import * as Icons from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { Category, Product, CategorySelection } from '@/lib/types'
interface StepSoftwareProps {
visibleCategories: Category[]
products: Product[]
selections: Record<string, CategorySelection>
selectProduct: (catId: string, prodId: string) => void
isProductDisabled: (prod: Product, catId: string) => boolean
isModuleDisabled: (mod: any, selectedModIds: string[]) => boolean
toggleModule: (catId: string, modId: string) => void
moduleQuantities: Record<string, number>
setModuleQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>
selectedBillingInterval: 'one_time' | 'monthly'
billingLabel: (interval: string) => string
billingBadgeClass: (interval: string) => string
/** Modul-IDs, die bereits lizenziert sind (Upgrade-Modus) */
existingModuleIds?: string[]
activeCategoryId: string | null
editingDeviceName?: string | null
onCancelEdit?: () => void
productValidationErrors?: string[]
}
export function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const LucideIcon = icon ? (Icons as any)[icon] : null
if (!LucideIcon) return <Icons.HelpCircle className={className} />
return <LucideIcon className={className} />
}
export function StepSoftware({
visibleCategories,
products,
selections,
selectProduct,
isProductDisabled,
isModuleDisabled,
toggleModule,
moduleQuantities,
setModuleQuantities,
selectedBillingInterval,
billingLabel,
billingBadgeClass,
existingModuleIds = [],
activeCategoryId,
editingDeviceName = null,
onCancelEdit,
productValidationErrors = [],
}: StepSoftwareProps) {
const currentCategory = visibleCategories.find(c => c.id === activeCategoryId) ?? visibleCategories[0] ?? null
if (!currentCategory) return null
const catProducts = products.filter(p => {
if (p.category_id !== currentCategory.id) return false
return selectedBillingInterval === 'one_time'
? p.show_in_kauf !== false
: p.show_in_abo !== false
})
const sel = selections[currentCategory.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
return (
<Card className="glass-dark border-white/10 h-full flex flex-col rounded-2xl overflow-hidden min-h-0">
{/* ─── FIXED CARD HEADER (shrink-0 mb-1) ─── */}
<CardHeader className="border-b border-white/10 py-2.5 px-4 shrink-0 bg-slate-950/40">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-primary/20 text-primary border border-primary/30 shrink-0">
<ShoppingCart className="w-4 h-4" />
</div>
<div>
<CardTitle className="text-sm font-bold text-white leading-tight">
{currentCategory.name}
</CardTitle>
<CardDescription className="text-[11px] text-slate-400 mt-0.5 leading-tight">
{currentCategory.description || 'Passen Sie die Konfiguration für diese Kategorie an.'}
</CardDescription>
</div>
</div>
{editingDeviceName && (
<div className="flex items-center gap-2">
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs font-bold gap-1.5 px-2.5 py-0.5 rounded-lg animate-pulse shrink-0">
Kasse: &quot;{editingDeviceName}&quot;
</Badge>
{onCancelEdit && (
<Button
size="sm"
variant="ghost"
onClick={onCancelEdit}
className="h-6 text-[11px] text-slate-400 hover:text-white hover:bg-white/10 px-2"
>
<X className="w-3 h-3 mr-1" /> Neuer Entwurf
</Button>
)}
</div>
)}
</div>
</CardHeader>
{/* ─── SCROLLABLE CONTENT AREA (flex-1 min-h-0 min-w-0 overflow-y-auto pr-3 pb-12) ─── */}
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-4 pr-3 pb-12 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
<AnimatePresence mode="wait">
<motion.div
key={currentCategory.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="space-y-4 min-w-0"
>
{/* Category selection status header */}
{(() => {
const isMissingRequired = currentCategory.is_required && !sel?.productId && (!sel?.productIds || sel.productIds.length === 0)
return (
<div className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
isMissingRequired
? 'bg-red-500/10 border-red-500/50 text-red-400 font-bold'
: 'bg-white/5 border-white/10'
}`}>
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
isMissingRequired ? 'bg-red-500/20 text-red-400' : 'bg-primary/20 text-primary'
}`}>
<CategoryIcon icon={currentCategory.icon} className="w-3.5 h-3.5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-bold text-white text-xs flex items-center gap-2 flex-wrap">
<span>{currentCategory.name}</span>
{isMissingRequired && (
<span className="text-[10px] bg-red-500/20 text-red-400 border border-red-500/40 px-2 py-0.2 rounded-full font-extrabold animate-pulse tracking-wider">
PFLICHTFELD AUSWÄHLEN!
</span>
)}
</h3>
<p className="text-slate-400 text-[10px] mt-0.5">
{currentCategory.allow_multiselect ? 'Mehrfachauswahl möglich' : 'Einzelauswahl'}
</p>
</div>
{sel?.productIds && sel.productIds.length > 0 ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30 text-[11px] rounded-full shrink-0">
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
</Badge>
) : sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30 text-[11px] rounded-full shrink-0">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
) : currentCategory.is_required ? (
<Badge variant="destructive" className="ml-auto opacity-90 text-[11px] rounded-full bg-red-500/30 text-red-400 border border-red-500/50 animate-pulse font-extrabold shrink-0">
<AlertCircle className="w-3 h-3 mr-1 text-red-400" /> Bitte auswählen!
</Badge>
) : (
<Badge variant="outline" className="ml-auto border-white/10 text-slate-400 text-[11px] rounded-full bg-white/5 shrink-0">
Optional
</Badge>
)}
</div>
)
})()}
{/* Products List (grid-cols-1 xl:grid-cols-2 gap-3) */}
{catProducts.length === 0 ? (
<p className="text-slate-500 text-xs italic p-4 bg-white/5 rounded-xl border border-white/5 text-center">
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
</p>
) : currentCategory.allow_multiselect ? (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
{catProducts.map(product => {
const isChecked = sel?.productIds?.includes(product.id) ?? false
const disabled = isProductDisabled(product, currentCategory.id)
return (
<div key={product.id} className="relative min-w-0">
<Label
onClick={() => !disabled && selectProduct(currentCategory.id, product.id)}
className={`flex flex-col justify-between h-full p-3 rounded-xl border transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="min-w-0 w-full">
<div className="flex justify-between items-start gap-2 w-full">
<div className="flex items-start gap-2 min-w-0 flex-1">
<Checkbox
checked={isChecked}
disabled={disabled}
onCheckedChange={() => { }}
className="border-white/20 data-[state=checked]:bg-primary rounded mt-0.5 shrink-0"
/>
<div className="min-w-0 flex-1">
<span className="font-bold text-xs text-white block break-words">{product.name}</span>
<span className={`inline-block text-[9px] px-1.5 py-0.2 mt-1 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
</span>
</div>
</div>
<span className="text-primary font-bold text-xs shrink-0 text-right">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-[10px] text-slate-500 font-normal block">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<p className="text-[11px] text-slate-400 mt-2 font-normal leading-relaxed break-words">{product.description}</p>
)}
</div>
{product.modules && product.modules.length > 0 && (
<span className="text-[10px] text-slate-500 mt-2 font-normal block">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</div>
) : (
<RadioGroup
value={sel?.productId ?? ''}
onValueChange={id => selectProduct(currentCategory.id, id)}
className="grid grid-cols-1 xl:grid-cols-2 gap-3"
>
{catProducts.map(product => {
const disabled = isProductDisabled(product, currentCategory.id)
const isChecked = sel?.productId === product.id
return (
<div key={product.id} className="relative min-w-0">
<RadioGroupItem
value={product.id}
id={`${currentCategory.id}-${product.id}`}
disabled={disabled}
className="peer sr-only"
/>
<Label
htmlFor={disabled ? undefined : `${currentCategory.id}-${product.id}`}
className={`flex flex-col justify-between h-full p-3 rounded-xl border transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="min-w-0 w-full">
<div className="flex justify-between items-start gap-2 w-full">
<div className="min-w-0 flex-1">
<span className="font-bold text-xs text-white block break-words">{product.name}</span>
<span className={`inline-block text-[9px] px-1.5 py-0.2 mt-1 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
</span>
</div>
<span className="text-primary font-bold text-xs shrink-0 text-right">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-[10px] text-slate-500 font-normal block">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<p className="text-[11px] text-slate-400 mt-2 font-normal leading-relaxed break-words">{product.description}</p>
)}
</div>
{product.modules && product.modules.length > 0 && (
<span className="text-[10px] text-slate-500 mt-2 font-normal block">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
)}
{/* Modules Section */}
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
<div className="mt-4 p-3.5 rounded-xl bg-white/5 border border-white/10 space-y-3 min-w-0">
<p className="text-xs font-bold text-white flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
Zusatzmodule für {selectedProduct.name}
</p>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-2.5">
{selectedProduct.modules.map(module => {
const isExistingLicense = existingModuleIds.includes(module.id)
const disabled = isExistingLicense || isModuleDisabled(module, sel?.moduleIds ?? [])
const checked = isExistingLicense || (sel?.moduleIds.includes(module.id) ?? false)
return (
<div
key={module.id}
title={isExistingLicense ? "Dieses Modul ist auf dieser Kasse bereits aktiv und dauerhaft lizenziert." : undefined}
className={`flex flex-col justify-between p-3 rounded-xl border transition-all duration-200 min-w-0 ${
isExistingLicense
? 'border-primary/20 bg-primary/5 opacity-75'
: disabled
? 'border-white/5 bg-white/5 opacity-40'
: checked
? 'border-primary bg-primary/10 shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40 text-white'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<div className="flex items-start space-x-2.5 min-w-0">
<Checkbox
id={`mod-${currentCategory.id}-${module.id}`}
checked={checked}
onCheckedChange={() => !isExistingLicense && toggleModule(currentCategory.id, module.id)}
disabled={disabled}
className="rounded border-white/20 data-[state=checked]:bg-primary mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<Label
htmlFor={isExistingLicense ? undefined : `mod-${currentCategory.id}-${module.id}`}
className={`font-semibold text-xs flex justify-between items-start text-white gap-2 ${
isExistingLicense ? 'cursor-default' : disabled ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<span className="flex-1 min-w-0 break-words">
{module.name}
{isExistingLicense && (
<span className="inline-flex items-center gap-1 text-[9px] px-1.5 py-0.2 rounded bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-wider ml-1 mt-0.5">
<Lock className="w-2.5 h-2.5" /> Lizenziert
</span>
)}
</span>
<span className="text-primary font-bold text-xs shrink-0 text-right">
{isExistingLicense ? (
<span className="text-slate-500 text-[11px] font-normal">inkl.</span>
) : (
<>+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}</>
)}
</span>
</Label>
{module.description && (
<p className="text-[11px] text-slate-400 mt-1 leading-relaxed break-words">{module.description}</p>
)}
{!isExistingLicense && disabled && (
<p className="text-[10px] text-red-400 mt-1 font-medium flex items-center gap-1">
<AlertCircle className="w-3 h-3 text-red-400 shrink-0" />
{module.requirements?.length && !module.requirements.some(
reqId => sel?.moduleIds.includes(reqId)
)
? 'Benötigt weitere Module'
: 'Nicht kombinierbar mit aktueller Auswahl'}
</p>
)}
</div>
</div>
{/* Scalable Quantity */}
{checked && !isExistingLicense && module.has_quantity && (
<div className="flex items-center gap-3 mt-2.5 pl-6 pt-2 border-t border-white/5">
<Label htmlFor={`qty-${module.id}`} className="text-[11px] text-slate-400 font-medium shrink-0">Menge:</Label>
<Input
id={`qty-${module.id}`}
type="number"
min={1}
max={999}
value={moduleQuantities[module.id] || 1}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value) || 1)
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
}}
className="w-14 h-6 bg-white/5 border-white/10 text-white text-xs text-center rounded focus:border-primary focus:ring-1 focus:ring-primary"
/>
<span className="text-[10px] text-slate-400 truncate">
Gesamt:{' '}
<span className="text-primary font-bold">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price * (moduleQuantities[module.id] || 1))}
</span>
</span>
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Inline Validation Warnings */}
{productValidationErrors.length > 0 && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 space-y-1">
{productValidationErrors.map((err, errIdx) => (
<p key={errIdx} className="text-xs text-red-400 flex items-center gap-1.5 font-medium">
<AlertCircle className="w-3.5 h-3.5 shrink-0 text-red-400" />
{err}
</p>
))}
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</Card>
)
}