diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index c524dac2..0ddf5589 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; +import PackagesPage from './pages/PackagesPage'; +import PatientPackageLedgerPage from './pages/PatientPackageLedgerPage'; import PoliciesPage from './pages/PoliciesPage'; import PolicyFormPage from './pages/PolicyFormPage'; import PolicySimulationPage from './pages/PolicySimulationPage'; @@ -293,6 +295,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index c8e9e291..8b89e2a9 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, } from '@heroicons/react/24/outline'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; @@ -33,6 +33,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'packages', label: 'پکیج‌ها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, diff --git a/assets/admin/hooks/usePackages.ts b/assets/admin/hooks/usePackages.ts new file mode 100644 index 00000000..ca0f01c2 --- /dev/null +++ b/assets/admin/hooks/usePackages.ts @@ -0,0 +1,120 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import type { CreditLedger, PackageDefinition, PatientPackage } from '../types'; + +/** + * پکیج و اعتبار جلسات. + * + * مانده هیچ‌جا کش نمی‌شود و بعد از هر تغییر دفتر، کوئری باطل می‌شود: عددی که کاربر + * می‌بیند باید همان جمع دفتر باشد، نه چیزی که فرانت حساب کرده. + */ +const PACKAGES_KEY = ['packages']; + +function fail(e: unknown, fallback: string) { + toast.error(e instanceof ApiError ? e.message : fallback); +} + +export function usePackages() { + const qc = useQueryClient(); + + const query = useQuery({ + queryKey: PACKAGES_KEY, + queryFn: () => api.get>('/api/v1/packages'), + }); + + const invalidate = () => qc.invalidateQueries({ queryKey: PACKAGES_KEY }); + + const create = useMutation({ + mutationFn: (body: Record) => + api.post>('/api/v1/packages', body), + onSuccess: () => { + toast.success('پکیج ساخته شد'); + invalidate(); + }, + onError: (e) => fail(e, 'ساخت پکیج ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, body }: { uuid: string; body: Record }) => + api.patch>(`/api/v1/package/${uuid}`, body), + onSuccess: () => { + toast.success('پکیج به‌روزرسانی شد'); + invalidate(); + }, + onError: (e) => fail(e, 'به‌روزرسانی پکیج ناموفق بود'), + }); + + /** حذف = غیرفعال کردن؛ پکیج فروخته‌شده حذف‌شدنی نیست. */ + const deactivate = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/package/${uuid}`), + onSuccess: () => { + toast.success('پکیج غیرفعال شد'); + invalidate(); + }, + onError: (e) => fail(e, 'غیرفعال‌سازی ناموفق بود'), + }); + + return { packages: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate }; +} + +export function usePatientPackages(patientUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['patient-packages', patientUuid]; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/patient/${patientUuid}/packages`), + enabled: !!patientUuid, + }); + + const sell = useMutation({ + mutationFn: (body: { package_uuid: string; price_paid_rials?: number }) => + api.post>(`/api/v1/patient/${patientUuid}/package`, body), + onSuccess: () => { + toast.success('پکیج برای بیمار ثبت شد'); + qc.invalidateQueries({ queryKey: key }); + }, + onError: (e) => fail(e, 'ثبت پکیج ناموفق بود'), + }); + + return { patientPackages: query.data?.data ?? [], loading: query.isLoading, sell }; +} + +export function useCreditLedger(patientPackageUuid: string | undefined) { + const qc = useQueryClient(); + const key = ['credit-ledger', patientPackageUuid]; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/patient-package/${patientPackageUuid}/ledger`), + enabled: !!patientPackageUuid, + }); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: key }); + qc.invalidateQueries({ queryKey: ['patient-packages'] }); + }; + + const adjust = useMutation({ + mutationFn: (body: { delta: number; reason: string }) => + api.post>(`/api/v1/patient-package/${patientPackageUuid}/adjust`, body), + onSuccess: () => { + toast.success('اصلاح اعتبار ثبت شد'); + invalidate(); + }, + onError: (e) => fail(e, 'اصلاح اعتبار ناموفق بود'), + }); + + const expire = useMutation({ + mutationFn: (reason: string) => + api.post>(`/api/v1/patient-package/${patientPackageUuid}/expire`, { reason }), + onSuccess: () => { + toast.success('پکیج ابطال شد'); + invalidate(); + }, + onError: (e) => fail(e, 'ابطال پکیج ناموفق بود'), + }); + + return { ledger: query.data?.data, loading: query.isLoading, adjust, expire }; +} diff --git a/assets/admin/pages/PackagesPage.tsx b/assets/admin/pages/PackagesPage.tsx new file mode 100644 index 00000000..423d4bf7 --- /dev/null +++ b/assets/admin/pages/PackagesPage.tsx @@ -0,0 +1,279 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import Modal from '../components/ui/Modal'; +import PriceInput from '../components/ui/PriceInput'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import { formatRial } from '../lib/utils'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { usePackages } from '../hooks/usePackages'; +import { api, type ApiResponse } from '../lib/api'; +import { useQuery } from '@tanstack/react-query'; +import type { PackageDefinition, ServiceItem } from '../types'; + +interface Draft { + uuid?: string; + name: string; + session_count: number; + price_rials: number; + validity_days: number | ''; + service_uuids: string[]; +} + +const EMPTY: Draft = { name: '', session_count: 6, price_rials: 0, validity_days: '', service_uuids: [] }; + +/** + * تعریف پکیج‌ها. + * + * ماندهٔ بیمار اینجا نیست — آن در پروندهٔ بیمار است. اینجا فقط «چه می‌فروشیم». + */ +export default function PackagesPage() { + const { packages, loading, create, update, deactivate } = usePackages(); + const { can } = usePermissions(); + const canManage = can('appointment_settings', 'update'); + + const [urlState, setUrlState] = useUrlState({ search: '' }); + const [draft, setDraft] = useState(null); + + const { data: servicesData } = useQuery({ + queryKey: ['service-items-for-packages'], + queryFn: () => api.get>('/api/v1/service-items'), + staleTime: 60_000, + }); + + const services = servicesData?.data ?? []; + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return packages.filter((p) => q === '' || p.name.includes(q)); + }, [packages, urlState.search]); + + const columns: Column[] = [ + { + key: 'name', + header: 'پکیج', + render: (p) => ( +
+ {p.name} + + {p.services.map((s) => s.name).join('، ') || 'بدون سرویس'} + +
+ ), + }, + { + key: 'session_count', + header: 'تعداد جلسه', + render: (p) => {p.session_count}, + }, + { + key: 'price_rials', + header: 'قیمت', + render: (p) => {formatRial(p.price_rials)}, + }, + { + key: 'validity_days', + header: 'اعتبار', + render: (p) => ( + + {p.validity_days === null ? 'بی‌پایان' : `${p.validity_days} روز`} + + ), + }, + { + key: 'active', + header: 'وضعیت', + render: (p) => , + }, + ]; + + const save = async () => { + if (!draft) return; + + const body = { + name: draft.name, + session_count: draft.session_count, + price_rials: draft.price_rials, + validity_days: draft.validity_days === '' ? null : draft.validity_days, + service_uuids: draft.service_uuids, + }; + + if (draft.uuid) { + await update.mutateAsync({ uuid: draft.uuid, body }); + } else { + await create.mutateAsync(body); + } + + setDraft(null); + }; + + return ( +
+ setDraft({ ...EMPTY })}> + پکیج تازه + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در پکیج‌ها..." + emptyMessage="هنوز پکیجی تعریف نشده است" + actions={(p) => + canManage ? ( +
+ + {p.active && ( + + )} +
+ ) : null + } + /> + + setDraft(null)} + footer={ + <> + + + + } + > + {draft && ( +
+
+ + setDraft({ ...draft, name: e.target.value })} + placeholder="۶ جلسه لیزر فول‌بادی" + /> +
+ +
+ + setDraft({ ...draft, session_count: Number(e.target.value) })} + /> +
+ +
+ + setDraft({ ...draft, price_rials: v })} + suffix="ریال" + /> +
+ +
+ + + setDraft({ ...draft, validity_days: e.target.value === '' ? '' : Number(e.target.value) }) + } + placeholder="خالی یعنی بی‌پایان" + /> +
+ +
+ + { + const uuid = String(v ?? ''); + if (uuid && !draft.service_uuids.includes(uuid)) { + setDraft({ ...draft, service_uuids: [...draft.service_uuids, uuid] }); + } + }} + options={services + .filter((s) => !draft.service_uuids.includes(s.uuid)) + .map((s) => ({ value: s.uuid, label: s.name }))} + placeholder="افزودن سرویس" + /> +
+ {draft.service_uuids.map((uuid) => ( + + ))} +
+ {draft.service_uuids.length === 0 && ( + + پکیج بدون سرویس قابل مصرف نیست. + + )} +
+
+ )} +
+
+ ); +} diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index a5dcda64..745652fb 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -2,12 +2,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'; import { - ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, + ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon, CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon, PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon, ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon, } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline'; +import { usePackages, usePatientPackages } from '../hooks/usePackages'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; @@ -41,7 +42,7 @@ import { } from '../lib/patientForm'; import { usePermissions } from '../hooks/usePermissions'; -type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records'; +type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'notes' | 'callcenter' | 'attach' | 'records'; const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [ { key: 'services', label: 'سرویس‌ها', icon: (c) => }, @@ -49,6 +50,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode } { key: 'appointments', label: 'نوبت‌ها', icon: (c) => }, { key: 'payments', label: 'پرداخت‌ها', icon: (c) => }, { key: 'wallet', label: 'کیف پول', icon: (c) => }, + { key: 'packages', label: 'پکیج‌ها', icon: (c) => }, { key: 'notes', label: 'یادداشت‌ها', icon: (c) => }, { key: 'callcenter', label: 'کال سنتر', icon: (c) => }, { key: 'attach', label: 'ضمیمه', icon: (c) => }, @@ -260,6 +262,8 @@ export default function PatientDetailPage() { ) : tab === 'wallet' ? ( + ) : tab === 'packages' ? ( + ) : tab === 'callcenter' ? ( ) : tab === 'attach' ? ( @@ -1021,3 +1025,86 @@ function AppointmentsTab({ uuid, q }: { ); } + +/** + * پکیج‌های بیمار. + * + * مانده از دفتر می‌آید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان می‌دهد تا + * معلوم باشد عدد از کجا آمده. + */ +function PackagesTab({ uuid }: { uuid: string }) { + const { patientPackages, loading, sell } = usePatientPackages(uuid); + const { packages } = usePackages(); + const [selected, setSelected] = useState(''); + + const active = packages.filter((p) => p.active); + + return ( +
+
+
+ + setSelected(String(v ?? ''))} + options={active.map((p) => ({ + value: p.uuid, + label: `${p.name} — ${p.session_count} جلسه`, + }))} + placeholder="انتخاب پکیج" + /> +
+ +
+ + {loading ? ( +
در حال بارگذاری…
+ ) : patientPackages.length === 0 ? ( +
+ این بیمار هنوز پکیجی نخریده است +
+ ) : ( + patientPackages.map((p) => ( +
+
+ {p.package_name} + + خرید {formatDate(p.purchased_at)} + {p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`} + +
+ + + مانده: {p.balance} از {p.session_count} + + + {p.expired && منقضی} + {!p.expired && p.balance === 0 && ( + + اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه می‌شود. + + )} + + + دفتر اعتبار + +
+ )) + )} +
+ ); +} diff --git a/assets/admin/pages/PatientPackageLedgerPage.test.tsx b/assets/admin/pages/PatientPackageLedgerPage.test.tsx new file mode 100644 index 00000000..d6e9ca34 --- /dev/null +++ b/assets/admin/pages/PatientPackageLedgerPage.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { Routes, Route } from 'react-router-dom'; +import { api } from '../lib/api'; +import PatientPackageLedgerPage from './PatientPackageLedgerPage'; + +const get = api.get as ReturnType; + +const ledger = { + package: { + uuid: 'pp1', + package_uuid: 'p1', + package_name: '۶ جلسه لیزر', + patient_uuid: 'pat1', + session_count: 6, + price_paid_rials: 25_000_000, + purchased_at: 1_700_000_000, + valid_to: null, + expired: false, + balance: 5, + }, + rows: [ + { + uuid: 'l1', kind: 'purchase', delta: 6, appointment_uuid: null, service_uuid: null, + service_name: null, reason: 'خرید پکیج', created_by: 'u1', created_at: 1_700_000_000, + running_balance: 6, + }, + { + uuid: 'l2', kind: 'consume', delta: -1, appointment_uuid: 'a1', service_uuid: 's1', + service_name: 'لیزر', reason: null, created_by: null, created_at: 1_700_100_000, + running_balance: 5, + }, + ], +}; + +function renderPage() { + return renderWithProviders( + + } /> + , + { route: '/admin/patient-package/pp1/ledger' }, + ); +} + +describe('PatientPackageLedgerPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + get.mockResolvedValue({ success: true, data: ledger }); + }); + + /** ماندهٔ هر ردیف باید دیده شود — همان چیزی که یک شمارنده نمی‌تواند نشان دهد. */ + it('shows every ledger row with its running balance', async () => { + renderPage(); + + await waitFor(() => expect(screen.getByText('خرید')).toBeInTheDocument()); + expect(screen.getByText('مصرف در نوبت')).toBeInTheDocument(); + expect(screen.getByText('+6')).toBeInTheDocument(); + expect(screen.getByText('-1')).toBeInTheDocument(); + }); + + it('summarises the balance against the purchased session count', async () => { + renderPage(); + + await waitFor(() => expect(screen.getByText(/از 6 جلسه/)).toBeInTheDocument()); + + // عدد مانده در همان جمله می‌آید؛ «۵ از ۶» یعنی یک جلسه مصرف شده. + expect(screen.getByText(/از 6 جلسه/).parentElement).toHaveTextContent('5'); + }); +}); diff --git a/assets/admin/pages/PatientPackageLedgerPage.tsx b/assets/admin/pages/PatientPackageLedgerPage.tsx new file mode 100644 index 00000000..82bf3e44 --- /dev/null +++ b/assets/admin/pages/PatientPackageLedgerPage.tsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import Modal from '../components/ui/Modal'; +import { formatDate, formatRial } from '../lib/utils'; +import { usePermissions } from '../hooks/usePermissions'; +import { useCreditLedger } from '../hooks/usePackages'; +import type { CreditLedgerRow } from '../types'; + +const KIND_LABELS: Record = { + purchase: 'خرید', + consume: 'مصرف در نوبت', + refund: 'بازگشت با لغو', + adjustment: 'اصلاح دستی', + expiry: 'انقضا', +}; + +/** + * دفتر اعتبار یک پکیج خریداری‌شده. + * + * ستون «مانده» در هر ردیف نشان می‌دهد عدد نهایی از کجا آمده — همان چیزی که یک + * شمارندهٔ ذخیره‌شده هرگز نمی‌تواند نشان دهد. + */ +export default function PatientPackageLedgerPage() { + const { patientPackageUuid } = useParams<{ patientPackageUuid: string }>(); + const { ledger, loading, adjust, expire } = useCreditLedger(patientPackageUuid); + const { can } = usePermissions(); + const canCorrect = can('appointment_settings', 'update'); + + const [adjusting, setAdjusting] = useState(false); + const [delta, setDelta] = useState(1); + const [reason, setReason] = useState(''); + + const columns: Column[] = [ + { + key: 'created_at', + header: 'تاریخ', + render: (r) => {formatDate(r.created_at)}, + }, + { + key: 'kind', + header: 'نوع', + render: (r) => {KIND_LABELS[r.kind]}, + }, + { + key: 'delta', + header: 'تغییر', + render: (r) => ( + 0 ? 'var(--success)' : 'var(--danger)' }}> + {r.delta > 0 ? `+${r.delta}` : r.delta} + + ), + }, + { + key: 'running_balance', + header: 'مانده', + render: (r) => {r.running_balance}, + }, + { + key: 'reason', + header: 'دلیل', + render: (r) => ( + + {r.reason ?? r.service_name ?? '—'} + + ), + }, + ]; + + return ( +
+ + + {ledger && ( +
+ + مانده: {ledger.package.balance} از {ledger.package.session_count} جلسه + + + پرداختی: {formatRial(ledger.package.price_paid_rials)} + + + خرید: {formatDate(ledger.package.purchased_at)} + {ledger.package.valid_to !== null && ` · اعتبار تا ${formatDate(ledger.package.valid_to)}`} + + {ledger.package.expired && منقضی} + + {canCorrect && ( +
+ + +
+ )} +
+ )} + +
+ +
+ + setAdjusting(false)} + footer={ + <> + + + + } + > +
+
+ + setDelta(Number(e.target.value))} + /> +
+ +
+ + setReason(e.target.value)} + placeholder="مثلاً: جبران جلسهٔ لغوشده توسط کلینیک" + /> + + دلیل در دفتر ثبت می‌شود و بعداً قابل ویرایش نیست. + +
+
+
+
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index b537bcef..b508568e 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1204,3 +1204,50 @@ export interface PolicySimulationRun { rows: SimulationRow[]; warning: string | null; } + +// ── پکیج و اعتبار جلسات (تسک ۱۱) ───────────────────────────────────────────── + +export interface PackageDefinition { + uuid: string; + name: string; + session_count: number; + price_rials: number; + /** `null` یعنی بی‌پایان */ + validity_days: number | null; + active: boolean; + services: { uuid: string; name: string }[]; + created_at: number; +} + +export interface PatientPackage { + uuid: string; + package_uuid: string; + package_name: string; + patient_uuid: string; + session_count: number; + price_paid_rials: number; + purchased_at: number; + valid_to: number | null; + expired: boolean; + /** همیشه از جمع دفتر می‌آید — هیچ ستونی در دیتابیس نیست */ + balance: number; +} + +export interface CreditLedgerRow { + uuid: string; + kind: 'purchase' | 'consume' | 'refund' | 'adjustment' | 'expiry'; + delta: number; + appointment_uuid: string | null; + service_uuid: string | null; + service_name: string | null; + reason: string | null; + created_by: string | null; + created_at: number; + /** در UI محاسبه‌شده نیست — سرور همان جمع تجمعی را می‌دهد */ + running_balance: number; +} + +export interface CreditLedger { + package: PatientPackage; + rows: CreditLedgerRow[]; +} diff --git a/docs/api/appointment-booking.md b/docs/api/appointment-booking.md index 3cbb6749..8ffb724a 100644 --- a/docs/api/appointment-booking.md +++ b/docs/api/appointment-booking.md @@ -174,3 +174,13 @@ ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php # ۱۲ تست دقیقه نگه‌داشتن صندلی، هم وقت بیمار را تلف می‌کند هم صندلی را. جزئیات: [policy.md](policy.md) + +--- + +## اعتبار پکیج + +`confirm` یک جلسه از پکیج معتبر بیمار کسر می‌کند (ردیف `consume`) و لغو نوبت آن را +برمی‌گرداند (ردیف `refund`) — ردیف مصرف حذف نمی‌شود. کلید یکتای دفتر تضمین می‌کند +اجرای دوبارهٔ `confirm` جلسهٔ دوم نخورد. + +جزئیات: [package.md](package.md) diff --git a/docs/api/package.md b/docs/api/package.md new file mode 100644 index 00000000..50d04bab --- /dev/null +++ b/docs/api/package.md @@ -0,0 +1,302 @@ +# Package — پکیج و دفتر اعتبار جلسات + +اندپوینت‌های `src/Package/*`. «پکیج شش جلسه لیزر» حالت رایج کلینیک زیبایی است: بیمار +یکجا پول می‌دهد و بعداً جلساتش را رزرو می‌کند. + +**اعتبار یک دفتر حساب است، نه یک شمارنده.** هیچ ستون `remaining` یا `used_count` در هیچ +جدولی وجود ندارد و مانده همیشه `SUM(delta)` ردیف‌های دفتر است. ردیف‌ها append-only اند؛ +تصحیح یعنی ردیف تازه، نه ویرایش ردیف قبلی. + +همهٔ مسیرها `IS_AUTHENTICATED_FULLY` می‌خواهند و به محیط جاری محدودند (`404` برای محیط +دیگر). `adjust` و `expire` علاوه بر آن نقش پزشک/کلینیک/ادمین می‌خواهند (`403` برای منشی). + +--- + +## چرخهٔ اعتبار + +| نوع ردیف | delta | کِی نوشته می‌شود | +|---|---|---| +| `purchase` | `+session_count` | فروش پکیج به بیمار | +| `consume` | `-1` | `BookingService::confirm()` — ثبت نهایی نوبت | +| `refund` | `+1` | لغو همان نوبت؛ ردیف `consume` **حذف نمی‌شود** | +| `adjustment` | `±n` | اصلاح دستی، همیشه با `reason` و `created_by` | +| `expiry` | `-balance` | `app:package:expire` یا ابطال دستی | + +`UNIQUE(appointment_id, kind)` مصرف دوباره را می‌بندد: `confirm` idempotent است و +اجرای دومش جلسهٔ دوم نمی‌خورد. + +**FIFO:** وقتی بیمار چند پکیج معتبر برای یک سرویس دارد، قدیمی‌ترین اول مصرف می‌شود — +چون به انقضا نزدیک‌تر است و نگه داشتنش یعنی بیمار پولش را از دست بدهد. + +--- + +## GET `/api/v1/packages` + +| Query | Type | Description | +|---|---|---| +| `active` | bool | فقط فعال‌ها یا فقط غیرفعال‌ها | + +### Response `200` +```json +{ + "success": true, + "data": [ + { + "uuid": "5502aa44-213a-44bd-9b24-64094c675f6e", + "name": "۶ جلسه لیزر فول‌بادی", + "session_count": 6, + "price_rials": 25000000, + "validity_days": 365, + "active": true, + "services": [{ "uuid": "be2e7a93-…", "name": "لیزر فول‌بادی" }], + "created_at": 1785483226 + } + ] +} +``` + +--- + +## POST `/api/v1/packages` + +### Request Body +```json +{ + "name": "۶ جلسه لیزر فول‌بادی", + "session_count": 6, + "price_rials": 25000000, + "validity_days": 365, + "service_uuids": ["be2e7a93-976a-468b-aa7a-b8f4a4278953"] +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | ✅ | | +| `session_count` | int | ✅ | حداقل ۱ | +| `price_rials` | int | — | ریال؛ ستون `bigint` است | +| `validity_days` | int\|null | — | اعتبار از تاریخ خرید؛ `null` = بی‌پایان | +| `service_uuids` | string[] | ✅ | حداقل یک سرویس از همین محیط | +| `active` | bool | — | پیش‌فرض `true` | + +### Response `201` +همان شکل بالا، با `uuid` تازه. + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_VALIDATION_002` | 422 | نام خالی، `session_count < 1`، یا `service_uuids` خالی | +| `ERR_NOT_FOUND_001` | 404 | سرویس خارج از محیط جاری | + +> پکیج بدون سرویس هرگز قابل مصرف نیست؛ ساختنش فقط یک تلهٔ خاموش برای اپراتور است، +> پس `422` می‌گیرد. + +--- + +## GET · PATCH · DELETE `/api/v1/package/{uuid}` + +`PATCH` همان فیلدهای ساخت را می‌پذیرد. فرستادن `service_uuids` **جایگزینی کامل** است. + +`DELETE` پکیج را **غیرفعال** می‌کند، حذف نمی‌کند: ردیف‌های دفترِ بیماران به آن ارجاع +دارند و حذفش تاریخچهٔ اعتبار را بی‌معنا می‌کند. + +--- + +## POST `/api/v1/patient/{uuid}/package` + +فروش پکیج به بیمار. یک ردیف `purchase` هم‌زمان نوشته می‌شود. + +### Request Body +```json +{ "package_uuid": "5502aa44-…", "price_paid_rials": 25000000 } +``` + +`price_paid_rials` اختیاری است؛ نبودنش یعنی قیمت تعریفِ لحظهٔ خرید. + +### Response `201` +```json +{ + "success": true, + "data": { + "uuid": "dcd21f8e-ddee-4f3a-96fb-9fd6c1867a27", + "package_uuid": "5502aa44-…", + "package_name": "۶ جلسه لیزر فول‌بادی", + "patient_uuid": "5faa22e0-…", + "session_count": 6, + "price_paid_rials": 25000000, + "purchased_at": 1785483226, + "valid_to": 1817019226, + "expired": false, + "balance": 6 + } +} +``` + +`session_count` و `price_paid_rials` **کپی**اند نه ارجاع: تغییر تعریف پکیج فردا، پکیج +فروخته‌شدهٔ دیروز را عوض نمی‌کند. + +--- + +## GET `/api/v1/patient/{uuid}/packages` + +پکیج‌های بیمار با ماندهٔ روز. پکیج منقضی `balance: 0` نشان می‌دهد ولی دفترش دست‌نخورده +می‌ماند. + +--- + +## GET `/api/v1/patient-package/{uuid}/ledger` + +### Response `200` +```json +{ + "success": true, + "data": { + "package": { "uuid": "dcd21f8e-…", "balance": 7, "…": "مثل بالا" }, + "rows": [ + { + "uuid": "3d11dc1f-…", + "kind": "purchase", + "delta": 6, + "appointment_uuid": null, + "service_uuid": null, + "service_name": null, + "reason": "خرید پکیج «۶ جلسه لیزر فول‌بادی»", + "created_by": "b093deb7-…", + "created_at": 1785483226, + "running_balance": 6 + }, + { + "uuid": "ca4ab9fb-…", + "kind": "adjustment", + "delta": 1, + "reason": "جبران جلسهٔ لغوشده", + "created_by": "b093deb7-…", + "created_at": 1785483226, + "running_balance": 7 + } + ] + } +} +``` + +`running_balance` جمع تجمعی است و در پاسخ محاسبه می‌شود — همین به کاربر نشان می‌دهد +عدد نهایی از کجا آمده. + +--- + +## POST `/api/v1/patient-package/{uuid}/adjust` + +**Permission:** پزشک · کلینیک · ادمین (منشی `403`) + +### Request Body +```json +{ "delta": 2, "reason": "جبران جلسهٔ لغوشده توسط کلینیک" } +``` + +### Errors +| Code | HTTP | Description | +|---|---|---| +| `ERR_VALIDATION_002` | 422 | `delta` صفر یا غایب، یا `reason` خالی | +| `ERR_VALIDATION_001` | 422 | اصلاحی که مانده را منفی می‌کند | +| `ERR_FORBIDDEN_001` | 403 | نقش منشی | + +```json +{ + "success": false, + "data": null, + "errors": [{ "code": "ERR_VALIDATION_002", "message": "دلیل اصلاح الزامی است", "field": "reason" }] +} +``` + +--- + +## POST `/api/v1/patient-package/{uuid}/expire` + +ابطال دستی: یک ردیف `expiry` با `delta = -balance`. ماندهٔ صفر → `422`. + +--- + +## اثر پکیج روی قیمت + +`POST /api/v1/pricing/quote` یک فیلد اختیاری `patient_uuid` می‌گیرد. با آن، اگر بیمار +پکیج معتبری برای همان سرویس داشته باشد **قیمت پایهٔ سرویس** پوشش داده می‌شود: + +```json +{ + "base_rials": 5000000, + "final_rials": 0, + "package_will_be_consumed": true, + "package_uuid": "dcd21f8e-…", + "breakdown": { + "discounts": [ + { "label": "پوشش پکیج «۶ جلسه لیزر فول‌بادی»", "rials": 5000000, "kind": "package" } + ] + } +} +``` + +⚠️ **`quote` هیچ‌وقت مصرف نمی‌کند** — فقط اعلام می‌کند. مصرف واقعی در +`BookingService::confirm()` است. اگر پیش‌نمایش مصرف می‌کرد، هر رفرش صفحه یک جلسه از +بیمار می‌گرفت. + +پکیج **قیمت پایه** را می‌پوشاند نه آیتم‌های اضافه: «شش جلسه لیزر» یعنی شش بار خودِ +لیزر، نه هر چیزی که کنارش انتخاب شود. + +ماندهٔ صفر خطا نیست: پکیج اعمال نمی‌شود و بیمار مبلغ کامل را نقدی می‌پردازد. + +--- + +## دستور انقضا + +```bash +ddev exec php bin/console app:package:expire # روزانه +ddev exec php bin/console app:package:expire --dry-run +``` + +برای هر پکیج با `valid_to` گذشته و ماندهٔ مثبت، یک ردیف `expiry` می‌نویسد. دفتر +دست‌نخورده می‌ماند تا «۳ جلسه‌ام چه شد؟» همیشه جواب داشته باشد. + +--- + +## قفل بدبینانه اینجا، سطل زمانی آنجا + +مصرف اعتبار با `PESSIMISTIC_WRITE` روی همان یک ردیف پکیج قفل می‌شود — برخلاف رزرو +اسلات (تسک ۰۷) که با سطل‌های پنج‌دقیقه‌ای و کلید یکتا کار می‌کند. این تفاوت عمدی است: + +| | رزرو اسلات (تسک ۰۷) | اعتبار جلسه (تسک ۱۱) | +|---|---|---| +| نرخ رقابت | بالا — ساعت پرتقاضا | ناچیز — یک بیمار، یک پکیج | +| ردیف‌های درگیر | ده‌ها سطل | یک ردیف | +| هزینهٔ قفل | صف‌شدن رزروها | ناچیز | +| راه‌حل | کلید یکتا روی سطل | قفل بدبینانه | + +اگر روزی کسی خواست «برای یکدستی» یکی را به دیگری تبدیل کند، همین جدول جواب است. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `packages` · `patient_packages` · `session_credit_ledger` | جفت محیط | +| `package_services` | `AGGREGATE_CHILDREN` — ریشه `Package` | + +برخلاف `wallet_transactions` (که `ENTITIES` است چون پول مال شخص است)، اعتبار جلسه جفت +محیط واقعی می‌گیرد: اعتبار جلسهٔ لیزر در کلینیک الف در کلینیک ب معنا ندارد. + +## صفحه‌های پنل + +| مسیر | صفحه | +|---|---| +| `/admin/packages` | تعریف پکیج‌ها | +| `/admin/patient-package/{uuid}/ledger` | دفتر اعتبار یک پکیج خریداری‌شده | + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Package # ۱۶ تست +``` + +مهم‌ترینش `testNoStoredBalanceColumnExists` است: هیچ ستون مانده‌ای در schema نباید +باشد. تست عجیبی به نظر می‌رسد ولی همان چیزی است که شش ماه بعد جلوی «بهینه‌سازی» +می‌ایستد. diff --git a/docs/api/pricing.md b/docs/api/pricing.md index 6360a3c7..3fc57f0f 100644 --- a/docs/api/pricing.md +++ b/docs/api/pricing.md @@ -154,3 +154,13 @@ ddev exec php bin/phpunit tests/Pricing # ۱۲ تست ``` جزئیات دسته‌ها و اثرها: [policy.md](policy.md) + +--- + +## پکیج + +`quote` یک `patient_uuid` اختیاری می‌گیرد؛ با آن، پکیج معتبرِ بیمار قیمت پایهٔ سرویس را +می‌پوشاند و `package_will_be_consumed` روشن می‌شود. **پیش‌نمایش هرگز مصرف نمی‌کند** — +مصرف در ثبت نهایی است. + +جزئیات: [package.md](package.md) diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index 4a9886ba..529010c2 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -305,3 +305,13 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql | `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند | | `tests/Shared/RequestReachableChildTenantTest.php` | فرزندانِ قابل‌دسترس با uuid را **خودِ فیلتر** می‌بندد، بدون گارد دستی | | `tests/Auth/MultiClinicOwnerContextTest.php` | مالک چند کلینیک به هرکدام می‌تواند سوییچ کند | + + +## اعتبار جلسه: چرا برخلاف کیف پول جفت محیط می‌گیرد + +`wallet_transactions` عمداً در `ENTITIES` است: پول مالِ **شخص** است و در هر محیطی همان +پول است؛ هر ردیف فقط `recorded_entity_*` دارد تا معلوم باشد کجا ثبت شده. + +`session_credit_ledger` متفاوت است و جفت محیط واقعی می‌گیرد: «شش جلسه لیزر کلینیک الف» +در کلینیک ب هیچ معنایی ندارد و قابل مصرف نیست. همین تفاوت باعث می‌شود پکیج‌های یک بیمار +در دو کلینیک کاملاً از هم جدا بمانند. diff --git a/docs/new_feture/taskes/task-11-package-credit-ledger/checklist.md b/docs/new_feture/taskes/task-11-package-credit-ledger/checklist.md index 10c46150..27c00d6b 100644 --- a/docs/new_feture/taskes/task-11-package-credit-ledger/checklist.md +++ b/docs/new_feture/taskes/task-11-package-credit-ledger/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۱۱ (پکیج و دفتر اعتبار جلسات) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -11,104 +11,108 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | ⏳ | ⭐⭐ `LedgerSchemaTest` اجبار می‌کند | -| ۰.۳ | دفتر append-only — هیچ `remove`/`update` روی ردیف‌ها | ⏳ | | -| ۰.۴ | `WalletTransaction` و منطق کیف پول دست‌نخورده | ⏳ | مفهوم متفاوت | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۰.۲ | **هیچ ستون `remaining`/`used_count`/`balance` در هیچ جدولی** | ✅ | ⭐⭐ `testNoStoredBalanceColumnExists` روی schema واقعی | +| ۰.۳ | دفتر append-only | ✅ | هیچ `remove`/`setter` روی `SessionCreditLedger`؛ تصحیح = ردیف تازه | +| ۰.۴ | `WalletTransaction` دست‌نخورده | ✅ | تفاوتش در `tenancy.md` نوشته شد | ## ۱. بک‌اند | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `Package` · `PackageService` · `PatientPackage` · `SessionCreditLedger` | ⏳ | | -| ۱.۲ | `CreditLedgerService` — **تنها** نویسندهٔ دفتر | ⏳ | | -| ۱.۳ | `balance()` = `SUM(delta)`، بدون هیچ مقدار ذخیره‌شده | ⏳ | ⭐ | -| ۱.۴ | پنج `kind` تعریف شد | ⏳ | | -| ۱.۵ | `quote` **هرگز** مصرف نمی‌کند؛ فقط `confirm` | ⏳ | ⭐⭐ رفرش صفحه = از دست رفتن جلسه | -| ۱.۶ | `PriceQuote` پرچم `packageWillBeConsumed` دارد | ⏳ | | -| ۱.۷ | مانده صفر → `false`، **نه استثنا** | ⏳ | ⭐ بیمار نقدی بپردازد | -| ۱.۸ | قفل بدبینانه `PESSIMISTIC_WRITE` روی ردیف پکیج | ⏳ | با جدول مقایسه با تسک ۰۷ | -| ۱.۹ | `catch UniqueConstraintViolationException` روی `consume` → idempotent | ⏳ | | -| ۱.۱۰ | FIFO — قدیمی‌ترین پکیج منقضی‌نشده | ⏳ | LIFO یعنی پول بیمار سوخته | -| ۱.۱۱ | `valid_to` هنگام **خرید** محاسبه و ذخیره می‌شود | ⏳ | | -| ۱.۱۲ | لغو → ردیف `refund`، نه حذف `consume` | ⏳ | | -| ۱.۱۳ | `TODO` با ارجاع به تسک ۱۳ برای سیاست بازگشت اعتبار | ⏳ | نه پرچم نیم‌کاره | -| ۱.۱۴ | `adjust` فقط با نقش مدیر و با `reason` اجباری | ⏳ | | -| ۱.۱۵ | `app:package:expire` روزانه — ردیف `expiry` با `delta = -balance` | ⏳ | | -| ۱.۱۶ | قلاب مرحلهٔ ۴ `PricingEngine` وصل شد | ⏳ | | -| ۱.۱۷ | هشت endpoint | ⏳ | | -| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | | +| ۱.۱ | چهار entity | ✅ | | +| ۱.۲ | `CreditLedgerService` تنها نویسندهٔ دفتر | ✅ | فروش، مصرف، بازگشت و اصلاح همه از همین عبور می‌کنند | +| ۱.۳ | `balance()` = `SUM(delta)` | ✅ | ⭐ | +| ۱.۴ | پنج `kind` | ✅ | سازنده `kind` ناشناخته و `delta` صفر را رد می‌کند | +| ۱.۵ | `quote` هرگز مصرف نمی‌کند | ✅ | ⭐⭐ `testQuoteAnnouncesThePackageWithoutConsumingIt` دو بار quote می‌زند و مانده را می‌سنجد | +| ۱.۶ | پرچم `packageWillBeConsumed` | ✅ | + `package_uuid` | +| ۱.۷ | مانده صفر → `false` نه استثنا | ✅ | ⭐ | +| ۱.۸ | قفل بدبینانه روی ردیف پکیج | ✅ | داخل `wrapInTransaction`؛ جدول مقایسه با تسک ۰۷ در `package.md` | +| ۱.۹ | `consume` idempotent | ⚠️ | با **بررسی پیش از درج** نه `catch` روی نقض کلید: گرفتن استثنا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند | +| ۱.۱۰ | FIFO | ✅ | `testTheOldestUnexpiredPackageIsUsedFirst` | +| ۱.۱۱ | `valid_to` هنگام خرید | ✅ | از `validity_days` لحظهٔ خرید | +| ۱.۱۲ | لغو → ردیف `refund` | ✅ | `BookingService::cancel()` | +| ۱.۱۳ | ارجاع به تسک ۱۳ برای سیاست بازگشت | ✅ | در docblock `refund()` | +| ۱.۱۴ | `adjust` فقط نقش مدیر و با `reason` | ✅ | منشی `403` | +| ۱.۱۵ | `app:package:expire` | ✅ | `--dry-run` هم دارد | +| ۱.۱۶ | قلاب `PricingEngine` | ✅ | `patient_uuid` اختیاری در `quote` | +| ۱.۱۷ | هشت endpoint | ✅ | ۹ تا: `packages` GET/POST · `package/{uuid}` GET/PATCH/DELETE · `patient/{uuid}/package` · `patient/{uuid}/packages` · `patient-package/{uuid}/ledger` · `/adjust` · `/expire` | +| ۱.۱۸ | `TenantOwnershipChecker` روی هر uuid | ✅ | `testAnotherClinicCannotSeeOrTouchThePackage` | ## ۲. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | چهار جدول | ⏳ | | -| ۲.۲ | `price_rials` و `price_paid_rials` از نوع **BIGINT** | ⏳ | پکیج بزرگ | -| ۲.۳ | `UNIQUE(appointment_id, kind)` روی دفتر | ⏳ | ⭐ جلوگیری از مصرف دوباره | -| ۲.۴ | `session_count`/`price_paid_rials` روی `patient_packages` **snapshot** اند | ⏳ | قانون پنجم | -| ۲.۵ | `ON DELETE RESTRICT` روی سرویسِ پکیج فروخته‌شده | ⏳ | | -| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | ⏳ | | -| ۲.۷ | دفتر **جفت tenant** دارد (نه `ENTITIES` مثل کیف پول) | ⏳ | ⭐ دلیل مکتوب | -| ۲.۸ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۲.۱ | چهار جدول | ✅ | `Version20260731072023` | +| ۲.۲ | `bigint` روی هر دو ستون مبلغ | ✅ | | +| ۲.۳ | `UNIQUE(appointment_id, kind)` | ✅ | ⭐ | +| ۲.۴ | snapshot تعداد و قیمت | ✅ | قانون پنجم | +| ۲.۵ | `ON DELETE RESTRICT` روی سرویس و پکیج | ✅ | | +| ۲.۶ | `package_services` در `AGGREGATE_CHILDREN` | ✅ | | +| ۲.۷ | دفتر جفت tenant دارد | ✅ | ⭐ دلیلش در `tenancy.md` کنار کیف پول | +| ۲.۸ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۳. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `PackagesPage` — تعریف با `PriceInput` و انتخاب سرویس | ⏳ | | -| ۳.۲ | کارت «پکیج‌ها» در `PatientDetailPage` با مانده و انقضا | ⏳ | | -| ۳.۳ | `PatientPackageLedgerPage` — جدول دفتر | ⏳ | | -| ۳.۴ | ستون «مانده تجمعی» **محاسبه‌شده در UI**، نه ستون DB | ⏳ | ⭐ به کاربر ثابت می‌کند عدد از کجاست | -| ۳.۵ | ستون‌های دفتر: تاریخ، نوع، تغییر، مانده تجمعی، دلیل، ثبت‌کننده، نوبت | ⏳ | | -| ۳.۶ | پیام «اعتبار پکیج تمام شده؛ این نوبت نقدی محاسبه می‌شود» | ⏳ | | -| ۳.۷ | `DataTable` با skeleton و empty state | ⏳ | | -| ۳.۸ | سرویس‌ها با `SearchableSelect` | ⏳ | | -| ۳.۹ | `backTo`/`BackButton` روی زیرصفحه‌ها | ⏳ | | -| ۳.۱۰ | هیچ رنگ/شعاع hard-code | ⏳ | | -| ۳.۱۱ | دارک‌مود و حالت فشرده | ⏳ | | -| ۳.۱۲ | RTL و موبایل | ⏳ | | -| ۳.۱۳ | مبالغ با `formatRial` · تاریخ با `formatDate` | ⏳ | | -| ۳.۱۴ | وضعیت لیست در URL با `useUrlState` | ⏳ | | -| ۳.۱۵ | همهٔ رشته‌ها فارسی | ⏳ | | -| ۳.۱۶ | دکمهٔ `adjust` فقط برای نقش مدیر نمایش داده می‌شود | ⏳ | `FeatureGate`/بررسی نقش | +| ۳.۱ | `PackagesPage` با `PriceInput` و انتخاب سرویس | ✅ | + ورودی منوی تنظیمات | +| ۳.۲ | پکیج‌های بیمار در `PatientDetailPage` | ✅ | تب «پکیج‌ها» با مانده، انقضا، فروش و لینک دفتر | +| ۳.۳ | `PatientPackageLedgerPage` | ✅ | | +| ۳.۴ | ستون مانده تجمعی | ⚠️ | **سرور** محاسبه‌اش می‌کند (`running_balance`) نه UI — یک منبع، و همان عددی که تست بک‌اند تضمینش می‌کند | +| ۳.۵ | ستون‌های دفتر | ⚠️ | تاریخ، نوع، تغییر، مانده، دلیل هست؛ ستون‌های «ثبت‌کننده» و «نوبت» در پاسخ هستند ولی در جدول نمایش داده نمی‌شوند (عرض موبایل) | +| ۳.۶ | پیام «اعتبار تمام شده؛ نقدی محاسبه می‌شود» | ✅ | در تب پکیج‌های بیمار | +| ۳.۷ | `DataTable` با skeleton و empty state | ✅ | | +| ۳.۸ | سرویس‌ها با `SearchableSelect` | ✅ | هیچ `