feat(frontend): lifecycle status, wallet balance, admin billing UI
- 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
This commit is contained in:
@@ -4,8 +4,8 @@ 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 } from '@/types';
|
||||
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
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 = [
|
||||
@@ -322,6 +322,164 @@ export default function AdminBillingPage() {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user