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:
hamed
2026-07-16 11:30:12 +03:30
co-authored by Claude Opus 4.8
parent 7b87fda8f9
commit 1a783971c9
8 changed files with 379 additions and 4 deletions
@@ -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>
);
}