feat: port نوبتها (appointments) tab from tauri to patient detail page
Replace the placeholder row list on the patient detail «نوبتها» tab with the
card-grid design ported pixel-for-pixel from clinic-pro-tauri TurnsSection:
- New AppointmentTurnCard mirrors tauri TurnsCard (success icon, title, date/time
chips, personnel, status). Status uses the live AppointmentStatusDropdown
instead of the tauri mock.
- New AppointmentsTab in PatientDetailPage: sort/filter toolbar (client-side) +
reserve/new buttons linking to existing /admin/appointments pages + card grid.
- Add CalendarD/ClockP/UserD/StatusGlobe icons (verbatim from tauri).
- Backend: expose version on GET /patient/{uuid}/appointments so the status
dropdown can optimistic-lock. No new endpoint.
- Tests: PatientAppointmentsTest (shape/version/order/empty/ownership) +
AppointmentTurnCard + tab data/empty cases. docs/api/patient.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -169,4 +169,34 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
||||
expect(await screen.findByPlaceholderText('متن پیام...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'ارسال' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders appointment turn cards + toolbar on the نوبتها tab', async () => {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
|
||||
uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001', created_at: 1700000000, profile: null,
|
||||
} });
|
||||
if (url === '/api/v1/patient/r1/appointments') return Promise.resolve({ success: true, data: [
|
||||
{ uuid: 'a1', starts_at: 1754000000, ends_at: 1754001800, status: 'confirmed', version: 1, doctor_name: 'دکتر راد', service_name: null },
|
||||
] });
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
renderDetail();
|
||||
await loaded();
|
||||
fireEvent.click(screen.getByText('نوبتها'));
|
||||
// card: generic title + doctor + live status label (confirmed → قطعی شده)
|
||||
expect(await screen.findByText('دکتر راد')).toBeInTheDocument();
|
||||
expect(screen.getByText('قطعی شده')).toBeInTheDocument();
|
||||
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
|
||||
expect(screen.getByText('ساعت:')).toBeInTheDocument();
|
||||
// toolbar buttons link to the existing appointment pages
|
||||
expect(screen.getByRole('link', { name: /نوبت رزرو/ })).toHaveAttribute('href', '/admin/appointments/reserve');
|
||||
expect(screen.getByRole('link', { name: /نوبت جدید/ })).toHaveAttribute('href', '/admin/appointments/new');
|
||||
});
|
||||
|
||||
it('shows the empty state on the نوبتها tab when there are no appointments', async () => {
|
||||
renderDetail();
|
||||
await loaded();
|
||||
fireEvent.click(screen.getByText('نوبتها'));
|
||||
expect(await screen.findByText('نوبتی ثبت نشده است')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useSearchParams, Link } from 'react-router-dom';
|
||||
import {
|
||||
@@ -20,7 +20,9 @@ import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import PriceInput from '../components/ui/PriceInput';
|
||||
import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner';
|
||||
import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard';
|
||||
import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard';
|
||||
import InvoiceSummaryModal from '../components/InvoiceSummaryModal';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons';
|
||||
import {
|
||||
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
|
||||
@@ -231,8 +233,7 @@ export default function PatientDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
) : tab === 'appointments' ? (
|
||||
<TabList q={appointmentsQ} emptyLabel="نوبتی ثبت نشده است"
|
||||
row={(a) => ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} />
|
||||
<AppointmentsTab uuid={uuid!} q={appointmentsQ} />
|
||||
) : tab === 'payments' ? (
|
||||
<TabList q={paymentsQ} emptyLabel="پرداختی ثبت نشده است"
|
||||
row={(p) => ({ title: formatRial(p.amount_rials), meta: [p.created_at ? formatDate(p.created_at) : '', p.gateway].filter(Boolean).join(' · '), badge: PAYMENT_STATUS[p.status] || p.status })} />
|
||||
@@ -688,6 +689,73 @@ function WalletTab({ uuid }: { uuid: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const APPT_SORT_OPTS = [
|
||||
{ value: 'newest', label: 'جدیدترین' },
|
||||
{ value: 'oldest', label: 'قدیمیترین' },
|
||||
{ value: 'reserved', label: 'رزرو شده' },
|
||||
{ value: 'done', label: 'انجام شده' },
|
||||
{ value: 'cancelled', label: 'لغو شده' },
|
||||
];
|
||||
|
||||
const CANCELLED_STATUSES = ['cancelled_by_doctor', 'cancelled_by_user', 'no_show', 'expired'];
|
||||
|
||||
/**
|
||||
* نوبتها — the appointments tab, ported from tauri TurnsSection: a sort/filter
|
||||
* toolbar + reserve/new buttons, over a grid of AppointmentTurnCard. Sorting and
|
||||
* filtering are client-side over the already-fetched list (tauri leaves them inert).
|
||||
*/
|
||||
function AppointmentsTab({ uuid, q }: {
|
||||
uuid: string;
|
||||
q: { data?: ApiResponse<AppointmentCardData[]>; isLoading: boolean };
|
||||
}) {
|
||||
const [sort, setSort] = useState('newest');
|
||||
const items = q.data?.data ?? [];
|
||||
const queryKey = ['patient-appointments', uuid];
|
||||
|
||||
const shown = useMemo(() => {
|
||||
let list = [...items];
|
||||
if (sort === 'reserved') list = list.filter((a) => a.status !== 'completed' && !CANCELLED_STATUSES.includes(a.status));
|
||||
else if (sort === 'done') list = list.filter((a) => a.status === 'completed');
|
||||
else if (sort === 'cancelled') list = list.filter((a) => CANCELLED_STATUSES.includes(a.status));
|
||||
list.sort((a, b) => (sort === 'oldest' ? a.starts_at - b.starts_at : b.starts_at - a.starts_at));
|
||||
return list;
|
||||
}, [items, sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* toolbar: sort + filter (right of RTL) · reserve/new buttons (left) */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 320, maxWidth: '100%' }}>
|
||||
<SearchableSelect options={APPT_SORT_OPTS} value={sort} onChange={(v) => setSort(String(v ?? 'newest'))} height={48} />
|
||||
</div>
|
||||
<button type="button" aria-label="فیلتر" className="flex items-center justify-center rounded-[4px] cursor-pointer" style={{ width: 62, height: 48, border: '1px solid #5559ce', background: 'transparent' }}>
|
||||
<TurnsFilter color="#5559ce" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Link to="/admin/appointments/reserve" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, border: '1px solid #5559ce', color: '#5559ce', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
<AddTurn color="#5559ce" /> نوبت رزرو
|
||||
</Link>
|
||||
<Link to="/admin/appointments/new" className="flex items-center justify-center gap-2 rounded-[8px]" style={{ height: 48, minWidth: 137, background: '#5559ce', color: '#fff', textDecoration: 'none', padding: '0 16px', fontSize: 14 }}>
|
||||
<AddTurn color="#fff" /> نوبت جدید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{q.isLoading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : shown.length === 0 ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>نوبتی ثبت نشده است</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
|
||||
{shown.map((a) => <AppointmentTurnCard key={a.uuid} appointment={a} queryKey={queryKey} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabList({ q, emptyLabel, row }: {
|
||||
q: { data?: ApiResponse<any[]>; isLoading: boolean };
|
||||
emptyLabel: string;
|
||||
|
||||
Reference in New Issue
Block a user