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'), base_price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'),
category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'), category_id: z.string().min(1, 'Bitte wählen Sie eine Kategorie'),
is_active: z.boolean().default(true), is_active: z.boolean().default(true),
billing_interval: z.enum(['one_time', 'monthly', 'yearly']).default('monthly'),
modules: z.array(moduleSchema).default([]), modules: z.array(moduleSchema).default([]),
}) })
@@ -69,6 +70,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
base_price: product?.base_price || 0, base_price: product?.base_price || 0,
category_id: product?.category_id || '', category_id: product?.category_id || '',
is_active: product?.is_active ?? true, is_active: product?.is_active ?? true,
billing_interval: product?.billing_interval || 'monthly',
modules: product?.modules?.map(m => ({ modules: product?.modules?.map(m => ({
name: m.name, name: m.name,
description: m.description || '', description: m.description || '',
@@ -121,7 +123,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
<DialogTitle className="text-2xl font-bold"> <DialogTitle className="text-2xl font-bold">
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'} {product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-slate-300">
Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu. Definieren Sie das Basisprodukt und fügen Sie optionale oder erforderliche Module hinzu.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -136,9 +138,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Produktname</FormLabel> <FormLabel className="text-white">Produktname</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -149,9 +151,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="base_price" name="base_price"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Basispreis ()</FormLabel> <FormLabel className="text-white">Basispreis ()</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -164,16 +166,16 @@ export function CreateProductDialog({ children, categories, product }: { childre
name="category_id" name="category_id"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Kategorie</FormLabel> <FormLabel className="text-white">Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger className="bg-white/5 border-white/10"> <SelectTrigger className="bg-white/5 border-white/10 text-white">
<SelectValue placeholder="Kategorie wählen" /> <SelectValue placeholder="Kategorie wählen" className="text-white" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent className="glass-dark border-white/10 text-white"> <SelectContent className="glass-dark border-white/10 text-white">
{categories.map(cat => ( {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> </SelectContent>
</Select> </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 <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Beschreibung</FormLabel> <FormLabel className="text-white">Beschreibung</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -233,9 +267,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.name`} name={`modules.${index}.name`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Modulname</FormLabel> <FormLabel className="text-white">Modulname</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -246,9 +280,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.price`} name={`modules.${index}.price`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Aufpreis ()</FormLabel> <FormLabel className="text-white">Aufpreis ()</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -262,9 +296,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.requirements`} name={`modules.${index}.requirements`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Benötigt (IDs, kommagetrennt)</FormLabel> <FormLabel className="text-white">Benötigt (IDs, kommagetrennt)</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -275,9 +309,9 @@ export function CreateProductDialog({ children, categories, product }: { childre
name={`modules.${index}.exclusions`} name={`modules.${index}.exclusions`}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Schließt aus (IDs, kommagetrennt)</FormLabel> <FormLabel className="text-white">Schließt aus (IDs, kommagetrennt)</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -298,7 +332,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
/> />
</FormControl> </FormControl>
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<FormLabel>Erforderlich</FormLabel> <FormLabel className="text-white">Erforderlich</FormLabel>
</div> </div>
</FormItem> </FormItem>
)} )}
@@ -308,7 +342,7 @@ export function CreateProductDialog({ children, categories, product }: { childre
))} ))}
{fields.length === 0 && ( {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. Noch keine Module hinzugefügt.
</div> </div>
)} )}

View File

@@ -32,7 +32,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
<Card className="glass-dark border-white/10 shadow-2xl overflow-hidden"> <Card className="glass-dark border-white/10 shadow-2xl overflow-hidden">
<CardHeader> <CardHeader>
<CardTitle>Produktübersicht</CardTitle> <CardTitle>Produktübersicht</CardTitle>
<CardDescription> <CardDescription className="text-slate-400">
Alle konfigurierten Software-Lösungen und Erweiterungen. Alle konfigurierten Software-Lösungen und Erweiterungen.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -40,17 +40,17 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-white/10 hover:bg-white/5"> <TableRow className="border-white/10 hover:bg-white/5">
<TableHead className="text-muted-foreground">Name</TableHead> <TableHead className="text-white font-bold">Name</TableHead>
<TableHead className="text-muted-foreground">Basispreis</TableHead> <TableHead className="text-white font-bold">Basispreis</TableHead>
<TableHead className="text-muted-foreground">Module</TableHead> <TableHead className="text-white font-bold">Module</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead> <TableHead className="text-white font-bold">Status</TableHead>
<TableHead className="text-right text-muted-foreground">Aktionen</TableHead> <TableHead className="text-right text-white font-bold">Aktionen</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{products.map((product) => ( {products.map((product) => (
<TableRow key={product.id} className="border-white/5 hover:bg-white/5 transition-colors"> <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 flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{product.name} {product.name}
@@ -59,15 +59,24 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
{product.category.name} {product.category.name}
</Badge> </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> </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> </div>
</TableCell> </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> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Layers className="w-3 h-3 text-primary" /> <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> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -96,7 +105,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
))} ))}
{products.length === 0 && ( {products.length === 0 && (
<TableRow> <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! Keine Produkte gefunden. Erstellen Sie Ihr erstes Produkt!
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -21,6 +21,19 @@ type CategorySelection = {
moduleIds: string[] // selected module IDs for this product 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 ────────────────────────────────────────────────────────────── // ─── Icon helper ──────────────────────────────────────────────────────────────
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) { function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const Icon = icon === 'Cloud' ? Cloud : icon === 'Utensils' ? Utensils : icon === 'HardDrive' ? HardDrive : Package 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" 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"> <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"> <span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', { {new Intl.NumberFormat('de-DE', {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
}).format(product.base_price)} }).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span> </span>
</div> </div>
{product.description && ( {product.description && (
@@ -374,6 +393,7 @@ export function OrderWizard({
<span className="text-white">{prod.name}</span> <span className="text-white">{prod.name}</span>
<span className="text-white"> <span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(prod.base_price)} {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> </span>
</div> </div>
{sel.moduleIds.map(mId => { {sel.moduleIds.map(mId => {
@@ -511,6 +531,7 @@ export function OrderWizard({
</span> </span>
<span> <span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)} {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> </span>
</div> </div>
{sel.moduleIds.length > 0 && ( {sel.moduleIds.length > 0 && (

View File

@@ -4,6 +4,7 @@ export type Product = {
description?: string | null | undefined; description?: string | null | undefined;
base_price: number; base_price: number;
is_active: boolean; is_active: boolean;
billing_interval: 'one_time' | 'monthly' | 'yearly';
created_at: string; created_at: string;
updated_at: string; updated_at: string;
category_id?: string | null; category_id?: string | null;

View File

@@ -0,0 +1,4 @@
-- Add billing_interval to products to support subscription and one-time products
-- Values: 'one_time', 'monthly', 'yearly'
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS billing_interval TEXT DEFAULT 'monthly' NOT NULL
CHECK (billing_interval IN ('one_time', 'monthly', 'yearly'));