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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user