feat(subscription): allow admin to delete a granted subscription

Granting stacks a new row and extends the existing expiry, so a mistaken
grant had no way back: the report tab was read-only and no endpoint deleted
a subscription (only DELETE .../subscription/period/{uuid}, a different
resource).

Adds DELETE /api/v1/admin/subscription/{uuid}. Payment-backed subscriptions
are refused with 409 — deleting one would leave the sales report with a
payment that owns nothing; refunds are the correct path there.

The report tab gets a per-row delete with a confirm dialog, plus a button
that jumps to the grant tab so add and remove live in the same place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-19 21:59:12 +03:30
co-authored by Claude Opus 5
parent 5eb3bd586f
commit a1584c3296
6 changed files with 239 additions and 3 deletions
+43 -2
View File
@@ -626,8 +626,10 @@ function GrantTab() {
// ── Report tab ────────────────────────────────────────────────────────────
function ReportTab() {
function ReportTab({ onAdd }: { onAdd: () => void }) {
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [deleteRow, setDeleteRow] = useState<ReportRow | null>(null);
const limit = 20;
const { data, isLoading } = useQuery({
@@ -635,11 +637,27 @@ function ReportTab() {
queryFn: () => api.get<PaginatedResponse<ReportRow>>(`/api/v1/admin/subscription/report?page=${page}&limit=${limit}`),
});
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/admin/subscription/${uuid}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-subscription-report'] });
qc.invalidateQueries({ queryKey: ['admin-grant-active'] });
setDeleteRow(null);
toast.success('اشتراک حذف شد');
},
onError: (e: any) => { setDeleteRow(null); toast.error(e.message); },
});
const rows: ReportRow[] = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
return (
<div className="card">
<div className="card-pad" style={{ display: 'flex', justifyContent: 'flex-end', borderBottom: '1px solid var(--border)' }}>
<button type="button" className="btn primary" onClick={onAdd}>
<PlusIcon style={{ width: 15 }} /> اعطای اشتراک جدید
</button>
</div>
{isLoading ? (
<div className="card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : rows.length === 0 ? (
@@ -655,6 +673,7 @@ function ReportTab() {
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>انقضا</th>
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>تاریخ ثبت</th>
<th style={{ textAlign: 'left', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>عملیات</th>
</tr>
</thead>
<tbody>
@@ -689,6 +708,17 @@ function ReportTab() {
{row.expiresAt ? formatDate(row.expiresAt) : <span className="muted">بینهایت</span>}
</td>
<td style={{ padding: '10px 16px', color: 'var(--text-3)' }}>{formatDate(row.createdAt)}</td>
<td style={{ padding: '10px 16px', textAlign: 'left' }}>
<button
type="button"
className="btn sm"
title="حذف اشتراک"
aria-label={`حذف اشتراک ${row.entityName ?? row.entityId}`}
onClick={() => setDeleteRow(row)}
>
<TrashIcon style={{ width: 13 }} />
</button>
</td>
</tr>
))}
</tbody>
@@ -698,6 +728,17 @@ function ReportTab() {
</div>
</>
)}
<ConfirmDialog
open={!!deleteRow}
title="حذف اشتراک"
message={`آیا مطمئن هستید که می‌خواهید اشتراک «${deleteRow?.entityName ?? ''}» را حذف کنید؟ دسترسی این محیط به قابلیت‌های پلن قطع می‌شود.`}
confirmLabel="حذف"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteRow && deleteMut.mutate(deleteRow.uuid)}
onCancel={() => setDeleteRow(null)}
/>
</div>
);
}
@@ -722,7 +763,7 @@ export default function AdminSubscriptionPage() {
{tab === 'plans' && <PlansTab />}
{tab === 'grant' && <GrantTab />}
{tab === 'report' && <ReportTab />}
{tab === 'report' && <ReportTab onAdd={() => setTab('grant')} />}
</>
);
}