feat(appointments): close the three remaining design gaps

1. شارژ کیف پول is now functional end-to-end. New owner-gated
   POST /api/v1/patient/{uuid}/wallet/charge creates a manual credit
   WalletTransaction (computed balance_after); the patient detail's wallet tab
   gains a top-up modal (PriceInput + description) and supports ?tab= deep
   links. The deposit sections of the create drawer, the edit page and the
   replace modal link to it via WalletChargeLink (record resolved by mobile).
2. جایگزینی نوبت now matches appointments-replace.pdf: patient search-or-new,
   بخش/سرویس/پرسنل selects prefilled from the appointment, deposit toggle +
   amount + charge link, read-only original date/time, status pick and notes —
   all through the general PATCH.
3. The confirmed-appointments table is paginated (20/page, client-side so the
   schedule view and doctor-tab derivation keep the whole day), resetting on
   date/doctor/filter changes. The page-local STATUS_META also adopts the
   design labels plus following_up/salon for the schedule cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-14 00:26:47 +03:30
co-authored by Claude Fable 5
parent 90e2894c86
commit 165ececab4
12 changed files with 439 additions and 41 deletions
@@ -85,15 +85,40 @@ describe('AppointmentActionsMenu (عملیات نوبت)', () => {
})));
});
it('replace modal swaps the patient on the slot', async () => {
it('replace modal swaps the patient and keeps the slot locked', async () => {
openMenu();
fireEvent.click(screen.getByText('جایگزینی نوبت'));
expect(await screen.findByPlaceholderText('نام و نام خانوادگی مراجعه کننده')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'ساغر صابری' } });
expect(await screen.findByPlaceholderText('نام و نام خانوادگی')).toBeInTheDocument();
// the original slot is shown read-only
expect(screen.getByDisplayValue('2024-12-31')).toBeDisabled();
expect(screen.getByDisplayValue('09:00')).toBeDisabled();
// prefilled from the appointment's current specs
expect((screen.getByLabelText('وضعیت') as HTMLSelectElement).value).toBe('confirmed');
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی'), { target: { value: 'ساغر صابری' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس'), { target: { value: '09356619438' } });
fireEvent.click(screen.getByText('ثبت نوبت'));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
patient_name: 'ساغر صابری', patient_mobile: '09356619438', version: 3,
patient_name: 'ساغر صابری', patient_mobile: '09356619438',
service_section_uuid: 's1', service_item_uuid: 'i1', staff_uuid: 'st1',
version: 3,
})));
});
it('replace modal picks an existing patient from the record search', async () => {
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [
{ uuid: 'rec9', user_name: 'پریسا همتی', user_mobile: '09120009999' },
] });
return Promise.resolve({ success: true, data: [] });
});
openMenu();
fireEvent.click(screen.getByText('جایگزینی نوبت'));
fireEvent.change(await screen.findByPlaceholderText('جستجوی نام، شماره تماس، شماره پرونده...'), { target: { value: 'پریسا' } });
fireEvent.click(await screen.findByText('پریسا همتی'));
fireEvent.click(screen.getByText('ثبت نوبت'));
await waitFor(() => expect(patch).toHaveBeenCalledWith('/api/v1/appointment/ap1', expect.objectContaining({
patient_name: 'پریسا همتی', patient_mobile: '09120009999',
})));
});
});
+155 -10
View File
@@ -14,6 +14,7 @@ import type { Appointment } from '../types';
import { formatRial } from '../lib/utils';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import PriceInput from './ui/PriceInput';
import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown';
/** Row actions for the appointments table (Figma عملیات menu). */
@@ -26,11 +27,32 @@ const toEpoch = (isoDate: string, time: string) =>
* Resolve the patient-record uuid behind an appointment via the patient list
* search (mobile is unique per user). Returns null when no record exists yet.
*/
async function findRecordUuid(mobile: string): Promise<string | null> {
export async function findRecordUuid(mobile: string): Promise<string | null> {
const res: any = await api.get(`/api/v1/patient?search=${encodeURIComponent(mobile)}&limit=1`);
return res?.data?.[0]?.uuid ?? null;
}
/**
* «شارژ کیف پول» accent link (appointment create/edit forms) — deep-links the
* patient's wallet tab, where the manual top-up modal lives.
*/
export function WalletChargeLink({ mobile }: { mobile?: string }) {
const navigate = useNavigate();
const go = async () => {
if (!mobile || mobile.trim().length < 10) { toast.error('ابتدا شماره تماس مراجعه کننده را وارد کنید'); return; }
try {
const recordUuid = await findRecordUuid(mobile.trim());
if (!recordUuid) { toast.error('پرونده‌ای برای این بیمار یافت نشد'); return; }
navigate(`/admin/patients/${recordUuid}?tab=wallet`);
} catch { toast.error('خطا در یافتن پرونده بیمار'); }
};
return (
<button type="button" className="btn sm ghost" style={{ color: 'var(--accent)' }} onClick={go}>
شارژ کیف پول
</button>
);
}
export default function AppointmentActionsMenu({ appointment, queryKey }: {
appointment: Appointment; queryKey: unknown[];
}) {
@@ -262,44 +284,167 @@ export function TransferReserveModal({ appointment: a, queryKey, onClose }: {
// ─────────────────────────────────────────────────────────────────────────────
// جایگزینی نوبت — put a different patient into the same slot
// (appointments-replace.pdf: patient search-or-new, بخش/سرویس, deposit,
// locked date/time, پرسنل, وضعیت, توضیحات)
// ─────────────────────────────────────────────────────────────────────────────
interface PickerOption { uuid: string; name?: string; full_name?: string }
interface PickedPatient { uuid: string; user_name?: string; user_mobile?: string }
export function ReplaceAppointmentModal({ appointment: a, queryKey, onClose }: {
appointment: Appointment; queryKey: unknown[]; onClose: () => void;
}) {
const qc = useQueryClient();
// patient: search an existing record or enter a new person
const [patientSearch, setPatientSearch] = useState('');
const [picked, setPicked] = useState<PickedPatient | null>(null);
const [name, setName] = useState('');
const [mobile, setMobile] = useState('');
const patientsQ = useQuery<ApiResponse<PickedPatient[]>>({
queryKey: ['replace-patients', patientSearch],
queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`),
enabled: patientSearch.trim().length >= 2,
});
// service specs + staff + status
const [sectionUuid, setSectionUuid] = useState(a.service_section?.uuid ?? '');
const [itemUuid, setItemUuid] = useState(a.service_item?.uuid ?? '');
const [staffUuid, setStaffUuid] = useState(a.staff?.uuid ?? '');
const [status, setStatus] = useState(a.status);
const sectionsQ = useQuery<ApiResponse<PickerOption[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
const itemsQ = useQuery<ApiResponse<PickerOption[]>>({
queryKey: ['service-items', sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const staffQ = useQuery<ApiResponse<PickerOption[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
// deposit
const [depositRequired, setDepositRequired] = useState(!!a.deposit_required);
const [depositRials, setDepositRials] = useState(a.deposit_amount_rials ?? 0);
const [note, setNote] = useState('');
const effectiveName = picked?.user_name || name.trim();
const effectiveMobile = picked?.user_mobile || mobile.trim();
const replace = useMutation({
mutationFn: () => api.patch(`/api/v1/appointment/${a.uuid}`, {
patient_name: name.trim(), patient_mobile: mobile.trim(),
patient_name: effectiveName,
patient_mobile: effectiveMobile,
service_section_uuid: sectionUuid,
service_item_uuid: itemUuid,
staff_uuid: staffUuid,
deposit_required: depositRequired,
deposit_amount_rials: depositRequired ? depositRials : null,
...(note.trim() ? { note: note.trim() } : {}),
...(status !== a.status ? { status } : {}),
version: a.version,
}),
onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success('نوبت جایگزین شد'); onClose(); },
onError: (e: any) => toast.error(e.message || 'خطا در جایگزینی نوبت'),
});
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const;
const lockedField = { margin: '6px 0 12px', opacity: 0.6 } as const;
const patients = patientsQ.data?.data ?? [];
const statusOptions: [string, string][] = [
['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'],
['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'],
];
return (
<Modal open title="جایگزینی نوبت" onClose={onClose}>
<div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>نام و نام خانوادگی</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
<label style={label}>انتخاب مراجعه کننده</label>
<div className="field" style={{ margin: '6px 0 8px' }}>
<input value={picked ? `${picked.user_name ?? ''}${picked.user_mobile ?? ''}` : patientSearch}
onChange={e => { setPicked(null); setPatientSearch(e.target.value); }}
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
</div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>شماره تماس</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" />
{!picked && patients.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden' }}>
{patients.map(p => (
<button key={p.uuid} onClick={() => setPicked(p)} style={{
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
</button>
))}
</div>
)}
{picked === null && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
<div className="field"><input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی" /></div>
<div className="field"><input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس" dir="ltr" /></div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
<div>
<label style={label}>بخش</label>
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
<option value="">انتخاب بخش</option>
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
<div>
<label style={label}>سرویس</label>
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
<option value="">انتخاب زیر بخش</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
</div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>توضیحات</label>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 12 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
بیعانه مورد نیاز است.
</label>
{depositRequired && <WalletChargeLink mobile={effectiveMobile} />}
</div>
{depositRequired && (
<div style={{ marginBottom: 12 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
</div>
)}
{/* the replacement keeps the original slot — date/time locked */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={label}>انتخاب تاریخ</label>
<div className="field" style={lockedField}><input value={a.appointment_date} disabled dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={lockedField}><input value={a.appointment_time} disabled dir="ltr" /></div>
</div>
</div>
<label style={label}>انتخاب پرسنل</label>
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 12px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
<option value="">انتخاب...</option>
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
</select>
<label style={label}>انتخاب وضعیت</label>
<select aria-label="وضعیت" style={{ ...sel, margin: '6px 0 12px' }} value={status} onChange={e => setStatus(e.target.value as Appointment['status'])}>
{statusOptions.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<label style={label}>توضیحات</label>
<div className="field" style={{ height: 'auto', margin: '6px 0 16px' }}>
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} placeholder="توضیحات..."
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
</div>
<button className="btn primary" style={{ width: '100%' }}
disabled={name.trim().length < 2 || mobile.trim().length < 10 || replace.isPending}
disabled={effectiveName.length < 2 || effectiveMobile.length < 10 || replace.isPending}
onClick={() => replace.mutate()}>
ثبت نوبت
</button>
@@ -81,6 +81,13 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => {
})));
});
it('deposit toggle reveals the amount field and the wallet-charge link', async () => {
renderDrawer();
fireEvent.click(screen.getByLabelText('بیعانه مورد نیاز است.'));
expect(await screen.findByText('مبلغ بیعانه (تومان)')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /شارژ کیف پول/ })).toBeInTheDocument();
});
it('reserve mode hides time fields and posts a day-level entry', async () => {
renderWithProviders(
<NewAppointmentDrawer doctorUuid="d1" defaultDate="2026-08-01" queryKey={['r']} onClose={() => {}} isReserve />,
@@ -7,6 +7,7 @@ import type { ApiResponse } from '../lib/api';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import PriceInput from './ui/PriceInput';
import { WalletChargeLink } from './AppointmentActions';
interface Option { uuid: string; name?: string; full_name?: string }
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string }
@@ -208,11 +209,14 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
</label>
</div>
{depositRequired && (
<div style={{ marginBottom: 12 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}>
<PriceInput value={depositRials} onChange={setDepositRials} />
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}>
<PriceInput value={depositRials} onChange={setDepositRials} />
</div>
</div>
<WalletChargeLink mobile={effectiveMobile} />
</div>
)}
</>
+10 -4
View File
@@ -7,12 +7,15 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
import { WalletChargeLink } from '../components/AppointmentActions';
interface Option { uuid: string; name?: string; full_name?: string }
interface AppointmentDetail {
uuid: string; slot_start: number; slot_end: number; status: string; version: number;
note?: string | null;
patient_mobile?: string | null;
user?: { uuid: string; mobile: string } | null;
deposit_required?: boolean; deposit_amount_rials?: number | null;
service_section?: Option | null; service_item?: Option | null; staff?: Option | null;
}
@@ -162,10 +165,13 @@ export default function AppointmentEditPage() {
بیعانه مورد نیاز است.
</label>
{depositRequired && (
<div style={{ minWidth: 220 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
</div>
<>
<div style={{ minWidth: 220 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}><PriceInput value={depositRials} onChange={setDepositRials} /></div>
</div>
<WalletChargeLink mobile={a.patient_mobile || a.user?.mobile || ''} />
</>
)}
</div>
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } 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 {},
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import AppointmentsPage from './AppointmentsPage';
const get = api.get as ReturnType<typeof vi.fn>;
const mkRow = (i: number) => ({
uuid: `ap${i}`, patient_name: `بیمار ${i}`, patient_mobile: '09120000000',
doctor_uuid: 'doc1', doctor_name: 'دکتر احمدی',
slot_start: 1735639200 + i * 1800, slot_end: 1735641000 + i * 1800,
appointment_date: '2024-12-31', appointment_time: '09:00', end_time: '09:30',
status: 'confirmed', version: 1, created_at: '',
});
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doc1' } as any);
get.mockImplementation((url: string) => {
if (url.includes('/my/appointments/today-stats')) return Promise.resolve({ success: true, data: { total: 25, completed: 0, waiting: 0, cancelled: 0 } });
if (url.includes('/my/appointments')) return Promise.resolve({
success: true,
data: Array.from({ length: 25 }, (_, i) => mkRow(i + 1)),
meta: { totalRecords: 25, totalPages: 1, currentPage: 1 },
});
return Promise.resolve({ success: true, data: [] });
});
});
describe('AppointmentsPage — table pagination', () => {
it('shows 20 rows per page and navigates to the rest', async () => {
renderWithProviders(<AppointmentsPage />);
fireEvent.click(await screen.findByText('نمایش جدولی'));
expect(await screen.findByText('بیمار 1')).toBeInTheDocument();
expect(screen.getByText('بیمار 20')).toBeInTheDocument();
expect(screen.queryByText('بیمار 21')).toBeNull();
// page 2 → remaining 5 rows
fireEvent.click(screen.getByRole('button', { name: '۲' }));
expect(await screen.findByText('بیمار 21')).toBeInTheDocument();
expect(screen.queryByText('بیمار 1')).toBeNull();
});
});
+32 -14
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon,
@@ -11,6 +11,7 @@ import type { Appointment } from '../types';
import { formatDate, toGregorianDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
import Pagination from '../components/ui/Pagination';
import AppointmentActionsMenu from '../components/AppointmentActions';
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
@@ -25,14 +26,17 @@ const EMPTY_ARR: Appointment[] = [];
// Status config
// ─────────────────────────────────────────────────────────────────────────────
// Labels follow the Figma نوبت‌ها design; keep in sync with AppointmentStatusDropdown.
const STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
pending: { label: 'رزرو شده', color: 'var(--info)', bg: 'var(--info-bg)' },
confirmed: { label: 'تأیید شده', color: 'var(--success)', bg: 'var(--success-bg)' },
completed: { label: 'تکمیل شده', color: 'var(--success)', bg: 'var(--success-bg)' },
cancelled_by_doctor: { label: 'لغو پزشک', color: 'var(--danger)', bg: 'var(--danger-bg)' },
cancelled_by_user: { label: 'لغو بیمار', color: 'var(--danger)', bg: 'var(--danger-bg)' },
no_show: { label: 'غیبت', color: 'var(--text-3)', bg: 'var(--surface-2)' },
expired: { label: 'منقضی', color: 'var(--text-3)', bg: 'var(--surface-2)' },
pending: { label: 'ثبت شده', color: 'var(--info)', bg: 'var(--info-bg)' },
confirmed: { label: 'قطعی شده', color: 'var(--success)', bg: 'var(--success-bg)' },
following_up: { label: 'در حال پیگیری', color: 'var(--warning)', bg: 'var(--warning-bg)' },
salon: { label: 'سالن', color: 'var(--violet)', bg: 'var(--violet-bg)' },
completed: { label: 'ویزیت شده', color: 'var(--success)', bg: 'var(--success-bg)' },
cancelled_by_doctor: { label: 'لغو شده', color: 'var(--danger)', bg: 'var(--danger-bg)' },
cancelled_by_user: { label: 'لغو توسط بیمار', color: 'var(--danger)', bg: 'var(--danger-bg)' },
no_show: { label: 'غیبت', color: 'var(--text-3)', bg: 'var(--surface-2)' },
expired: { label: 'منقضی', color: 'var(--text-3)', bg: 'var(--surface-2)' },
};
function statusMeta(s: string) {
@@ -574,6 +578,13 @@ export default function AppointmentsPage() {
const filteredAppointments = applyAppointmentFilters(appointments, filters);
const filtersActive = filters !== EMPTY_FILTERS && JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS);
// Table pagination is client-side: the day's full list stays loaded because
// the schedule view and doctor-tab derivation need every row.
const TABLE_PAGE_SIZE = 20;
const [tablePage, setTablePage] = useState(1);
useEffect(() => { setTablePage(1); }, [selectedDate, selectedDoctorUuid, filters]);
const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE);
// ── Clinic: load doctors from clinic profile (not derived from appointments)
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
queryKey: ['clinic-doctors', dbUuid],
@@ -782,12 +793,19 @@ export default function AppointmentsPage() {
{/* Content */}
<div style={{ padding: 16 }}>
{viewMode === 'table' ? (
<TableView
items={filteredAppointments}
loading={apptQuery.isLoading}
queryKey={apptQueryKey}
showDoctor={showDoctorCol}
/>
<>
<TableView
items={pagedAppointments}
loading={apptQuery.isLoading}
queryKey={apptQueryKey}
showDoctor={showDoctorCol}
/>
{filteredAppointments.length > TABLE_PAGE_SIZE && (
<div style={{ marginTop: 14 }}>
<Pagination page={tablePage} total={filteredAppointments.length} limit={TABLE_PAGE_SIZE} onPageChange={setTablePage} />
</div>
)}
</>
) : (
<>
{bookingHint && !isRepresentation && (
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { renderWithProviders } from '../test/utils';
@@ -109,6 +109,36 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
expect(await screen.findByText('شارژ')).toBeInTheDocument();
});
it('opens the wallet tab directly via ?tab=wallet and offers a top-up', async () => {
renderWithProviders(
<Routes><Route path="/admin/patients/:uuid" element={<PatientDetailPage />} /></Routes>,
{ route: '/admin/patients/r1?tab=wallet' },
);
expect(await screen.findByText('موجودی کیف پول')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /شارژ کیف پول/ }));
expect(await screen.findByText('مبلغ شارژ (تومان)')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ثبت شارژ' })).toBeDisabled();
});
it('posts the manual wallet charge', async () => {
const post = api.post as ReturnType<typeof vi.fn>;
post.mockResolvedValue({ success: true, data: { balance_rials: 800000, transaction: {} } });
renderWithProviders(
<Routes><Route path="/admin/patients/:uuid" element={<PatientDetailPage />} /></Routes>,
{ route: '/admin/patients/r1?tab=wallet' },
);
await screen.findByText('موجودی کیف پول');
fireEvent.click(screen.getByRole('button', { name: /شارژ کیف پول/ }));
fireEvent.change(await screen.findByPlaceholderText('مثلاً: بیعانه نوبت'), { target: { value: 'بیعانه' } });
// PriceInput displays toman; typing 30,000 toman = 300,000 rials
const priceInput = screen.getByText('مبلغ شارژ (تومان)').parentElement!.querySelector('input')!;
fireEvent.change(priceInput, { target: { value: '30000' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت شارژ' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/patient/r1/wallet/charge', expect.objectContaining({
description: 'بیعانه',
})));
});
it('renders the messages tab with a send box', async () => {
renderDetail();
await screen.findByText('ساغر صابری');
+46 -3
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, Link } from 'react-router-dom';
import { useParams, useSearchParams, Link } from 'react-router-dom';
import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
@@ -17,6 +17,7 @@ import { formatDate, formatRial } from '../lib/utils';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
@@ -58,7 +59,12 @@ function InfoRow({ label, value }: { label: string; value?: string | null }) {
/** پرونده — the tabbed patient case-file (Figma "جزئیات پرونده"). */
export default function PatientDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const [tab, setTab] = useState<TabKey>('services');
// ?tab=wallet etc. lets other pages (e.g. appointment forms) deep-link a tab
const [searchParams] = useSearchParams();
const requested = searchParams.get('tab') as TabKey | null;
const [tab, setTab] = useState<TabKey>(
requested && TABS.some((t) => t.key === requested) ? requested : 'services',
);
const { data, isLoading } = useQuery<ApiResponse<PatientRecord>>({
queryKey: ['patient', uuid],
@@ -517,13 +523,32 @@ function CallCenterTab({ uuid }: { uuid: string }) {
interface WalletTxn { uuid: string; amount_rials: number; type: string; description?: string | null; balance_after: number; created_at: number }
/** کیف پول — patient wallet balance card + recent-transaction ledger. */
/** کیف پول — patient wallet balance card + manual top-up + recent-transaction ledger. */
function WalletTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const [chargeOpen, setChargeOpen] = useState(false);
const [amountRials, setAmountRials] = useState(0);
const [description, setDescription] = useState('');
const { data, isLoading } = useQuery<ApiResponse<{ balance_rials: number; recent_transactions: WalletTxn[] }>>({
queryKey: ['patient-wallet', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/wallet`),
enabled: !!uuid,
});
const charge = useMutation({
mutationFn: () => api.post(`/api/v1/patient/${uuid}/wallet/charge`, {
amount_rials: amountRials,
...(description.trim() ? { description: description.trim() } : {}),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] });
toast.success('کیف پول شارژ شد');
setChargeOpen(false); setAmountRials(0); setDescription('');
},
onError: (e: any) => toast.error(e.message || 'خطا در شارژ کیف پول'),
});
if (isLoading) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
const balance = data?.data?.balance_rials ?? 0;
const txns = data?.data?.recent_transactions ?? [];
@@ -532,7 +557,25 @@ function WalletTab({ uuid }: { uuid: string }) {
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20, marginBottom: 16, maxWidth: 320 }}>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>موجودی کیف پول</div>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(balance)}</div>
<button className="btn sm" style={{ marginTop: 12, color: 'var(--accent)', border: '1px solid var(--accent)', background: 'var(--accent-bg)' }}
onClick={() => setChargeOpen(true)}>
<PlusIcon style={{ width: 14 }} /> شارژ کیف پول
</button>
</div>
<Modal open={chargeOpen} title="شارژ کیف پول" onClose={() => setChargeOpen(false)}>
<div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>مبلغ شارژ (تومان)</label>
<div style={{ margin: '6px 0 12px' }}><PriceInput value={amountRials} onChange={setAmountRials} /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>توضیحات</label>
<div className="field" style={{ margin: '6px 0 16px' }}>
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="مثلاً: بیعانه نوبت" />
</div>
<button className="btn primary" style={{ width: '100%' }} disabled={amountRials <= 0 || charge.isPending} onClick={() => charge.mutate()}>
ثبت شارژ
</button>
</div>
</Modal>
{txns.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تراکنشی ثبت نشده است</div>
) : (
+7
View File
@@ -578,6 +578,13 @@ Response: `{ success, data: [{ uuid, order_id, amount_rials, status, gateway, ty
موجودی + ۱۰ تراکنش اخیر (تب کیف‌پول). `balance_rials` = مجموع credit debit.
Response: `{ success, data: { balance_rials, recent_transactions: [{ uuid, amount_rials, type, description, balance_after, created_at }] } }`
### POST `/api/v1/patient/{uuid}/wallet/charge`
شارژ دستی کیف‌پول (مثلاً بیعانهٔ حضوری). یک تراکنش `credit` برای کاربرِ صاحب رکورد می‌سازد.
```json
{ "amount_rials": 300000, "description": "بیعانه نوبت (اختیاری، پیش‌فرض «شارژ کیف پول»)" }
```
`amount_rials` باید > 0 باشد وگرنه `422`. Response `201`: `{ success, data: { transaction, balance_rials } }`
### GET `/api/v1/patient/{uuid}/wallet/transactions`
دفترِ کاملِ تراکنش‌های کیف‌پول (paginated). Query: `page`, `limit` (≤100).
Response: `{ success, data: [{ uuid, amount_rials, type, description, balance_after, created_at }], meta: { totalRecords, totalPages, currentPage } }`
@@ -134,6 +134,40 @@ class PatientController extends BaseController
return $this->paginated($txns, $this->walletRepo->countByUser($patient), $page, $limit);
}
/**
* Manual wallet top-up (شارژ کیف پول) — e.g. a deposit taken at the desk.
* Creates a credit WalletTransaction for the record's owner User; balance
* is derived (credit debit), so balance_after is computed here.
*/
#[Route('/api/v1/patient/{uuid}/wallet/charge', methods: ['POST'])]
public function chargeWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$amount = (int) ($data['amount_rials'] ?? 0);
if ($amount <= 0) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ شارژ باید بزرگ‌تر از صفر باشد', 422, 'amount_rials');
}
$patient = $record->getUser();
$balance = $this->settlementRepo->getWalletBalance($patient) + $amount;
$txn = new \App\Settlement\Entity\WalletTransaction($patient, $amount, 'credit', $balance);
$description = trim((string) ($data['description'] ?? ''));
$txn->setDescription($description !== '' ? $description : 'شارژ کیف پول');
$this->walletRepo->save($txn);
return $this->success([
'transaction' => $txn->toArray(),
'balance_rials' => $balance,
], 201);
}
// ── Call center (کال سنتر) ─────────────────────────────────────────────────
/** List the patient's call log (newest first, optional ?outcome=success|missed). */
+29 -1
View File
@@ -74,14 +74,42 @@ class PatientFinancialsTest extends ApiTestCase
self::assertSame(1, $res['meta']['totalRecords']);
}
public function testChargeWalletCreatesCreditAndReturnsBalance(): void
{
[$owner, $record, $patient] = $this->recordFor();
$this->em->persist(new WalletTransaction($patient, 200000, 'credit', 200000));
$this->em->flush();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, [
'amount_rials' => 300000, 'description' => 'بیعانه نوبت',
]);
self::assertSame(201, $this->responseCode());
self::assertSame(500000, $res['data']['balance_rials']);
self::assertSame('credit', $res['data']['transaction']['type']);
self::assertSame('بیعانه نوبت', $res['data']['transaction']['description']);
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
self::assertSame(500000, $wallet['data']['balance_rials']);
}
public function testChargeWalletRejectsNonPositiveAmount(): void
{
[$owner, $record] = $this->recordFor();
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, ['amount_rials' => 0]);
self::assertSame(422, $this->responseCode());
}
public function testFinancialsAreOwnershipScoped(): void
{
[, $record] = $this->recordFor();
[$other] = $this->recordFor();
// A different owner cannot read this record's finances.
// A different owner cannot read this record's finances (or charge them).
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/payments', $other);
self::assertSame(404, $this->responseCode());
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $other, ['amount_rials' => 1000]);
self::assertSame(404, $this->responseCode());
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $other);
self::assertSame(404, $this->responseCode());
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $other);