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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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('ساغر صابری');
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user