feat: add billing_interval to products supporting one-time, monthly and yearly subscription pricing
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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 && (
|
||||
|
||||
@@ -4,6 +4,7 @@ export type Product = {
|
||||
description?: string | null | undefined;
|
||||
base_price: number;
|
||||
is_active: boolean;
|
||||
billing_interval: 'one_time' | 'monthly' | 'yearly';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
category_id?: string | null;
|
||||
|
||||
@@ -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'));
|
||||
Reference in New Issue
Block a user