Files
clinicpro/assets/admin/components/dashboard/NewAppointmentsTable.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

174 lines
7.2 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, { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import AppointmentStatusDropdown, { STATUS_META } from '../ui/AppointmentStatusDropdown';
import { findRecordUuid } from '../AppointmentActions';
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-[var(--text-2)]">
{emptyText ?? 'نوبتی برای امروز ثبت نشده'}
</p>
);
}
return (
<div className="w-full overflow-x-auto rounded-[8px] border border-solid border-[var(--border)]">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[var(--surface-2)]">
{HEAD.map((h, i) => (
<th
key={h}
className={`text-[var(--text-2)] dark:text-[var(--text)] 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-[var(--border-2)] dark:border-transparent hover:bg-[var(--primary-soft)] dark:hover:bg-[var(--surface-2)] 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">
<ViewLink row={r} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* وضعیت‌هایی که نوبت از مرحلهٔ «قطعی‌شدن» رد شده — در این‌ها پروندهٔ بیمار حتماً
* ساخته شده است (ساخت پرونده اثر جانبیِ قطعی‌کردن است).
*/
const CONFIRMED_STATUSES = ['confirmed', 'following_up', 'salon', 'completed', 'no_show'];
/**
* «مشاهده» — نوبتِ قطعی‌شده به **پروندهٔ بیمار** می‌رود (چون از لحظهٔ قطعی‌شدن پرونده
* دارد)، بقیه به لیست نوبت‌های همان روز. uuid پرونده روی ردیف نیست، پس با موبایلِ
* بیمار resolve می‌شود — همان مسیری که منوی عملیات نوبت‌ها استفاده می‌کند.
*/
function ViewLink({ row }: { row: ApptRow }) {
const navigate = useNavigate();
const [busy, setBusy] = useState(false);
const dayHref = isoDay(row.slot_start) ? `/admin/appointments?date=${isoDay(row.slot_start)}` : '/admin/appointments';
const cls = 'text-[var(--primary)] text-[12px] font-medium hover:underline whitespace-nowrap';
if (!CONFIRMED_STATUSES.includes(row.status) || !row.patient_mobile) {
return <Link to={dayHref} className={cls}>مشاهده</Link>;
}
const openRecord = async () => {
setBusy(true);
try {
const recordUuid = await findRecordUuid(row.patient_mobile!);
navigate(recordUuid ? `/admin/patients/${recordUuid}` : dayHref);
} catch {
toast.error('خطا در یافتن پرونده بیمار');
} finally {
setBusy(false);
}
};
return (
<button type="button" onClick={openRecord} disabled={busy} className={`${cls} bg-transparent border-none cursor-pointer`}>
{busy ? '...' : 'مشاهده'}
</button>
);
}
/** نمایش فقط‌خواندنی وضعیت — وقتی version یا queryKey در دسترس نیست. */
function StatusPill({ status }: { status: string }) {
const meta = STATUS_META[status] ?? { label: status, color: 'var(--text-3)' };
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-[var(--text-2)] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap text-start ${className}`}
>
{children}
</td>
);
}