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
+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>
) : (