e10f9c5235
- Wallet balance display in dashboard header - Lifecycle status badges (color-coded) in apps list - Plan expiry countdown column - Admin apps: suspended/pending-deletion summary cards - Admin billing: lifecycle settings management - Updated TypeScript types for lifecycle and billing
486 lines
20 KiB
TypeScript
486 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import api from '@/lib/api';
|
|
import { toast } from 'react-toastify';
|
|
import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types';
|
|
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock } from 'lucide-react';
|
|
import { useConfirm } from '@/components/confirm-modal';
|
|
|
|
const runtimeOptions = [
|
|
{ value: 'nodejs', label: 'Node.js' },
|
|
{ value: 'laravel', label: 'Laravel' },
|
|
{ value: 'wordpress', label: 'WordPress' },
|
|
] as const;
|
|
|
|
type AppRuntime = 'nodejs' | 'laravel' | 'wordpress';
|
|
|
|
const runtimeLabels: Record<AppRuntime, string> = {
|
|
nodejs: 'Node.js',
|
|
laravel: 'Laravel',
|
|
wordpress: 'WordPress',
|
|
};
|
|
|
|
const cycleLabels: Record<BillingCycle, string> = {
|
|
hourly: 'Hourly',
|
|
monthly: 'Monthly',
|
|
yearly: 'Yearly',
|
|
};
|
|
|
|
const resourceLabels: Record<PricingResourceType, string> = {
|
|
base_fee: 'Base Fee',
|
|
cpu_per_core: 'CPU (per core)',
|
|
memory_per_gb: 'Memory (per GB)',
|
|
storage_per_gb: 'Storage (per GB)',
|
|
database_addon: 'Database Addon',
|
|
};
|
|
|
|
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon'];
|
|
|
|
interface RuleForm {
|
|
resourceType: PricingResourceType;
|
|
unitPrice: string;
|
|
description: string;
|
|
}
|
|
|
|
const emptyRule = (): RuleForm => ({ resourceType: 'base_fee', unitPrice: '', description: '' });
|
|
|
|
export default function AdminBillingPage() {
|
|
const queryClient = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
|
|
const [formName, setFormName] = useState('');
|
|
const [formRuntime, setFormRuntime] = useState<AppRuntime>('nodejs');
|
|
const [formDesc, setFormDesc] = useState('');
|
|
const [formCycle, setFormCycle] = useState<BillingCycle>('monthly');
|
|
const [rules, setRules] = useState<RuleForm[]>([emptyRule()]);
|
|
|
|
const { data: plans = [], isLoading } = useQuery<ServicePlan[]>({
|
|
queryKey: ['billing-plans'],
|
|
queryFn: () => api.get('/billing/plans').then((r) => r.data),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: any) => editingId
|
|
? api.patch(`/billing/plans/${editingId}`, data)
|
|
: api.post('/billing/plans', data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
|
toast.success(editingId ? 'Plan updated' : 'Plan created');
|
|
resetForm();
|
|
},
|
|
onError: (err: any) => toast.error(err.response?.data?.message || 'Error'),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => api.delete(`/billing/plans/${id}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
|
toast.success('Plan deleted');
|
|
},
|
|
onError: () => toast.error('Failed to delete plan'),
|
|
});
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
|
api.patch(`/billing/plans/${id}`, { isActive }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['billing-plans'] });
|
|
},
|
|
});
|
|
|
|
const resetForm = () => {
|
|
setShowForm(false);
|
|
setEditingId(null);
|
|
setFormName('');
|
|
setFormRuntime('nodejs');
|
|
setFormDesc('');
|
|
setFormCycle('monthly');
|
|
setRules([emptyRule()]);
|
|
};
|
|
|
|
const startEdit = (plan: ServicePlan) => {
|
|
setEditingId(plan.id);
|
|
setFormName(plan.name);
|
|
setFormRuntime(plan.runtime);
|
|
setFormDesc(plan.description || '');
|
|
setFormCycle(plan.billingCycle);
|
|
setRules(
|
|
plan.pricingRules.map((r) => ({
|
|
resourceType: r.resourceType,
|
|
unitPrice: String(r.unitPrice),
|
|
description: r.description || '',
|
|
})),
|
|
);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
if (!formName.trim()) return toast.error('Plan name is required');
|
|
const validRules = rules.filter((r) => r.unitPrice && Number(r.unitPrice) > 0);
|
|
if (validRules.length === 0) return toast.error('Add at least one pricing rule');
|
|
|
|
createMutation.mutate({
|
|
name: formName,
|
|
runtime: formRuntime,
|
|
description: formDesc || undefined,
|
|
billingCycle: formCycle,
|
|
pricingRules: validRules.map((r) => ({
|
|
resourceType: r.resourceType,
|
|
unitPrice: Number(r.unitPrice),
|
|
description: r.description || undefined,
|
|
})),
|
|
});
|
|
};
|
|
|
|
const addRule = () => setRules([...rules, emptyRule()]);
|
|
const removeRule = (i: number) => setRules(rules.filter((_, idx) => idx !== i));
|
|
const updateRule = (i: number, field: keyof RuleForm, value: string) => {
|
|
const updated = [...rules];
|
|
updated[i] = { ...updated[i], [field]: value };
|
|
setRules(updated);
|
|
};
|
|
|
|
const formatPrice = (n: number) => Number(n).toLocaleString('en-US');
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="page-title flex items-center gap-2"><DollarSign className="w-6 h-6" /> Billing Plans</h1>
|
|
<p className="page-subtitle">Define service plans and pricing for each application type</p>
|
|
</div>
|
|
{!showForm && (
|
|
<button onClick={() => setShowForm(true)} className="btn-primary flex items-center gap-2">
|
|
<Plus className="w-4 h-4" /> New Plan
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create / Edit Form */}
|
|
{showForm && (
|
|
<div className="card space-y-4">
|
|
<h2 className="text-lg font-semibold">{editingId ? 'Edit Plan' : 'Create New Plan'}</h2>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Application Type</label>
|
|
<select className="input-field" value={formRuntime} onChange={(e) => setFormRuntime(e.target.value as AppRuntime)}>
|
|
{runtimeOptions.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Plan Name</label>
|
|
<input className="input-field" placeholder="e.g. Node.js Standard" value={formName} onChange={(e) => setFormName(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Billing Cycle</label>
|
|
<select className="input-field" value={formCycle} onChange={(e) => setFormCycle(e.target.value as BillingCycle)}>
|
|
<option value="hourly">Hourly</option>
|
|
<option value="monthly">Monthly</option>
|
|
<option value="yearly">Yearly</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
|
|
<input className="input-field" placeholder="Description of this plan" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} />
|
|
</div>
|
|
|
|
{/* Pricing Rules */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="text-sm font-semibold text-gray-700">Pricing Rules</label>
|
|
<button onClick={addRule} className="btn-secondary text-xs flex items-center gap-1">
|
|
<Plus className="w-3 h-3" /> Add Rule
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{rules.map((rule, i) => (
|
|
<div key={i} className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
|
<select
|
|
className="input-field flex-1 text-sm"
|
|
value={rule.resourceType}
|
|
onChange={(e) => updateRule(i, 'resourceType', e.target.value)}
|
|
>
|
|
{allResourceTypes.map((rt) => (
|
|
<option key={rt} value={rt}>{resourceLabels[rt]}</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
className="input-field w-36 text-sm"
|
|
type="number"
|
|
placeholder="Price (Toman)"
|
|
value={rule.unitPrice}
|
|
onChange={(e) => updateRule(i, 'unitPrice', e.target.value)}
|
|
/>
|
|
<input
|
|
className="input-field flex-1 text-sm"
|
|
placeholder="Note (optional)"
|
|
value={rule.description}
|
|
onChange={(e) => updateRule(i, 'description', e.target.value)}
|
|
/>
|
|
{rules.length > 1 && (
|
|
<button onClick={() => removeRule(i)} className="text-red-500 hover:text-red-700 p-1">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<button onClick={resetForm} className="btn-ghost">Cancel</button>
|
|
<button onClick={handleSubmit} disabled={createMutation.isPending} className="btn-primary disabled:opacity-50">
|
|
{createMutation.isPending ? 'Saving...' : editingId ? 'Update Plan' : 'Create Plan'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Plans List */}
|
|
{isLoading ? (
|
|
<div className="text-center py-12 text-gray-400">Loading...</div>
|
|
) : plans.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-400">No plans created yet</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{plans.map((plan) => (
|
|
<div key={plan.id} className="card">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setExpandedPlan(expandedPlan === plan.id ? null : plan.id)}
|
|
className="p-1 text-gray-400 hover:text-gray-600"
|
|
>
|
|
{expandedPlan === plan.id ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900">{plan.name}</h3>
|
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
<span className="badge badge-blue">{runtimeLabels[plan.runtime] || plan.runtime}</span>
|
|
<span className="badge badge-purple">{cycleLabels[plan.billingCycle]}</span>
|
|
{plan.description && <span>— {plan.description}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => toggleMutation.mutate({ id: plan.id, isActive: !plan.isActive })}
|
|
className={`p-1 transition-colors ${plan.isActive ? 'text-green-500' : 'text-gray-400'}`}
|
|
title={plan.isActive ? 'Deactivate' : 'Activate'}
|
|
>
|
|
{plan.isActive ? <ToggleRight className="w-5 h-5" /> : <ToggleLeft className="w-5 h-5" />}
|
|
</button>
|
|
<button onClick={() => startEdit(plan)} className="p-1 text-blue-500 hover:text-blue-700">
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
const ok = await confirm({ title: 'Delete Plan', message: `Are you sure you want to delete "${plan.name}"?`, confirmText: 'Delete', variant: 'danger' });
|
|
if (ok) deleteMutation.mutate(plan.id);
|
|
}}
|
|
className="p-1 text-red-500 hover:text-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded pricing rules */}
|
|
{expandedPlan === plan.id && (
|
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-gray-500 text-xs">
|
|
<th className="text-left pb-2">Resource</th>
|
|
<th className="text-left pb-2">Unit Price (Toman)</th>
|
|
<th className="text-left pb-2">Note</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{plan.pricingRules.map((rule) => (
|
|
<tr key={rule.id} className="border-t border-gray-50">
|
|
<td className="py-2 font-medium">{resourceLabels[rule.resourceType]}</td>
|
|
<td className="py-2 text-green-700 font-mono">{formatPrice(rule.unitPrice)}</td>
|
|
<td className="py-2 text-gray-500">{rule.description || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── Lifecycle Retention Settings ───────────────────── */}
|
|
<LifecycleSettingsSection />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Lifecycle Settings Sub-component ─────────────────────────────
|
|
|
|
function LifecycleSettingsSection() {
|
|
const queryClient = useQueryClient();
|
|
const [editing, setEditing] = useState(false);
|
|
const [hourlyHours, setHourlyHours] = useState('');
|
|
const [monthlyDays, setMonthlyDays] = useState('');
|
|
const [yearlyDays, setYearlyDays] = useState('');
|
|
|
|
const { data: settings, isLoading } = useQuery<LifecycleSettings>({
|
|
queryKey: ['lifecycle-settings'],
|
|
queryFn: () => api.get('/lifecycle/settings').then((r) => r.data),
|
|
});
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: (body: any) => api.patch('/lifecycle/settings', body),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
|
|
toast.success('Lifecycle settings updated');
|
|
setEditing(false);
|
|
},
|
|
onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to save'),
|
|
});
|
|
|
|
const startEditing = () => {
|
|
if (settings) {
|
|
setHourlyHours(String((settings.hourly.deleteAfterMs || 0) / 3600000));
|
|
setMonthlyDays(String((settings.monthly.deleteAfterMs || 0) / 86400000));
|
|
setYearlyDays(String((settings.yearly.deleteAfterMs || 0) / 86400000));
|
|
}
|
|
setEditing(true);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const body: any = {};
|
|
if (hourlyHours) body.hourlyDeleteAfterMs = Number(hourlyHours) * 3600000;
|
|
if (monthlyDays) body.monthlyDeleteAfterMs = Number(monthlyDays) * 86400000;
|
|
if (yearlyDays) body.yearlyDeleteAfterMs = Number(yearlyDays) * 86400000;
|
|
saveMutation.mutate(body);
|
|
};
|
|
|
|
return (
|
|
<div className="card mt-8">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="w-5 h-5 text-red-500" />
|
|
<h2 className="text-lg font-semibold text-gray-900">Data Retention & Deletion Policy</h2>
|
|
</div>
|
|
{!editing && (
|
|
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
|
|
<Edit2 className="w-3 h-3" /> Edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-500 mb-4">
|
|
Configure how long user data is retained after plan expiration before permanent deletion.
|
|
After a plan expires, the application is suspended (scaled to 0). If no payment is received within the grace period, the application and all its data are permanently deleted.
|
|
</p>
|
|
|
|
{isLoading ? (
|
|
<div className="text-center py-6 text-gray-400">Loading...</div>
|
|
) : editing ? (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div className="p-4 rounded-xl bg-blue-50 border border-blue-100">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Clock className="w-4 h-4 text-blue-600" />
|
|
<h3 className="font-semibold text-blue-900">Hourly Plans</h3>
|
|
</div>
|
|
<label className="text-xs text-blue-700 font-medium">Delete after (hours):</label>
|
|
<input
|
|
type="number"
|
|
className="input-field mt-1 text-sm"
|
|
value={hourlyHours}
|
|
onChange={(e) => setHourlyHours(e.target.value)}
|
|
min={1}
|
|
placeholder="24"
|
|
/>
|
|
</div>
|
|
<div className="p-4 rounded-xl bg-purple-50 border border-purple-100">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Clock className="w-4 h-4 text-purple-600" />
|
|
<h3 className="font-semibold text-purple-900">Monthly Plans</h3>
|
|
</div>
|
|
<label className="text-xs text-purple-700 font-medium">Delete after (days):</label>
|
|
<input
|
|
type="number"
|
|
className="input-field mt-1 text-sm"
|
|
value={monthlyDays}
|
|
onChange={(e) => setMonthlyDays(e.target.value)}
|
|
min={1}
|
|
placeholder="3"
|
|
/>
|
|
</div>
|
|
<div className="p-4 rounded-xl bg-green-50 border border-green-100">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Clock className="w-4 h-4 text-green-600" />
|
|
<h3 className="font-semibold text-green-900">Yearly Plans</h3>
|
|
</div>
|
|
<label className="text-xs text-green-700 font-medium">Delete after (days):</label>
|
|
<input
|
|
type="number"
|
|
className="input-field mt-1 text-sm"
|
|
value={yearlyDays}
|
|
onChange={(e) => setYearlyDays(e.target.value)}
|
|
min={1}
|
|
placeholder="7"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<button onClick={() => setEditing(false)} className="btn-ghost">Cancel</button>
|
|
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary disabled:opacity-50">
|
|
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div className="p-4 rounded-xl bg-blue-50/50 border border-blue-100">
|
|
<h3 className="font-semibold text-blue-900 flex items-center gap-1 text-sm">
|
|
<Clock className="w-4 h-4" /> Hourly Plans
|
|
</h3>
|
|
<p className="text-2xl font-bold text-blue-700 mt-2">
|
|
{settings?.hourly.deleteAfterHours ?? Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
|
|
<span className="text-sm font-normal ml-1">hours</span>
|
|
</p>
|
|
<p className="text-xs text-blue-500 mt-1">after suspension → delete</p>
|
|
</div>
|
|
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
|
|
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
|
|
<Clock className="w-4 h-4" /> Monthly Plans
|
|
</h3>
|
|
<p className="text-2xl font-bold text-purple-700 mt-2">
|
|
{settings?.monthly.deleteAfterDays ?? Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
|
|
<span className="text-sm font-normal ml-1">days</span>
|
|
</p>
|
|
<p className="text-xs text-purple-500 mt-1">after suspension → delete</p>
|
|
</div>
|
|
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
|
|
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
|
|
<Clock className="w-4 h-4" /> Yearly Plans
|
|
</h3>
|
|
<p className="text-2xl font-bold text-green-700 mt-2">
|
|
{settings?.yearly.deleteAfterDays ?? Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
|
|
<span className="text-sm font-normal ml-1">days</span>
|
|
</p>
|
|
<p className="text-xs text-green-500 mt-1">after suspension → delete</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|