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:
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
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 AppointmentTurnCard, { type AppointmentCardData } from './AppointmentTurnCard';
|
||||
|
||||
const base: AppointmentCardData = {
|
||||
uuid: 'a1', starts_at: 1754000000, status: 'confirmed', version: 1,
|
||||
doctor_name: 'دکتر ژیلا فتحی', service_name: null,
|
||||
};
|
||||
|
||||
describe('AppointmentTurnCard', () => {
|
||||
it('renders title, date/time labels, doctor and the live status label', () => {
|
||||
renderWithProviders(<AppointmentTurnCard appointment={base} queryKey={['x']} />);
|
||||
expect(screen.getByText('نوبت')).toBeInTheDocument(); // no service → generic title
|
||||
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
|
||||
expect(screen.getByText('ساعت:')).toBeInTheDocument();
|
||||
expect(screen.getByText('پرسنل:')).toBeInTheDocument();
|
||||
expect(screen.getByText('دکتر ژیلا فتحی')).toBeInTheDocument();
|
||||
expect(screen.getByText('قطعی شده')).toBeInTheDocument(); // confirmed via STATUS_META
|
||||
});
|
||||
|
||||
it('falls back to «—» when the doctor is missing', () => {
|
||||
renderWithProviders(<AppointmentTurnCard appointment={{ ...base, doctor_name: null }} queryKey={['x']} />);
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers the service name as the title when present', () => {
|
||||
renderWithProviders(<AppointmentTurnCard appointment={{ ...base, service_name: 'لیزر' }} queryKey={['x']} />);
|
||||
expect(screen.getByText('لیزر')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { formatDate, formatTime } from '../lib/utils';
|
||||
import {
|
||||
FilesServiceSuccess, FilesServiceMore, CalendarD, ClockP, UserD, StatusGlobe,
|
||||
} from './icons/FilesServiceIcons';
|
||||
import AppointmentStatusDropdown from './ui/AppointmentStatusDropdown';
|
||||
|
||||
export interface AppointmentCardData {
|
||||
uuid: string;
|
||||
starts_at: number;
|
||||
status: string;
|
||||
version: number;
|
||||
doctor_name?: string | null;
|
||||
service_name?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* label/value row inside the turn card — mirrors tauri ServiceInfoRow
|
||||
* (separatedValues variant): icon+label on one side, value (optionally chip) on
|
||||
* the other.
|
||||
*/
|
||||
function InfoRow({ icon, label, value, chip = false, valueStyle }: {
|
||||
icon: ReactNode; label: string; value: ReactNode; chip?: boolean; valueStyle?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{icon}
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#616161' }}>{label}:</span>
|
||||
</div>
|
||||
<span
|
||||
className={chip ? 'dark:bg-[#404040] dark:text-[#D7D8ED]' : 'dark:text-[#D7D8ED]'}
|
||||
style={{
|
||||
fontSize: 12, color: '#525252', textAlign: 'left',
|
||||
...(chip ? { background: '#efefef', borderRadius: 8, padding: '2px 8px', color: '#2f2f2f' } : {}),
|
||||
...valueStyle,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A patient «نوبت» card — ported pixel-for-pixel from tauri
|
||||
* files/services/TurnsCard. The status uses the admin's live
|
||||
* AppointmentStatusDropdown (backed by PATCH /appointment/{uuid}/status)
|
||||
* instead of the tauri mock.
|
||||
*/
|
||||
export default function AppointmentTurnCard({ appointment, queryKey }: {
|
||||
appointment: AppointmentCardData;
|
||||
queryKey: unknown[];
|
||||
}) {
|
||||
const title = appointment.service_name || 'نوبت';
|
||||
return (
|
||||
<div
|
||||
className="bg-white dark:bg-[#222433] border border-[#EDEDED] dark:border-[#35343D]"
|
||||
style={{ width: 277, maxWidth: '100%', minHeight: 249, borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 10, boxShadow: '0 1px 6px rgba(15,23,42,0.06)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<FilesServiceSuccess />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3, minWidth: 0 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FilesServiceMore style={{ color: '#9CA3AF', flexShrink: 0 }} />
|
||||
</div>
|
||||
|
||||
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
|
||||
|
||||
{/* Middle: date & time chips */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<InfoRow icon={<CalendarD />} label="تاریخ" chip value={formatDate(appointment.starts_at)} />
|
||||
<InfoRow icon={<ClockP />} label="ساعت" chip value={formatTime(appointment.starts_at)} />
|
||||
</div>
|
||||
|
||||
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
|
||||
|
||||
{/* Bottom: personnel & status */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<InfoRow icon={<UserD size={19} />} label="پرسنل" value={appointment.doctor_name || '—'} valueStyle={{ fontWeight: 500 }} />
|
||||
<InfoRow
|
||||
icon={<StatusGlobe size={19} />}
|
||||
label="وضعیت"
|
||||
value={<AppointmentStatusDropdown uuid={appointment.uuid} currentStatus={appointment.status} version={appointment.version} queryKey={queryKey} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -192,3 +192,50 @@ export function TabBody({ color = '#616161', style }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Turn card info-row icons (tauri CalendarD / ClockP / UserD / status) ───── */
|
||||
|
||||
export function CalendarD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
|
||||
<path d="M8 2V5" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M16 2V5" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M3.5 9.08984H20.5" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 8.5V17C21 20 19.5 22 16 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15.6947 13.7002H15.7037" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15.6947 16.7002H15.7037" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.9955 13.7002H12.0045" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.9955 16.7002H12.0045" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.29431 13.7002H8.30329" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.29431 16.7002H8.30329" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClockP({ color = '#525252', size = 20, style }: IconProps & { size?: number }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
|
||||
<path d="M18.3337 10.0003C18.3337 14.6003 14.6003 18.3337 10.0003 18.3337C5.40033 18.3337 1.66699 14.6003 1.66699 10.0003C1.66699 5.40033 5.40033 1.66699 10.0003 1.66699C14.6003 1.66699 18.3337 5.40033 18.3337 10.0003Z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M13.0914 12.6505L10.5081 11.1088C10.0581 10.8421 9.69141 10.2005 9.69141 9.67546V6.25879" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserD({ color = '#616161', size = 20, style }: IconProps & { size?: number }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
|
||||
<path d="M12 12C14.7614 12 17 9.76142 17 7C17 4.23858 14.7614 2 12 2C9.23858 2 7 4.23858 7 7C7 9.76142 9.23858 12 12 12Z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M20.5899 22C20.5899 18.13 16.7399 15 11.9999 15C7.25991 15 3.40991 18.13 3.40991 22" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusGlobe({ color = '#616161', size = 20, style }: IconProps & { size?: number }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none" style={style}>
|
||||
<path d="M2.04166 12.4751C2.93332 15.3418 5.33333 17.5501 8.31666 18.1584" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M1.70834 9.14984C2.13334 4.9415 5.68334 1.6665 10 1.6665C14.3167 1.6665 17.8667 4.94984 18.2917 9.14984" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.675 18.1666C14.65 17.5583 17.0417 15.3749 17.95 12.5166" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-1
@@ -371,6 +371,7 @@ GET /api/v1/patient/{uuid}/appointments
|
||||
"starts_at": 1754000000,
|
||||
"ends_at": 1754001800,
|
||||
"status": "confirmed",
|
||||
"version": 1,
|
||||
"doctor_name": "دکتر ژیلا فتحی",
|
||||
"service_name": null,
|
||||
"price_rials": null,
|
||||
@@ -380,7 +381,7 @@ GET /api/v1/patient/{uuid}/appointments
|
||||
}
|
||||
```
|
||||
|
||||
`status` یکی از: `pending`، `confirmed`، `completed`، `cancelled_by_doctor`، `cancelled_by_user`، `no_show`، `expired`. فیلدهای `service_name`/`price_rials` فعلاً همیشه `null` هستند (نوبت خدمت/قیمت مستقل ندارد).
|
||||
`status` یکی از: `pending`، `confirmed`، `completed`، `cancelled_by_doctor`، `cancelled_by_user`، `no_show`، `expired`. فیلدهای `service_name`/`price_rials` فعلاً همیشه `null` هستند (نوبت خدمت/قیمت مستقل ندارد). `version` نسخهٔ خوشبینانهٔ (optimistic-lock) نوبت است و برای فراخوانی `PATCH /api/v1/appointment/{uuid}/status` لازم است.
|
||||
|
||||
**Errors:**
|
||||
|
||||
|
||||
@@ -830,6 +830,7 @@ class PatientController extends BaseController
|
||||
'starts_at' => $a->getSlotStart(),
|
||||
'ends_at' => $a->getSlotEnd(),
|
||||
'status' => $a->getStatus(),
|
||||
'version' => $a->getVersion(),
|
||||
'doctor_name' => $a->getDoctor()->getName(),
|
||||
'service_name' => null,
|
||||
'price_rials' => null,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* List Patient Appointments (تب نوبتها): shape (incl. version), ordering,
|
||||
* empty boundary, and ownership scoping.
|
||||
*/
|
||||
class PatientAppointmentsTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر ژیلا فتحی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $record];
|
||||
}
|
||||
|
||||
private function appointment(Doctor $doctor, PatientRecord $record, int $slotStart): Appointment
|
||||
{
|
||||
$appt = new Appointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
return $appt;
|
||||
}
|
||||
|
||||
public function testListReturnsShapeWithVersion(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->recordFor();
|
||||
$this->appointment($doctor, $record, 1_754_000_000);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $list['data']);
|
||||
|
||||
$row = $list['data'][0];
|
||||
foreach (['uuid', 'starts_at', 'ends_at', 'status', 'version', 'doctor_name'] as $key) {
|
||||
self::assertArrayHasKey($key, $row);
|
||||
}
|
||||
self::assertSame(1, $row['version']);
|
||||
self::assertSame('pending', $row['status']);
|
||||
self::assertSame('دکتر ژیلا فتحی', $row['doctor_name']);
|
||||
self::assertSame(1_754_000_000, $row['starts_at']);
|
||||
}
|
||||
|
||||
public function testSortedByStartDescending(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->recordFor();
|
||||
$this->appointment($doctor, $record, 1_754_000_000);
|
||||
$this->appointment($doctor, $record, 1_755_000_000);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
|
||||
self::assertCount(2, $list['data']);
|
||||
self::assertSame(1_755_000_000, $list['data'][0]['starts_at']);
|
||||
self::assertSame(1_754_000_000, $list['data'][1]['starts_at']);
|
||||
}
|
||||
|
||||
public function testEmptyWhenNoAppointments(): void
|
||||
{
|
||||
[$owner, , $record] = $this->recordFor();
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(0, $list['data']);
|
||||
}
|
||||
|
||||
public function testNotFoundForUnknownRecord(): void
|
||||
{
|
||||
[$owner] = $this->recordFor();
|
||||
$this->authJson('GET', '/api/v1/patient/00000000-0000-0000-0000-000000000000/appointments', $owner);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnershipScoped(): void
|
||||
{
|
||||
[, $doctor, $record] = $this->recordFor();
|
||||
$this->appointment($doctor, $record, 1_754_000_000);
|
||||
|
||||
[$other] = $this->recordFor();
|
||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user