- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
137 lines
5.6 KiB
TypeScript
137 lines
5.6 KiB
TypeScript
/**
|
|
* «لیست نوبتهای جدید» table — ported from clinic-pro-tauri
|
|
* `src/components/dashboard/list/` (CustomTable + DetailT + Status).
|
|
*
|
|
* Source data was static mock; here it is fed by the real dashboard API
|
|
* (`/api/v1/dashboard/{clinic,doctor}` → `today_appointments`) or, when a
|
|
* `queryKey` is supplied, by the filtered doctor list endpoint. Status is
|
|
* editable in place only when the row carries a `version` (optimistic lock)
|
|
* and the parent passes the query key to invalidate.
|
|
*/
|
|
import React from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
|
|
|
|
export interface ApptRow {
|
|
uuid: string;
|
|
patient_name: string | null;
|
|
patient_mobile?: string | null;
|
|
doctor_name?: string | null;
|
|
service_name?: string | null;
|
|
slot_start: number;
|
|
slot_end?: number | null;
|
|
status: string;
|
|
/** لازم برای قفل خوشبینانهٔ PATCH وضعیت؛ نبودنش یعنی وضعیت فقطخواندنی است. */
|
|
version?: number;
|
|
}
|
|
|
|
/**
|
|
* روزِ محلیِ نوبت (YYYY-MM-DD) برای دیپلینک «مشاهده» به همان تاریخ در لیست نوبتها.
|
|
* همان قراردادی که AppointmentDetailPage/AppointmentsPage استفاده میکنند.
|
|
*/
|
|
const isoDay = (ts?: number | null): string => {
|
|
if (!ts) return '';
|
|
const d = new Date(ts * 1000);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
|
|
function formatTime(ts?: number | null): string {
|
|
if (!ts) return '—';
|
|
return new Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000));
|
|
}
|
|
|
|
const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
|
|
|
|
interface Props {
|
|
rows: ApptRow[];
|
|
loading?: boolean;
|
|
/** کلید کوئری برای invalidate پس از تغییر وضعیت. بدون آن وضعیت فقط نمایش داده میشود. */
|
|
queryKey?: unknown[];
|
|
emptyText?: string;
|
|
}
|
|
|
|
export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Props) {
|
|
if (loading) {
|
|
return <div className="skeleton h-[180px] rounded-[8px]" />;
|
|
}
|
|
if (!rows.length) {
|
|
return (
|
|
<p className="text-center py-[32px] text-[13.5px] text-[#7E7E7E] dark:text-[#A1A1A1]">
|
|
{emptyText ?? 'نوبتی برای امروز ثبت نشده'}
|
|
</p>
|
|
);
|
|
}
|
|
return (
|
|
<div className="w-full overflow-x-auto rounded-[8px] border border-solid border-[#E7E7E7] dark:border-[#35343D]">
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr className="bg-[#EFEFEF] dark:bg-[#35343D]">
|
|
{HEAD.map((h, i) => (
|
|
<th
|
|
key={h}
|
|
className={`text-[#616161] dark:text-[#D7D8ED] text-[14px] font-normal py-[10px] px-[18px] whitespace-nowrap ${i === HEAD.length - 1 ? 'text-center' : 'text-start'}`}
|
|
>
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r, idx) => (
|
|
<tr
|
|
key={r.uuid}
|
|
className="border-b border-[#DBDBDB] dark:border-transparent hover:bg-[#f4f5fd] dark:hover:bg-[#2a2c3d] transition-colors"
|
|
>
|
|
<Cell>{new Intl.NumberFormat('fa-IR').format(idx + 1)}</Cell>
|
|
<Cell>{r.patient_name || '—'}</Cell>
|
|
<Cell>{r.patient_mobile ? <bdi dir="ltr">{r.patient_mobile}</bdi> : '—'}</Cell>
|
|
<Cell><bdi dir="ltr">{formatTime(r.slot_start)}</bdi></Cell>
|
|
<Cell><bdi dir="ltr">{formatTime(r.slot_end)}</bdi></Cell>
|
|
<Cell>{r.service_name || '—'}</Cell>
|
|
<Cell>{r.doctor_name || '—'}</Cell>
|
|
<Cell>
|
|
{queryKey && r.version != null
|
|
? <AppointmentStatusDropdown uuid={r.uuid} currentStatus={r.status} version={r.version} queryKey={queryKey} />
|
|
: <StatusPill status={r.status} />}
|
|
</Cell>
|
|
<td className="py-[10px] px-[18px] text-center">
|
|
<Link
|
|
to={isoDay(r.slot_start) ? `/admin/appointments?date=${isoDay(r.slot_start)}` : '/admin/appointments'}
|
|
className="text-[#5559ce] text-[12px] font-medium hover:underline whitespace-nowrap"
|
|
>
|
|
مشاهده
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** نمایش فقطخواندنی وضعیت — وقتی version یا queryKey در دسترس نیست. */
|
|
function StatusPill({ status }: { status: string }) {
|
|
const meta = STATUS_META[status] ?? { label: status, color: '#9ca3af' };
|
|
return (
|
|
<span
|
|
className="inline-flex items-center gap-[5px] px-[10px] py-[3px] rounded-full text-[12px] font-bold whitespace-nowrap"
|
|
style={{ background: `${meta.color}15`, border: `1.5px solid ${meta.color}30`, color: meta.color }}
|
|
>
|
|
<span className="w-[7px] h-[7px] rounded-full shrink-0" style={{ background: meta.color }} />
|
|
{meta.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** همهی سلولها text-start هستند تا دقیقاً زیر هدر همنامشان بنشینند. */
|
|
function Cell({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<td
|
|
className={`text-[#616161] dark:text-[#A1A1A1] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap text-start ${className}`}
|
|
>
|
|
{children}
|
|
</td>
|
|
);
|
|
}
|