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:
@@ -14,6 +14,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage';
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const del = api.delete as ReturnType<typeof vi.fn>;
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
@@ -213,7 +214,27 @@ describe('AdminSubscriptionPage — اعطای اشتراک', () => {
|
||||
});
|
||||
|
||||
describe('AdminSubscriptionPage — گزارش', () => {
|
||||
beforeEach(() => { get.mockReset(); post.mockReset(); });
|
||||
beforeEach(() => { get.mockReset(); post.mockReset(); del.mockReset(); });
|
||||
|
||||
/** یک ردیف گزارشِ اعطایی — تستهای حذف و ناوبری از همین استفاده میکنند. */
|
||||
function mockReportRow() {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS });
|
||||
if (url.includes('/admin/subscription/report')) {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
meta: { totalRecords: 1 },
|
||||
data: [{
|
||||
uuid: 's-1', entityType: 'clinic', entityId: 11, entityName: 'کلینیک نمونه',
|
||||
isTrial: false, isGranted: true, grantedBy: 'ادمین',
|
||||
startsAt: 1700000000, expiresAt: 1800000000, createdAt: 1700000000,
|
||||
plan_name: 'professional', plan_level: 2,
|
||||
}],
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
|
||||
});
|
||||
}
|
||||
|
||||
it('اشتراک اعطایی را «اعطایی» نشان میدهد، نه «پولی»', async () => {
|
||||
get.mockImplementation((url: string) => {
|
||||
@@ -241,4 +262,27 @@ describe('AdminSubscriptionPage — گزارش', () => {
|
||||
expect(screen.getByText('ادمین')).toBeInTheDocument();
|
||||
expect(screen.queryByText('پولی')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('حذف اشتراک پس از تأیید، DELETE میفرستد', async () => {
|
||||
mockReportRow();
|
||||
del.mockResolvedValue({ success: true, data: null });
|
||||
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
fireEvent.click(await screen.findByText('گزارش فروش'));
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('حذف اشتراک کلینیک نمونه'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'حذف' }));
|
||||
|
||||
await waitFor(() => expect(del).toHaveBeenCalledWith('/api/v1/admin/subscription/s-1'));
|
||||
});
|
||||
|
||||
it('دکمهٔ اعطای اشتراک از گزارش به تب اعطا میبرد', async () => {
|
||||
mockReportRow();
|
||||
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
fireEvent.click(await screen.findByText('گزارش فروش'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /اعطای اشتراک جدید/ }));
|
||||
|
||||
expect(await screen.findByText(/پلن و دوره/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user