feat: port clinic dashboard components from clinic-pro-tauri
- Add NewAppointmentsTable for displaying today's appointments with status chips and formatted time. - Implement TauriCharts for bar and line charts representing patient counts and revenue. - Create TauriDashboardView to combine stat cards, charts, and new appointments list. - Introduce TauriStatCards for displaying key statistics with icons. - Add dashboardIcons for SVG icons used in stat cards. - Implement tests for DashboardPage to ensure correct rendering and API calls. - Create DashboardTodayAppointmentsTest to validate extended fields in today's appointments API response.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* «لیست نوبتهای جدید» 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`). Statuses are
|
||||
* clinicpro's real appointment statuses, mapped to the source's pill palette.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronDownIcon } from './dashboardIcons';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface StatusStyle {
|
||||
label: string;
|
||||
/** text + chevron color */
|
||||
fg: string;
|
||||
/** pill background classes (light + dark) */
|
||||
bg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a clinicpro appointment status to the source pill palette
|
||||
* (green/amber/blue/violet/red pastels). Unknown → neutral gray.
|
||||
*/
|
||||
const STATUS_STYLE: Record<string, StatusStyle> = {
|
||||
visited: { label: 'ویزیت شده', fg: '#3c9a4f', bg: 'bg-[#e4f2ea] dark:bg-[#324A32]' },
|
||||
completed: { label: 'تکمیل شده', fg: '#3c9a4f', bg: 'bg-[#e4f2ea] dark:bg-[#324A32]' },
|
||||
reserved: { label: 'رزرو شده', fg: '#0088ff', bg: 'bg-[#E3F2FD] dark:bg-[#1A2836]' },
|
||||
checked_in: { label: 'ورود به مطب', fg: '#0088ff', bg: 'bg-[#E3F2FD] dark:bg-[#1A2836]' },
|
||||
waiting_for_payment: { label: 'انتظار پرداخت', fg: '#f59e0b', bg: 'bg-[#FFF3E0] dark:bg-[#3E2E1B]' },
|
||||
waiting: { label: 'صف انتظار', fg: '#f59e0b', bg: 'bg-[#FFF3E0] dark:bg-[#3E2E1B]' },
|
||||
in_progress: { label: 'در حال ویزیت', fg: '#7c3aed', bg: 'bg-[#F3E5F5] dark:bg-[#2D1B36]' },
|
||||
cancelled_by_doctor: { label: 'لغو پزشک', fg: '#d32f2f', bg: 'bg-[#fde7e9] dark:bg-[rgba(255,84,80,0.20)]' },
|
||||
cancelled_by_user: { label: 'لغو بیمار', fg: '#d32f2f', bg: 'bg-[#fde7e9] dark:bg-[rgba(255,84,80,0.20)]' },
|
||||
auto_cancel_unpaid: { label: 'لغو خودکار', fg: '#d32f2f', bg: 'bg-[#fde7e9] dark:bg-[rgba(255,84,80,0.20)]' },
|
||||
no_show: { label: 'غیبت', fg: '#d32f2f', bg: 'bg-[#fde7e9] dark:bg-[rgba(255,84,80,0.20)]' },
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const s = STATUS_STYLE[status] ?? { label: status, fg: '#616161', bg: 'bg-[#EFEFEF] dark:bg-[#35343D]' };
|
||||
return (
|
||||
<div className={`w-[130px] py-[4px] px-[8px] rounded-[4px] flex items-center justify-between select-none ${s.bg}`}>
|
||||
<span className="text-[16px] font-medium flex-1 text-center" style={{ color: s.fg }}>
|
||||
{s.label}
|
||||
</span>
|
||||
<ChevronDownIcon color={s.fg} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ts?: number | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات'];
|
||||
|
||||
export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loading?: boolean }) {
|
||||
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]">
|
||||
نوبتی برای امروز ثبت نشده
|
||||
</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 === 0 ? 'text-right' : '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 className="text-right">{new Intl.NumberFormat('fa-IR').format(idx + 1)}</Cell>
|
||||
<Cell className="text-start">{r.patient_name || '—'}</Cell>
|
||||
<Cell className="text-start" dir="ltr">{r.patient_mobile || '—'}</Cell>
|
||||
<Cell className="text-right" dir="ltr">{formatTime(r.slot_start)}</Cell>
|
||||
<Cell className="text-right" dir="ltr">{formatTime(r.slot_end)}</Cell>
|
||||
<Cell className="text-right">{r.service_name || '—'}</Cell>
|
||||
<Cell className="text-start">{r.doctor_name ? `دکتر ${r.doctor_name}` : '—'}</Cell>
|
||||
<td className="py-[10px] px-[18px]">
|
||||
<StatusChip status={r.status} />
|
||||
</td>
|
||||
<td className="py-[10px] px-[18px] text-center">
|
||||
<Link
|
||||
to="/admin/appointments"
|
||||
className="text-[#5559ce] text-[12px] font-medium hover:underline whitespace-nowrap"
|
||||
>
|
||||
مشاهده
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ children, className = '', dir }: { children: React.ReactNode; className?: string; dir?: 'ltr' | 'rtl' }) {
|
||||
return (
|
||||
<td
|
||||
dir={dir}
|
||||
className={`text-[#616161] dark:text-[#A1A1A1] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap ${className}`}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user