fix(admin): paginate claims & settlement lists for backend-audit 50-cap

GET /billing/claims and GET /settlement now return only the first 50 rows by
default (data.data unchanged, data.meta added). ClaimsPage and
RepresentationSettlementPage read the full array with no pager, so rows beyond 50
were unreachable. Add page state + ?page/limit + the existing <Pagination>
(reading data.meta.totalRecords).

No change needed for: 422 on claim approve/pay (api.ts already surfaces the
backend message via toast; the admin UI sends no amount so it's unreachable),
the owner-only appointment-settings endpoints (admin user bypasses), and refresh
rotation (authStore.refresh already persists the rotated refresh_token).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 21:49:44 +03:30
co-authored by Claude Opus 4.8
parent 2764e68e60
commit 2f3f25f48e
3 changed files with 139 additions and 4 deletions
@@ -0,0 +1,119 @@
# تطبیق Admin SPA با تغییرات API برنچ `backend-audit`
## پروژه
`clinicpro` (Admin SPA — `assets/admin/`). پرامپت همتا برای سایت عمومی: `nobat724_front/.claude/prompt/adapt-backend-audit-api.md`.
## زمینه
برنچ `backend-audit` (۳۳ commit) چند endpoint را تغییر داد. مقدارهای wire خطاها حفظ شده‌اند (M21)، ولی **چند لیست حالا صفحه‌بندی شده‌اند** (پیش‌فرض ۵۰ ردیف، قبلاً همه) و یک `data.meta` افزوده‌اند، و تعدادی مسیر سخت‌گیری امنیتی جدید دارند. این پرامپت فقط مصرف‌کننده‌های **Admin SPA** را اصلاح می‌کند.
پاکت پاسخ این لیست‌ها (`$this->success(['data' => $rows, 'meta' => ...])`) به این شکل است:
```json
{ "success": true, "data": { "data": [ /* ردیفها */ ], "meta": { "totalRecords": 124, "totalPages": 3, "currentPage": 1 } } }
```
یعنی `data.data` (آرایه) **دست‌نخورده** است — خواندن فعلی نمی‌شکند — اما حالا فقط **۵۰ ردیف اول** برمی‌گردد مگر اینکه `?page`/`?limit` بفرستی. بدون اصلاح، کاربر ادمین بقیه‌ی ردیف‌ها را نمی‌بیند.
## جدول کامل تغییرات API (همه‌ی endpointهای تغییریافته)
| Endpoint | تغییر | مصرف در Admin SPA | اقدام |
|---|---|---|---|
| `GET /api/v1/billing/claims` | صفحه‌بندی + `data.meta`، پیش‌فرض ۵۰ | `pages/ClaimsPage.tsx` | **صفحه‌بندی اضافه شود** |
| `GET /api/v1/settlement` | صفحه‌بندی + `data.meta`، پیش‌فرض ۵۰ | `pages/RepresentationSettlementPage.tsx` | **صفحه‌بندی اضافه شود** |
| `GET /api/v1/wallet/transactions` | صفحه‌بندی + `?page` + `data.meta`، پیش‌فرض ۵۰ | مصرف نمی‌شود (فقط tauri) | — |
| `GET /api/v1/admin/comments/pending` | صفحه‌بندی + `data.meta`، پیش‌فرض ۵۰ | مصرف نشد (تأیید کن) | اگر صفحه‌ای دارد، صفحه‌بندی اضافه شود |
| `POST /api/v1/billing/claims/{uuid}/{approve|pay}` | اگر `approved_rials`/`paid_rials` خارج بازه باشد → **`422`** (`field` در پاسخ) | `ClaimsPage.tsx` transitionMut | هندل خطای ۴۲۲ |
| `GET /api/v1/appointment-settings/available-locations/{uuid}` | حالا فقط owner doctor یا `ROLE_ADMIN` (وگرنه `403`) | `pages/DoctorDetailPage.tsx` | بدون اصلاح — کاربر ادمین bypass دارد (مستند شود) |
| `GET /api/v1/appointment-settings/date-override/list/{uuid}` | همان — owner/admin | `DoctorDetailPage.tsx` | بدون اصلاح (admin bypass) |
| `GET /api/v1/appointment-settings/holidays/list/{uuid}` | همان — owner/admin | `DoctorDetailPage.tsx` | بدون اصلاح (admin bypass) |
| `GET /api/v1/insurance/{id}` | حالا owner/admin (`403`) | مصرف نشد | — |
| `GET /api/v1/clinic-pro/doctor-address/{id}` | حالا owner/admin (`403`) | مصرف نشد (تأیید کن) | — |
| `POST /api/v1/pre-registration` | `200 → 201` | اگر مصرف دارد، چک کن `res.ok`/2xx باشد نه `=== 200` | تأیید |
| کدهای خطای legacy (M21) | مقدار wire **بدون تغییر** (`SLOT_TAKEN`, `VALIDATION`, …) | — | بدون اصلاح |
> همه‌ی اصلاح‌های دیگر برنچ (N+1، unique، integrity) خروجی API را تغییر نداده‌اند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `assets/admin/pages/ClaimsPage.tsx` | لیست مطالبات بیمه — اکنون ۵۰-cap |
| `assets/admin/pages/RepresentationSettlementPage.tsx` | لیست تسویه‌ها — اکنون ۵۰-cap |
| `assets/admin/pages/DoctorDetailPage.tsx` | فقط مستندسازی (admin bypass) |
| `assets/admin/lib/api.ts` | `ApiResponse`/`PaginatedResponse` types |
| `assets/admin/components/ui/Pagination` | کامپوننت صفحه‌بندی موجود |
## وضعیت فعلی (کد واقعی)
### ClaimsPage.tsx — بدون صفحه‌بندی، فقط `data.data.data`
```tsx
const claimsQuery = useQuery<{ data: { data: Claim[] } }>({
queryKey: ['claims', queryString],
queryFn: () => api.get(`/api/v1/billing/claims${queryString}`),
});
// ...
const claims = (claimsQuery.data as any)?.data?.data ?? [];
const transitionMut = useMutation({
mutationFn: ({ uuid, action, body }) =>
api.post(`/api/v1/billing/claims/${uuid}/${action}`, body ?? {}),
onError: (e: Error) => toast.error(e.message), // ۴۲۲ جدید اینجا نمایش داده می‌شود
});
```
`queryString` از فیلترهای موجود (`status`, `insurance_id`, `from`, `to`, `q`) ساخته می‌شود ولی `page`/`limit` ندارد.
### RepresentationSettlementPage.tsx — بدون صفحه‌بندی
```tsx
const { data } = useQuery({
queryKey: ['settlements'],
queryFn: () => api.get<ApiResponse<SettlementRow[]>>('/api/v1/settlement'),
});
// data.data خوانده می‌شود به‌عنوان آرایه
```
## وظایف
### ۱. ClaimsPage — افزودن صفحه‌بندی
- یک `page` state اضافه کن (پیش‌فرض ۱) و آن را به `queryString` تزریق کن (`p.set('page', String(page))`). `page` را در `queryKey` بگذار تا refetch شود.
- total را از پاسخ بخوان: `const total = (claimsQuery.data as any)?.data?.meta?.totalRecords ?? 0;`
- کامپوننت `<Pagination>` موجود را زیر جدول مطالبات با `total`/`page`/`onPageChange` رندر کن (الگوی بقیه‌ی صفحه‌های لیست admin).
- وقتی فیلترها عوض شد، `page` را به ۱ برگردان.
```tsx
const [page, setPage] = useState(1);
// queryString: const s = p.toString(); p.set('page', String(page)); ...
const total = (claimsQuery.data as any)?.data?.meta?.totalRecords ?? 0;
// <Pagination currentPage={page} totalRecords={total} onPageChange={setPage} />
```
### ۲. ClaimsPage — هندل خطای ۴۲۲ روی approve/pay
اکنون اگر `approved_rials > total_claimed_rials` یا `paid_rials > total_approved_rials` (یا منفی) باشد، پاسخ `422` با `errors[0].field` (`approved_rials`/`paid_rials`) است. `onError` فعلی فقط `e.message` را toast می‌کند — کافی است، ولی مطمئن شو پیام فارسی backend («مبلغ تأییدشده باید بین ۰ و مبلغ مطالبه‌شده باشد») به کاربر نشان داده می‌شود (نه «خطای ناشناخته»). اگر فرم مبلغ دارد، خطا را کنار فیلد مربوطه با استفاده از `field` نشان بده.
### ۳. RepresentationSettlementPage — افزودن صفحه‌بندی
مثل ClaimsPage: `page` state + `?page=` در URL + خواندن `data.meta.totalRecords` + `<Pagination>`. خواندن `data.data` به‌عنوان آرایه دست‌نخورده می‌ماند.
### ۴. تأیید عدم مصرف
با grep تأیید کن Admin SPA این‌ها را **مصرف نمی‌کند** (اگر می‌کند، همان الگوی صفحه‌بندی را اعمال کن):
`/api/v1/wallet/transactions`، `/api/v1/admin/comments/pending`، `/api/v1/clinic-pro/doctor-address/`، `/api/v1/insurance/{id}` (GET تکی).
### ۵. مستندسازی (بدون تغییر کد)
در صفحه‌ی `DoctorDetailPage.tsx` این سه endpoint حالا owner-or-admin‌اند: `available-locations`، `date-override/list`، `holidays/list`. چون کاربر Admin SPA همیشه `ROLE_ADMIN` است، **bypass دارد و چیزی نمی‌شکند** — فقط در صورت اضافه‌شدن نقش‌های غیرادمین به این صفحه در آینده حواست باشد.
## نکات مهم
- پاکت پاسخ این لیست‌ها double-nest است: آرایه در `data.data`، متادیتا در `data.meta`. (نه `PaginatedResponse<T>` که `data` را آرایه‌ی تخت می‌گیرد — این endpointها از `success(['data'=>..., 'meta'=>...])` استفاده می‌کنند، نه `paginated()`.)
- پیش‌فرض `limit` سمت backend ۵۰، حداکثر ۱۰۰ است؛ `page` ۱-based.
- از کامپوننت `<Pagination>` و `<DataTable>` موجود استفاده کن؛ کتابخانه‌ی جدید اضافه نکن؛ RTL.
- تاریخ‌ها Unix timestamp؛ نمایش با `formatDate()` شمسی.
- بعد از تغییر، با `ddev exec yarn dev` بیلد را چک کن (خطاهای TS فقط در خروجی tsc ظاهر می‌شوند).
```
+12 -2
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, XMarkIcon, PaperAirplaneIcon, BanknotesIcon, MagnifyingGlassIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
@@ -9,6 +9,7 @@ import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import PersianDatePicker from '../components/ui/PersianDatePicker';
import FeatureGate from '../components/ui/FeatureGate';
import Pagination from '../components/ui/Pagination';
const isoNDaysAgo = (days: number): string => {
const d = new Date();
@@ -86,6 +87,11 @@ export default function ClaimsPage() {
const [searchInput, setSearchInput] = useState('');
const [rejectTarget, setRejectTarget] = useState<Claim | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [page, setPage] = useState(1);
const limit = 20;
// back to page 1 whenever a filter changes
useEffect(() => { setPage(1); }, [statusFilter, insuranceFilter, fromDate, toDate, search]);
const queryString = useMemo(() => {
const p = new URLSearchParams();
@@ -94,9 +100,11 @@ export default function ClaimsPage() {
const from = toUnix(fromDate); if (from) p.set('from', String(from));
const to = endOfDayUnix(toDate); if (to) p.set('to', String(to));
if (search.trim()) p.set('q', search.trim());
p.set('page', String(page));
p.set('limit', String(limit));
const s = p.toString();
return s ? `?${s}` : '';
}, [statusFilter, insuranceFilter, fromDate, toDate, search]);
}, [statusFilter, insuranceFilter, fromDate, toDate, search, page]);
const claimsQuery = useQuery<{ data: { data: Claim[] } }>({
queryKey: ['claims', queryString],
@@ -115,6 +123,7 @@ export default function ClaimsPage() {
});
const claims = (claimsQuery.data as any)?.data?.data ?? [];
const claimsTotal = (claimsQuery.data as any)?.data?.meta?.totalRecords ?? 0;
const debt = (debtQuery.data as any)?.data?.data ?? [];
const transitionMut = useMutation({
@@ -332,6 +341,7 @@ export default function ClaimsPage() {
})}
</div>
)}
<Pagination page={page} total={claimsTotal} limit={limit} onPageChange={setPage} />
</div>
<Modal open={!!rejectTarget} onClose={() => setRejectTarget(null)} title="رد مطالبه"
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatRial, formatDate } from '../lib/utils';
import Pagination from '../components/ui/Pagination';
interface WalletBalance { balance_rials: number }
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
@@ -29,6 +30,8 @@ export default function RepresentationSettlementPage() {
const qc = useQueryClient();
const [amount, setAmount] = useState('');
const [ibanId, setIbanId] = useState('');
const [page, setPage] = useState(1);
const limit = 20;
const balanceQ = useQuery({
queryKey: ['wallet-balance'],
@@ -55,12 +58,14 @@ export default function RepresentationSettlementPage() {
const income = useMemo<any>(() => ((summaryQ.data?.data as any)?.data ?? summaryQ.data?.data)?.income, [summaryQ.data]);
const listQ = useQuery({
queryKey: ['settlements-mine'],
queryFn: () => api.get<ApiResponse<SettlementRow[]>>('/api/v1/settlement'),
queryKey: ['settlements-mine', page],
queryFn: () => api.get<ApiResponse<SettlementRow[]>>(`/api/v1/settlement?page=${page}&limit=${limit}`),
staleTime: 30_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const settlements: SettlementRow[] = (listQ.data?.data as any)?.data ?? listQ.data?.data ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const settlementsTotal: number = (listQ.data?.data as any)?.meta?.totalRecords ?? settlements.length;
const createMut = useMutation({
mutationFn: (payload: { amount_rials: number; iban_id: string }) =>
@@ -163,6 +168,7 @@ export default function RepresentationSettlementPage() {
</tbody>
</table>
</div>
<Pagination page={page} total={settlementsTotal} limit={limit} onPageChange={setPage} />
</div>
</div>
);