Compare commits
4 Commits
6d6bb4dfe6
...
17d76f55e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d76f55e6 | ||
|
|
4e82146ab7 | ||
|
|
83a019785b | ||
|
|
f091b97ca0 |
File diff suppressed because it is too large
Load Diff
39
shop/components/wizard/progress-stepper.tsx
Normal file
39
shop/components/wizard/progress-stepper.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ProgressStepperProps {
|
||||||
|
step: number
|
||||||
|
basketItemsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressStepper({ step, basketItemsCount }: ProgressStepperProps) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto mb-12">
|
||||||
|
<div className="flex justify-between relative">
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-white/10 -translate-y-1/2 z-0" />
|
||||||
|
{[1, 2, 3, 4].map(s => (
|
||||||
|
<div key={s} className="relative z-10 flex flex-col items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
|
||||||
|
step >= s
|
||||||
|
? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
|
||||||
|
: 'bg-slate-800 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step > s ? <Check className="w-5 h-5" /> : s}
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-semibold ${step >= s ? 'text-primary' : 'text-slate-500'} flex items-center gap-1`}>
|
||||||
|
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
|
||||||
|
{s === 3 && basketItemsCount > 0 && (
|
||||||
|
<span className="bg-primary text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
|
||||||
|
{basketItemsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
shop/components/wizard/step-billing.tsx
Normal file
127
shop/components/wizard/step-billing.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { CreditCard, Calendar, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
interface StepBillingProps {
|
||||||
|
selectedBillingInterval: 'one_time' | 'monthly'
|
||||||
|
handleBillingIntervalChange: (val: 'one_time' | 'monthly') => void
|
||||||
|
customerMode: 'select' | 'create'
|
||||||
|
selectedEndCustomerId: string | null
|
||||||
|
lastLicenseDate: string
|
||||||
|
setLastLicenseDate: (date: string) => void
|
||||||
|
prevStep: () => void
|
||||||
|
nextStep: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepBilling({
|
||||||
|
selectedBillingInterval,
|
||||||
|
handleBillingIntervalChange,
|
||||||
|
customerMode,
|
||||||
|
selectedEndCustomerId,
|
||||||
|
lastLicenseDate,
|
||||||
|
setLastLicenseDate,
|
||||||
|
prevStep,
|
||||||
|
nextStep,
|
||||||
|
}: StepBillingProps) {
|
||||||
|
return (
|
||||||
|
<Card className="glass-dark border-white/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||||
|
<CreditCard className="w-6 h-6 text-primary" />
|
||||||
|
Abrechnungsmodell wählen
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-300">
|
||||||
|
Möchten Sie Lizenzen dauerhaft kaufen oder monatlich mieten?
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 py-4">
|
||||||
|
{/* Option 1: Kauf */}
|
||||||
|
<div
|
||||||
|
onClick={() => handleBillingIntervalChange('one_time')}
|
||||||
|
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
|
||||||
|
selectedBillingInterval === 'one_time'
|
||||||
|
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
|
||||||
|
: 'border-white/5 bg-white/5 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'one_time' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
|
||||||
|
<CreditCard className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-white">Einmaliger Kauf</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
Einmalige Anschaffungskosten für die Softwarelizenz. Keine monatlichen Mietgebühren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedBillingInterval === 'one_time' && (
|
||||||
|
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Option 2: Abo */}
|
||||||
|
<div
|
||||||
|
onClick={() => handleBillingIntervalChange('monthly')}
|
||||||
|
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
|
||||||
|
selectedBillingInterval === 'monthly'
|
||||||
|
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
|
||||||
|
: 'border-white/5 bg-white/5 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'monthly' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
|
||||||
|
<Calendar className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-white">Monatliches Abo (Miete)</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
Laufende monatliche Gebühren. Inklusive aller Updates und flexibler Laufzeit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedBillingInterval === 'monthly' && (
|
||||||
|
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedBillingInterval === 'one_time' && customerMode === 'select' && selectedEndCustomerId && (
|
||||||
|
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 mt-6 max-w-md animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<Label htmlFor="last-license-date" className="text-white font-medium flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-primary" />
|
||||||
|
Datum der letzten CAS-Lizenz (falls vorhanden)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="last-license-date"
|
||||||
|
type="date"
|
||||||
|
value={lastLicenseDate}
|
||||||
|
onChange={e => setLastLicenseDate(e.target.value)}
|
||||||
|
className="bg-[#0b1329] border-white/10 text-white focus:border-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Falls dieser Kunde bereits Lizenzen besitzt, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
||||||
|
<Button variant="ghost" className="text-white" onClick={prevStep}>
|
||||||
|
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
|
||||||
|
</Button>
|
||||||
|
<Button onClick={nextStep}>
|
||||||
|
Weiter zur Software <ChevronRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
shop/components/wizard/step-customer.tsx
Normal file
224
shop/components/wizard/step-customer.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { User, Building2, UserPlus, Search, Check, Loader2, ChevronRight } from 'lucide-react'
|
||||||
|
import { EndCustomer, Profile } from '@/lib/types'
|
||||||
|
|
||||||
|
interface StepCustomerProps {
|
||||||
|
isAdmin: boolean
|
||||||
|
companies: any[]
|
||||||
|
selectedCompanyId: string | null
|
||||||
|
setSelectedCompanyId: (id: string | null) => void
|
||||||
|
customerMode: 'select' | 'create'
|
||||||
|
setCustomerMode: (mode: 'select' | 'create') => void
|
||||||
|
endCustomers: EndCustomer[]
|
||||||
|
searchTerm: string
|
||||||
|
setSearchTerm: (term: string) => void
|
||||||
|
filteredEndCustomers: EndCustomer[]
|
||||||
|
selectedEndCustomerId: string | null
|
||||||
|
setSelectedEndCustomerId: (id: string | null) => void
|
||||||
|
newCustomerForm: any
|
||||||
|
setNewCustomerForm: React.Dispatch<React.SetStateAction<any>>
|
||||||
|
handleCreateCustomer: () => Promise<void>
|
||||||
|
isCreatingCustomer: boolean
|
||||||
|
nextStep: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepCustomer({
|
||||||
|
isAdmin,
|
||||||
|
companies,
|
||||||
|
selectedCompanyId,
|
||||||
|
setSelectedCompanyId,
|
||||||
|
customerMode,
|
||||||
|
setCustomerMode,
|
||||||
|
endCustomers,
|
||||||
|
searchTerm,
|
||||||
|
setSearchTerm,
|
||||||
|
filteredEndCustomers,
|
||||||
|
selectedEndCustomerId,
|
||||||
|
setSelectedEndCustomerId,
|
||||||
|
newCustomerForm,
|
||||||
|
setNewCustomerForm,
|
||||||
|
handleCreateCustomer,
|
||||||
|
isCreatingCustomer,
|
||||||
|
nextStep,
|
||||||
|
}: StepCustomerProps) {
|
||||||
|
return (
|
||||||
|
<Card className="glass-dark border-white/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||||
|
<User className="w-6 h-6 text-primary" />
|
||||||
|
Für wen bestellen Sie?
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-300">
|
||||||
|
Wählen Sie einen Endkunden aus oder legen Sie einen neuen an.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Admin Company Selector */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3">
|
||||||
|
<Label htmlFor="order-company" className="text-white font-medium flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-primary" />
|
||||||
|
Zugeordnetes Unternehmen (Admin-Option)
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="order-company"
|
||||||
|
value={selectedCompanyId || ''}
|
||||||
|
onChange={(e) => setSelectedCompanyId(e.target.value || null)}
|
||||||
|
className="flex h-9 w-full rounded-md border border-white/10 bg-slate-900 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-white"
|
||||||
|
>
|
||||||
|
<option value="">Keine Firma zugewiesen</option>
|
||||||
|
{companies.map((c: any) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modus-Toggle */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={customerMode === 'select' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCustomerMode('select')}
|
||||||
|
className={`gap-2 ${customerMode === 'select' ? '' : 'text-white'}`}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4" /> Bestandskunde wählen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={customerMode === 'create' ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCustomerMode('create')}
|
||||||
|
className={`gap-2 ${customerMode === 'create' ? '' : 'text-white'}`}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modus A: Bestandskunde wählen */}
|
||||||
|
{customerMode === 'select' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{endCustomers.filter(c => !c.is_anonymized).length === 0 ? (
|
||||||
|
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center space-y-3">
|
||||||
|
<Building2 className="w-10 h-10 text-slate-600 mx-auto" />
|
||||||
|
<p className="text-slate-400">Noch keine Endkunden angelegt.</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setCustomerMode('create')} className="border-white/10 text-white">
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Kunden suchen nach Name, Ort, PLZ..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filteredEndCustomers.length === 0 ? (
|
||||||
|
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
|
||||||
|
<p className="text-slate-400">Keine passenden Kunden gefunden.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
|
||||||
|
{filteredEndCustomers.map(customer => (
|
||||||
|
<label
|
||||||
|
key={customer.id}
|
||||||
|
className={`flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${selectedEndCustomerId === customer.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="end_customer"
|
||||||
|
value={customer.id}
|
||||||
|
checked={selectedEndCustomerId === customer.id}
|
||||||
|
onChange={() => setSelectedEndCustomerId(customer.id)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-semibold text-white">{customer.company_name}</p>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
|
||||||
|
{customer.first_name || customer.last_name ? ' · ' : ''}
|
||||||
|
{[customer.street, customer.zip, customer.city].filter(Boolean).join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedEndCustomerId === customer.id && (
|
||||||
|
<Check className="w-5 h-5 text-primary mt-0.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modus B: Neuen Kunden anlegen */}
|
||||||
|
{customerMode === 'create' && (
|
||||||
|
<div className="grid md:grid-cols-2 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
|
||||||
|
{([
|
||||||
|
{ label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' },
|
||||||
|
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
|
||||||
|
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
|
||||||
|
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
|
||||||
|
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' },
|
||||||
|
{ label: 'PLZ', key: 'zip', span: false, placeholder: '' },
|
||||||
|
{ label: 'Ort', key: 'city', span: false, placeholder: '' },
|
||||||
|
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
|
||||||
|
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
|
||||||
|
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
|
||||||
|
{ label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' },
|
||||||
|
] as const).map(({ label, key, span, placeholder }) => (
|
||||||
|
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
|
||||||
|
<Label className="text-slate-300 text-sm">{label}</Label>
|
||||||
|
<Input
|
||||||
|
value={newCustomerForm[key] || ''}
|
||||||
|
onChange={e => setNewCustomerForm((prev: any) => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="md:col-span-2 flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateCustomer}
|
||||||
|
disabled={isCreatingCustomer || !newCustomerForm.company_name?.trim()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isCreatingCustomer ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
|
||||||
|
Kunden speichern & auswählen
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="text-white" onClick={() => setCustomerMode('select')}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
|
||||||
|
<Button
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={
|
||||||
|
customerMode === 'create' ||
|
||||||
|
(customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Weiter zum Abrechnungsmodell <ChevronRight className="ml-2 w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
378
shop/components/wizard/step-software.tsx
Normal file
378
shop/components/wizard/step-software.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
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 { ShoppingCart, Check, AlertCircle } from 'lucide-react'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { Category, Product, CategorySelection } from '@/lib/types'
|
||||||
|
import { SummarySidebar } from './summary-sidebar'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Sidebar props
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
oneTimeTotal,
|
||||||
|
monthlyTotal,
|
||||||
|
oneTimeNet,
|
||||||
|
oneTimeTax,
|
||||||
|
oneTimeGross,
|
||||||
|
monthlyNet,
|
||||||
|
monthlyTax,
|
||||||
|
monthlyGross,
|
||||||
|
updatePriceModifier,
|
||||||
|
allCategoriesFilled,
|
||||||
|
productValidationErrors,
|
||||||
|
basketItems,
|
||||||
|
editingIdx,
|
||||||
|
editBasketItem,
|
||||||
|
deleteBasketItem,
|
||||||
|
deviceName,
|
||||||
|
setDeviceName,
|
||||||
|
addToBasket,
|
||||||
|
isNextStepDisabled,
|
||||||
|
hasActiveSelection,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
}: StepSoftwareProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{/* Left: Category sections */}
|
||||||
|
<div className="md:col-span-2 space-y-8">
|
||||||
|
<Card className="glass-dark border-white/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||||
|
<ShoppingCart className="w-6 h-6 text-primary" />
|
||||||
|
Software wählen
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-300">
|
||||||
|
Wählen Sie pro Kategorie mindestens einen Artikel aus.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
{visibleCategories.map((cat, idx) => {
|
||||||
|
const catProducts = products.filter(p => {
|
||||||
|
if (p.category_id !== cat.id) return false
|
||||||
|
return selectedBillingInterval === 'one_time'
|
||||||
|
? p.show_in_kauf !== false
|
||||||
|
: p.show_in_abo !== false
|
||||||
|
})
|
||||||
|
const sel = selections[cat.id]
|
||||||
|
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={cat.id}>
|
||||||
|
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
|
||||||
|
|
||||||
|
{/* Category header */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||||
|
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-lg">{cat.name}</h3>
|
||||||
|
{cat.description && (
|
||||||
|
<p className="text-slate-400 text-xs">{cat.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sel?.productIds && sel.productIds.length > 0 ? (
|
||||||
|
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
|
||||||
|
<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">
|
||||||
|
<Check className="w-3 h-3 mr-1" /> Ausgewählt
|
||||||
|
</Badge>
|
||||||
|
) : cat.is_required ? (
|
||||||
|
<Badge variant="destructive" className="ml-auto opacity-80">
|
||||||
|
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="ml-auto border-white/20 text-slate-400">
|
||||||
|
Optional
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catProducts.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm italic">
|
||||||
|
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
|
||||||
|
</p>
|
||||||
|
) : cat.allow_multiselect ? (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{catProducts.map(product => {
|
||||||
|
const isChecked = sel?.productIds?.includes(product.id) ?? false
|
||||||
|
const disabled = isProductDisabled(product, cat.id)
|
||||||
|
return (
|
||||||
|
<div key={product.id} className="relative">
|
||||||
|
<Label
|
||||||
|
onClick={() => !disabled && selectProduct(cat.id, product.id)}
|
||||||
|
className={`flex flex-col items-start p-4 rounded-xl border-2 transition-all ${disabled
|
||||||
|
? 'border-white/5 bg-white/5 opacity-50 cursor-not-allowed'
|
||||||
|
: isChecked
|
||||||
|
? 'border-primary bg-primary/5 cursor-pointer'
|
||||||
|
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between w-full items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={isChecked}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={() => { }}
|
||||||
|
className="border-white/20 data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
<span className="font-bold text-base text-white">{product.name}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
|
||||||
|
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-primary font-semibold text-sm">
|
||||||
|
{new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(product.base_price)}{' '}
|
||||||
|
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{product.description && (
|
||||||
|
<span className="text-sm text-slate-300 mt-1 pl-7">{product.description}</span>
|
||||||
|
)}
|
||||||
|
{product.modules && product.modules.length > 0 && (
|
||||||
|
<span className="text-xs text-slate-500 mt-1 pl-7">
|
||||||
|
{product.modules.length} optionale Module verfügbar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RadioGroup
|
||||||
|
value={sel?.productId ?? ''}
|
||||||
|
onValueChange={id => selectProduct(cat.id, id)}
|
||||||
|
className="grid gap-3"
|
||||||
|
>
|
||||||
|
{catProducts.map(product => {
|
||||||
|
const disabled = isProductDisabled(product, cat.id)
|
||||||
|
return (
|
||||||
|
<div key={product.id} className="relative">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={product.id}
|
||||||
|
id={`${cat.id}-${product.id}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className="peer sr-only"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={disabled ? undefined : `${cat.id}-${product.id}`}
|
||||||
|
className={`flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 transition-all ${disabled
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between w-full items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold text-base text-white">{product.name}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
|
||||||
|
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-primary font-semibold text-sm">
|
||||||
|
{new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(product.base_price)}{' '}
|
||||||
|
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{product.description && (
|
||||||
|
<span className="text-sm text-slate-300 mt-1">{product.description}</span>
|
||||||
|
)}
|
||||||
|
{product.modules && product.modules.length > 0 && (
|
||||||
|
<span className="text-xs text-slate-500 mt-1">
|
||||||
|
{product.modules.length} optionale Module verfügbar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modules */}
|
||||||
|
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
|
||||||
|
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
|
||||||
|
{selectedProduct.modules.map(module => {
|
||||||
|
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
|
||||||
|
const checked = sel?.moduleIds.includes(module.id) ?? false
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={module.id}
|
||||||
|
className={`flex flex-col p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-${cat.id}-${module.id}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={() => toggleModule(cat.id, module.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor={`mod-${cat.id}-${module.id}`}
|
||||||
|
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span>{module.name}</span>
|
||||||
|
<span className="text-primary font-bold">
|
||||||
|
+{new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(module.price)}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
{module.description && (
|
||||||
|
<p className="text-xs text-slate-400">{module.description}</p>
|
||||||
|
)}
|
||||||
|
{disabled && (
|
||||||
|
<p className="text-[10px] text-destructive mt-1">
|
||||||
|
{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 && module.has_quantity && (
|
||||||
|
<div className="flex items-center gap-3 mt-3 pl-8 pt-2 border-t border-white/5">
|
||||||
|
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">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-20 h-8 bg-white/5 border-white/10 text-white text-xs text-center rounded-lg"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Gesamt: {new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(module.price * (moduleQuantities[module.id] || 1))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Summary sidebar */}
|
||||||
|
<SummarySidebar
|
||||||
|
visibleCategories={visibleCategories}
|
||||||
|
selections={selections}
|
||||||
|
products={products}
|
||||||
|
moduleQuantities={moduleQuantities}
|
||||||
|
billingLabel={billingLabel}
|
||||||
|
oneTimeTotal={oneTimeTotal}
|
||||||
|
monthlyTotal={monthlyTotal}
|
||||||
|
oneTimeNet={oneTimeNet}
|
||||||
|
oneTimeTax={oneTimeTax}
|
||||||
|
oneTimeGross={oneTimeGross}
|
||||||
|
monthlyNet={monthlyNet}
|
||||||
|
monthlyTax={monthlyTax}
|
||||||
|
monthlyGross={monthlyGross}
|
||||||
|
updatePriceModifier={updatePriceModifier}
|
||||||
|
allCategoriesFilled={allCategoriesFilled}
|
||||||
|
productValidationErrors={productValidationErrors}
|
||||||
|
basketItems={basketItems}
|
||||||
|
editingIdx={editingIdx}
|
||||||
|
editBasketItem={editBasketItem}
|
||||||
|
deleteBasketItem={deleteBasketItem}
|
||||||
|
deviceName={deviceName}
|
||||||
|
setDeviceName={setDeviceName}
|
||||||
|
addToBasket={addToBasket}
|
||||||
|
isNextStepDisabled={isNextStepDisabled}
|
||||||
|
hasActiveSelection={hasActiveSelection}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
shop/components/wizard/step-summary.tsx
Normal file
275
shop/components/wizard/step-summary.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ShieldCheck, Building2, Calendar, Loader2 } from 'lucide-react'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { Category, Product, EndCustomer, Profile } from '@/lib/types'
|
||||||
|
|
||||||
|
interface StepSummaryProps {
|
||||||
|
finalItemsToShow: any[]
|
||||||
|
visibleCategories: Category[]
|
||||||
|
products: Product[]
|
||||||
|
oneTimeTotal: number
|
||||||
|
monthlyTotal: number
|
||||||
|
oneTimeNet: number
|
||||||
|
oneTimeTax: number
|
||||||
|
oneTimeGross: number
|
||||||
|
monthlyNet: number
|
||||||
|
monthlyTax: number
|
||||||
|
monthlyGross: number
|
||||||
|
updatePriceModifier: { label?: string }
|
||||||
|
selectedEndCustomer: EndCustomer | null
|
||||||
|
customerData: Partial<Profile>
|
||||||
|
lastLicenseDate: string
|
||||||
|
handleSubmit: () => Promise<void>
|
||||||
|
isSubmitting: boolean
|
||||||
|
initialOrder: any
|
||||||
|
prevStep: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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 StepSummary({
|
||||||
|
finalItemsToShow,
|
||||||
|
visibleCategories,
|
||||||
|
products,
|
||||||
|
oneTimeTotal,
|
||||||
|
monthlyTotal,
|
||||||
|
oneTimeNet,
|
||||||
|
oneTimeTax,
|
||||||
|
oneTimeGross,
|
||||||
|
monthlyNet,
|
||||||
|
monthlyTax,
|
||||||
|
monthlyGross,
|
||||||
|
updatePriceModifier,
|
||||||
|
selectedEndCustomer,
|
||||||
|
customerData,
|
||||||
|
lastLicenseDate,
|
||||||
|
handleSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
initialOrder,
|
||||||
|
prevStep,
|
||||||
|
}: StepSummaryProps) {
|
||||||
|
return (
|
||||||
|
<Card className="glass-dark border-primary/30 max-w-2xl mx-auto shadow-primary/10 shadow-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
|
||||||
|
<ShieldCheck className="w-10 h-10 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl text-white">Anfrage prüfen</CardTitle>
|
||||||
|
<CardDescription className="text-slate-300">
|
||||||
|
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 text-left">
|
||||||
|
<div className="p-4 rounded-lg bg-white/5 space-y-6">
|
||||||
|
{finalItemsToShow.map((item, itemIdx) => (
|
||||||
|
<div key={itemIdx} className="space-y-3 border-b border-white/10 pb-4 last:border-0 last:pb-0">
|
||||||
|
<div className="flex justify-between items-center bg-white/5 p-2 rounded">
|
||||||
|
<span className="text-white font-bold text-sm">Kasse: {item.deviceName}</span>
|
||||||
|
<span className="text-xs text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleCategories.map(cat => {
|
||||||
|
const sel = item.selections[cat.id]
|
||||||
|
const selectedProds: Product[] = []
|
||||||
|
if (cat.allow_multiselect && sel?.productIds) {
|
||||||
|
sel.productIds.forEach((pId: string) => {
|
||||||
|
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 null
|
||||||
|
|
||||||
|
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 pl-2">
|
||||||
|
<div className="text-slate-400 font-semibold text-xs flex items-center gap-1">
|
||||||
|
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5 text-primary" />
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
{sortedProds.map((prod, idx) => {
|
||||||
|
const isFree = idx < freeLimit
|
||||||
|
const actualPrice = isFree ? 0 : prod.base_price
|
||||||
|
const catTotal =
|
||||||
|
Number(actualPrice) +
|
||||||
|
(sel?.moduleIds?.reduce((acc: number, mId: string) => {
|
||||||
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
|
const qty = item.moduleQuantities[mId] || 1
|
||||||
|
return acc + (Number(mod?.price ?? 0) * qty)
|
||||||
|
}, 0) || 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={prod.id} className="pl-3">
|
||||||
|
<div className="flex justify-between font-semibold text-sm text-white">
|
||||||
|
<span>
|
||||||
|
{prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sel?.moduleIds && sel.moduleIds.length > 0 && (
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Module: {sel.moduleIds
|
||||||
|
.map((id: string) => {
|
||||||
|
const mod = prod.modules?.find(m => m.id === id)
|
||||||
|
const qty = item.moduleQuantities[id] || 1
|
||||||
|
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Separator className="bg-white/10" />
|
||||||
|
<div className="text-sm text-slate-300 space-y-1">
|
||||||
|
{selectedEndCustomer ? (
|
||||||
|
<>
|
||||||
|
<p className="font-semibold text-white flex items-center gap-2">
|
||||||
|
<Building2 className="w-3.5 h-3.5 text-primary" />
|
||||||
|
{selectedEndCustomer.company_name}
|
||||||
|
</p>
|
||||||
|
<p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p>
|
||||||
|
<p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p>
|
||||||
|
{lastLicenseDate && (
|
||||||
|
<p className="text-xs text-slate-400 flex items-center gap-1 mt-1 font-mono">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-primary" />
|
||||||
|
Letzte Lizenz vom: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-semibold text-white">{customerData.company_name}</p>
|
||||||
|
<p>{customerData.first_name} {customerData.last_name}</p>
|
||||||
|
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||||
|
<div className="space-y-4 border-t border-white/10 pt-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmaliger Betrag:</p>
|
||||||
|
<div className="flex justify-between text-sm 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-sm 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-white">
|
||||||
|
<span>Brutto:</span>
|
||||||
|
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{updatePriceModifier.label && (
|
||||||
|
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
|
||||||
|
{updatePriceModifier.label}
|
||||||
|
</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">Monatlicher Betrag:</p>
|
||||||
|
<div className="flex justify-between text-sm 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-sm 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-white">
|
||||||
|
<span>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 border-t border-white/10 pt-4">
|
||||||
|
<div className="flex justify-between text-sm 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-sm text-slate-400">
|
||||||
|
<span>zzgl. 19% MwSt.:</span>
|
||||||
|
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||||
|
</div>
|
||||||
|
{updatePriceModifier.label && (
|
||||||
|
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
|
||||||
|
{updatePriceModifier.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
|
||||||
|
<span>Gesamtbetrag (brutto):</span>
|
||||||
|
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 border-t border-white/10 pt-4">
|
||||||
|
<div className="flex justify-between text-sm 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-sm 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-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
|
||||||
|
<span>Gesamtbetrag (brutto):</span>
|
||||||
|
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-slate-500 italic">
|
||||||
|
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
|
||||||
|
<a href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
initialOrder ? (
|
||||||
|
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird aktualisiert...</>
|
||||||
|
) : (
|
||||||
|
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird versendet...</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
initialOrder ? 'Anfrage aktualisieren' : 'Anfrage versenden'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
|
||||||
|
Noch etwas ändern
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
299
shop/components/wizard/summary-sidebar.tsx
Normal file
299
shop/components/wizard/summary-sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
shop/components/wizard/toast-notification.tsx
Normal file
40
shop/components/wizard/toast-notification.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
toast: { message: string; type: 'error' | 'success' } | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastNotification({ toast, onClose }: ToastProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
|
className={`fixed bottom-5 right-5 z-50 flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg backdrop-blur-md transition-all duration-300 max-w-md ${
|
||||||
|
toast.type === 'error'
|
||||||
|
? 'bg-red-500/20 border-red-500/40 text-red-200'
|
||||||
|
: 'bg-green-500/20 border-green-500/40 text-green-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertCircle className={`w-5 h-5 shrink-0 ${toast.type === 'error' ? 'text-red-400' : 'text-green-400'}`} />
|
||||||
|
<p className="text-sm font-medium">{toast.message}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="w-5 h-5 ml-auto text-slate-400 hover:text-white"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
shop/package-lock.json
generated
16
shop/package-lock.json
generated
@@ -3881,6 +3881,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz",
|
||||||
"integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==",
|
"integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.105.1",
|
"@supabase/auth-js": "2.105.1",
|
||||||
"@supabase/functions-js": "2.105.1",
|
"@supabase/functions-js": "2.105.1",
|
||||||
@@ -3979,6 +3980,7 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3989,6 +3991,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -4047,6 +4050,7 @@
|
|||||||
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.1",
|
"@typescript-eslint/scope-manager": "8.59.1",
|
||||||
"@typescript-eslint/types": "8.59.1",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
@@ -4668,6 +4672,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5180,6 +5185,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -6004,6 +6010,7 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -6177,6 +6184,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -7549,6 +7557,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -8444,6 +8453,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.14.0",
|
"pg-connection-string": "^2.14.0",
|
||||||
"pg-pool": "^3.14.0",
|
"pg-pool": "^3.14.0",
|
||||||
@@ -8624,6 +8634,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -9069,6 +9080,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9078,6 +9090,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9090,6 +9103,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
|
||||||
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
|
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -10185,6 +10199,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10371,6 +10386,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
Reference in New Issue
Block a user