diff --git a/.claude/prompt/admin-spa-adapt-backend-audit.md b/.claude/prompt/admin-spa-adapt-backend-audit.md new file mode 100644 index 00000000..761cf04e --- /dev/null +++ b/.claude/prompt/admin-spa-adapt-backend-audit.md @@ -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>('/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;` +- کامپوننت `` موجود را زیر جدول مطالبات با `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; +// +``` + +### ۲. 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` + ``. خواندن `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` که `data` را آرایه‌ی تخت می‌گیرد — این endpointها از `success(['data'=>..., 'meta'=>...])` استفاده می‌کنند، نه `paginated()`.) +- پیش‌فرض `limit` سمت backend ۵۰، حداکثر ۱۰۰ است؛ `page` ۱-based. +- از کامپوننت `` و `` موجود استفاده کن؛ کتابخانه‌ی جدید اضافه نکن؛ RTL. +- تاریخ‌ها Unix timestamp؛ نمایش با `formatDate()` شمسی. +- بعد از تغییر، با `ddev exec yarn dev` بیلد را چک کن (خطاهای TS فقط در خروجی tsc ظاهر می‌شوند). +``` diff --git a/assets/admin/pages/ClaimsPage.tsx b/assets/admin/pages/ClaimsPage.tsx index 3c3d5060..374dc53d 100644 --- a/assets/admin/pages/ClaimsPage.tsx +++ b/assets/admin/pages/ClaimsPage.tsx @@ -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(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() { })} )} + setRejectTarget(null)} title="رد مطالبه" diff --git a/assets/admin/pages/RepresentationSettlementPage.tsx b/assets/admin/pages/RepresentationSettlementPage.tsx index 011c82d0..62112aee 100644 --- a/assets/admin/pages/RepresentationSettlementPage.tsx +++ b/assets/admin/pages/RepresentationSettlementPage.tsx @@ -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(() => ((summaryQ.data?.data as any)?.data ?? summaryQ.data?.data)?.income, [summaryQ.data]); const listQ = useQuery({ - queryKey: ['settlements-mine'], - queryFn: () => api.get>('/api/v1/settlement'), + queryKey: ['settlements-mine', page], + queryFn: () => api.get>(`/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() { + );