feat: add billing_interval to products supporting one-time, monthly and yearly subscription pricing

This commit is contained in:
DanielS
2026-05-01 02:33:51 +02:00
parent 1ec164cd7b
commit cc10352d72
5 changed files with 103 additions and 34 deletions

View File

@@ -53,6 +53,7 @@ const productSchema = z.object({
base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
is_active: z.boolean().default(true),
billing_interval: z.enum(['one_time', 'monthly', 'yearly']).default('monthly'),
modules: z.array(moduleSchema).default([]),
})
@@ -69,6 +70,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
base_price: product?.base_price || 0,
category_id: product?.category_id || '',
is_active: product?.is_active ?? true,
billing_interval: product?.billing_interval || 'monthly',
modules: product?.modules?.map(m => ({
name: m.name,
description: m.description || '',
@@ -121,7 +123,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
<DialogTitle className="text-2xl font-bold">
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
<DialogDescription className="text-slate-300">
Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu.
</DialogDescription>
</DialogHeader>
@@ -136,9 +138,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Produktname</FormLabel>
<FormLabel className="text-white">Produktname</FormLabel>
<FormControl>
<Input placeholder="z.B. Basic ERP" className="bg-white/5 border-white/10" {...field} />
<Input placeholder="z.B. Basic ERP" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -149,9 +151,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="base_price"
render={({ field }) => (
<FormItem>
<FormLabel>Basispreis ()</FormLabel>
<FormLabel className="text-white">Basispreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/5 border-white/10" {...field} />
<Input type="number" step="0.01" className="bg-white/5 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -164,16 +166,16 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel>Kategorie</FormLabel>
<FormLabel className="text-white">Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-white/5 border-white/10">
<SelectValue placeholder="Kategorie wählen" />
<SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Kategorie wählen" className="text-white" />
</SelectTrigger>
</FormControl>
<SelectContent className="glass-dark border-white/10 text-white">
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
<SelectItem key={cat.id} value={cat.id} className="text-white">{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
@@ -182,14 +184,46 @@ export function CreateProductDialog({ children, categories, product }: { childre
)}
/>
<FormField
control={form.control}
name="billing_interval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-white">Abrechnungsmodell</FormLabel>
<div className="grid grid-cols-3 gap-2 pt-1">
{[
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
{ value: 'yearly', label: 'Jährlich', icon: '📅' },
].map(opt => (
<button
key={opt.value}
type="button"
onClick={() => field.onChange(opt.value)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
field.value === opt.value
? 'border-primary bg-primary/10 text-white'
: 'border-white/10 bg-white/5 text-slate-400 hover:bg-white/10'
}`}
>
<span className="text-lg">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung</FormLabel>
<FormLabel className="text-white">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-white/5 border-white/10" {...field} />
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-white/5 border-white/10 text-white placeholder:text-slate-400" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -233,9 +267,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel>Modulname</FormLabel>
<FormLabel className="text-white">Modulname</FormLabel>
<FormControl>
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10" {...field} />
<Input placeholder="z.B. Cloud Storage" className="bg-white/10 border-white/10 text-white placeholder:text-slate-400" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -246,9 +280,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel>Aufpreis ()</FormLabel>
<FormLabel className="text-white">Aufpreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white/10 border-white/10" {...field} />
<Input type="number" step="0.01" className="bg-white/10 border-white/10 text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -262,9 +296,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.requirements`}
render={({ field }) => (
<FormItem>
<FormLabel>Benötigt (IDs, kommagetrennt)</FormLabel>
<FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs" {...field} />
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -275,9 +309,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.exclusions`}
render={({ field }) => (
<FormItem>
<FormLabel>Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormControl>
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs" {...field} />
<Input placeholder="UUIDs..." className="bg-white/10 border-white/10 text-xs text-white" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -298,7 +332,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Erforderlich</FormLabel>
<FormLabel className="text-white">Erforderlich</FormLabel>
</div>
</FormItem>
)}
@@ -308,7 +342,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
))}
{fields.length === 0 && (
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-muted-foreground text-sm">
<div className="text-center py-6 border-2 border-dashed border-white/5 rounded-lg text-slate-400 text-sm">
Noch keine Module hinzugefügt.
</div>
)}

View File

@@ -32,7 +32,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<CardHeader>
<CardTitle>Produktübersicht</CardTitle>
<CardDescription>
<CardDescription className="text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription>
</CardHeader>
@@ -40,17 +40,17 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
<Table>
<TableHeader>
<TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="text-muted-foreground">Basispreis</TableHead>
<TableHead className="text-muted-foreground">Module</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="text-right text-muted-foreground">Aktionen</TableHead>
<TableHead className="text-white font-bold">Name</TableHead>
<TableHead className="text-white font-bold">Basispreis</TableHead>
<TableHead className="text-white font-bold">Module</TableHead>
<TableHead className="text-white font-bold">Status</TableHead>
<TableHead className="text-right text-white font-bold">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors">
<TableCell className="font-medium">
<TableCell className="font-medium text-white">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{product.name}
@@ -59,15 +59,24 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
{product.category.name}
</Badge>
)}
{product.billing_interval === 'monthly' && (
<Badge className="text-[10px] bg-blue-500/20 text-blue-400 border border-blue-500/30 py-0 h-4">Abo/Monat</Badge>
)}
{product.billing_interval === 'yearly' && (
<Badge className="text-[10px] bg-purple-500/20 text-purple-400 border border-purple-500/30 py-0 h-4">Abo/Jahr</Badge>
)}
{product.billing_interval === 'one_time' && (
<Badge className="text-[10px] bg-slate-500/20 text-slate-300 border border-slate-500/30 py-0 h-4">Einmalig</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-normal">{product.description}</p>
<p className="text-xs text-slate-300 font-normal">{product.description}</p>
</div>
</TableCell>
<TableCell>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell className="text-white font-semibold">{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Layers className="w-3 h-3 text-primary" />
<span>{product.modules?.length || 0} Module</span>
<span className="text-slate-200">{product.modules?.length || 0} Module</span>
</div>
</TableCell>
<TableCell>
@@ -96,7 +105,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
))}
{products.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-10 text-muted-foreground">
<TableCell colSpan={5} className="text-center py-10 text-slate-400">
Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
</TableCell>
</TableRow>

View File

@@ -21,6 +21,19 @@ type CategorySelection = {
moduleIds: string[] // selected module IDs for this product
}
// ─── Billing interval helpers ─────────────────────────────────────────────────
function billingLabel(interval?: string) {
if (interval === 'one_time') return 'einmalig'
if (interval === 'yearly') return '/ Jahr'
return '/ Monat'
}
function billingBadgeClass(interval?: string) {
if (interval === 'one_time') return 'bg-slate-500/20 text-slate-300 border-slate-500/30'
if (interval === 'yearly') return 'bg-purple-500/20 text-purple-400 border-purple-500/30'
return 'bg-blue-500/20 text-blue-400 border-blue-500/30'
}
// ─── Icon helper ──────────────────────────────────────────────────────────────
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const Icon = icon === 'Cloud' ? Cloud : icon === 'Utensils' ? Utensils : icon === 'HardDrive' ? HardDrive : Package
@@ -267,12 +280,18 @@ export function OrderWizard({
className="flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer transition-all"
>
<div className="flex justify-between w-full items-center">
<span className="font-bold text-base text-white">{product.name}</span>
<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' : product.billing_interval === 'yearly' ? 'Abo/Jahr' : '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)}
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
@@ -374,6 +393,7 @@ export function OrderWizard({
<span className="text-white">{prod.name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(prod.base_price)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
@@ -511,6 +531,7 @@ export function OrderWizard({
</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
{' '}<span className="text-sm font-normal text-slate-400">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.length > 0 && (