feat(dashboard): enhance appointment table and link to specific dates
This commit is contained in:
@@ -20,6 +20,16 @@ export interface ApptRow {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* روزِ محلیِ نوبت (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));
|
||||
@@ -46,7 +56,7 @@ export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loadi
|
||||
{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'}`}
|
||||
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>
|
||||
@@ -59,16 +69,16 @@ export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loadi
|
||||
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>
|
||||
<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 ? `دکتر ${r.doctor_name}` : '—'}</Cell>
|
||||
<td className="py-[10px] px-[18px] text-center">
|
||||
<Link
|
||||
to="/admin/appointments"
|
||||
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"
|
||||
>
|
||||
مشاهده
|
||||
@@ -82,11 +92,11 @@ export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loadi
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ children, className = '', dir }: { children: React.ReactNode; className?: string; dir?: 'ltr' | 'rtl' }) {
|
||||
/** همهی سلولها text-start هستند تا دقیقاً زیر هدر همنامشان بنشینند. */
|
||||
function Cell({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<td
|
||||
dir={dir}
|
||||
className={`text-[#616161] dark:text-[#A1A1A1] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap ${className}`}
|
||||
className={`text-[#616161] dark:text-[#A1A1A1] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap text-start ${className}`}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
|
||||
@@ -27,6 +27,24 @@ describe('TauriBarChart', () => {
|
||||
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('۸ خرداد')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one bar per day of a full Jalali month', () => {
|
||||
const month: ChartPoint[] = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: i % 5 }));
|
||||
const { container } = render(<TauriBarChart data={month} />);
|
||||
expect(container.querySelectorAll('.bg-\\[\\#5559CE\\]')).toHaveLength(31);
|
||||
});
|
||||
|
||||
/**
|
||||
* styles.css ships an unlayered legacy `.flex { align-items: center }` that
|
||||
* beats Tailwind's `items-*` utilities and collapses the plot column to zero
|
||||
* height. The frame must pin alignment inline, or the chart renders blank.
|
||||
*/
|
||||
it('pins flex alignment inline so the legacy global .flex cannot collapse it', () => {
|
||||
const { container } = render(<TauriBarChart data={real} />);
|
||||
const frame = container.querySelector('.h-\\[300px\\]') as HTMLElement;
|
||||
expect(frame.style.alignItems).toBe('stretch');
|
||||
expect((frame.firstElementChild as HTMLElement).style.alignItems).toBe('stretch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TauriLineChart', () => {
|
||||
|
||||
@@ -47,13 +47,16 @@ function ChartFrame({
|
||||
yWidth: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// NOTE: styles.css ships an unlayered legacy `.flex { align-items: center }`
|
||||
// which beats Tailwind's layered `items-*` utilities, collapsing the plot
|
||||
// column to zero height. Inline `alignItems` is the only reliable override.
|
||||
return (
|
||||
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]">
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]" style={{ alignItems: 'stretch' }}>
|
||||
<div className="flex-1 flex min-h-0" style={{ alignItems: 'stretch' }}>
|
||||
{/* y-axis ticks, aligned to gridlines */}
|
||||
<div
|
||||
className="flex flex-col justify-between text-[14px] font-normal text-[#858D9D] text-left pl-[4px] shrink-0"
|
||||
style={{ width: yWidth }}
|
||||
style={{ width: yWidth, alignItems: 'flex-start' }}
|
||||
>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
|
||||
@@ -74,7 +77,7 @@ function ChartFrame({
|
||||
</div>
|
||||
</div>
|
||||
{/* x-axis labels */}
|
||||
<div className="flex pt-[8px]" style={{ paddingRight: yWidth }}>
|
||||
<div className="flex pt-[8px]" style={{ paddingRight: yWidth, alignItems: 'flex-start' }}>
|
||||
{labels.map((l, i) => (
|
||||
<span key={i} className="flex-1 text-center text-[10px] font-normal text-[#7E7E7E] whitespace-nowrap">
|
||||
{l}
|
||||
@@ -95,9 +98,9 @@ export function TauriBarChart({ data }: { data: ChartPoint[] }) {
|
||||
const top = ticks[0] || 1;
|
||||
return (
|
||||
<ChartFrame ticks={ticks} labels={data.map((d) => d.label)} yWidth={42}>
|
||||
<div className="absolute inset-0 flex items-stretch">
|
||||
<div className="absolute inset-0 flex items-stretch" style={{ alignItems: 'stretch' }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex items-end justify-center">
|
||||
<div key={i} className="flex-1 flex items-end justify-center" style={{ alignItems: 'flex-end' }}>
|
||||
<div
|
||||
title={faNum.format(d.value)}
|
||||
className="w-[24px] max-w-[30%] rounded-t-[3px] bg-[#5559CE]"
|
||||
|
||||
@@ -98,6 +98,38 @@ describe('DashboardPage (ported clinic dashboard)', () => {
|
||||
// ستون «وضعیت» از داشبورد حذف شده — تغییر وضعیت فقط در صفحه نوبتهاست
|
||||
expect(screen.queryByText('وضعیت')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('ویزیت شده')).not.toBeInTheDocument();
|
||||
|
||||
// کارت «پزشکان کلینیک» از داشبورد حذف شده
|
||||
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('«مشاهده» به همان روزِ نوبت در لیست نوبتها لینک میدهد', async () => {
|
||||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||||
await screen.findByText('دنیا خلیلی');
|
||||
|
||||
const d = new Date(1_718_000_000 * 1000);
|
||||
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
expect(screen.getByText('مشاهده').closest('a')).toHaveAttribute('href', `/admin/appointments?date=${day}`);
|
||||
});
|
||||
|
||||
it('سلولهای جدول با هدرهایشان همتراز هستند (همه text-start جز ستون عملیات)', async () => {
|
||||
const { container } = renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||||
await screen.findByText('دنیا خلیلی');
|
||||
|
||||
const heads = Array.from(container.querySelectorAll('thead th'));
|
||||
const cells = Array.from(container.querySelectorAll('tbody td'));
|
||||
expect(heads).toHaveLength(cells.length);
|
||||
|
||||
heads.slice(0, -1).forEach(th => expect(th.className).toContain('text-start'));
|
||||
cells.slice(0, -1).forEach(td => expect(td.className).toContain('text-start'));
|
||||
|
||||
// ستون آخر «عملیات» در هر دو سطر وسطچین است
|
||||
expect(heads[heads.length - 1].className).toContain('text-center');
|
||||
expect(cells[cells.length - 1].className).toContain('text-center');
|
||||
|
||||
// dir=ltr روی سلول نمینشیند (جهت را برمیگرداند و همترازی را میشکند)
|
||||
cells.forEach(td => expect(td.getAttribute('dir')).toBeNull());
|
||||
});
|
||||
|
||||
it('calls the real clinic dashboard endpoint', async () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
|
||||
import { TauriDashboardView } from '../components/dashboard/TauriDashboardView';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
@@ -638,8 +637,6 @@ interface ClinicDashboardData {
|
||||
}
|
||||
|
||||
function ClinicDashboard() {
|
||||
const { context, dbUuid } = useAuthStore();
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const preset: DatePreset = 'this_month';
|
||||
const range = getRange(preset);
|
||||
const chartPeriod = useJalaliChartPeriod();
|
||||
@@ -651,7 +648,6 @@ function ClinicDashboard() {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<ClinicDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const clinicUuid = d?.clinic.uuid ?? dbUuid ?? '';
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
@@ -677,57 +673,6 @@ function ClinicDashboard() {
|
||||
currentJalaliYear={chartPeriod.currentJalaliYear}
|
||||
/>
|
||||
|
||||
{/* پزشکان کلینیک — بخش عملکردی موجود clinicpro، حفظشده زیر نمای منتقلشده */}
|
||||
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>پزشکان کلینیک</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
<button className="btn ghost sm" style={{ fontSize: 11, padding: '3px 10px' }} onClick={() => setInviteOpen(true)}>
|
||||
+ دعوت جدید
|
||||
</button>
|
||||
{clinicUuid && (
|
||||
<Link to={`/admin/clinics/${clinicUuid}`} className="link">مدیریت</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!d?.doctors.length ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<p className="muted" style={{ fontSize: 13.5, marginBottom: 12 }}>هنوز پزشکی دعوت نشده</p>
|
||||
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
|
||||
دعوت اولین پزشک
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{d.doctors.map((doc, i) => (
|
||||
<div
|
||||
key={doc.uuid}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.doctors.length - 1 ? '1px solid var(--border)' : 'none' }}
|
||||
>
|
||||
<AvatarEl initials={doc.name.slice(0, 1)} hue={162} size="sm" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<b style={{ fontSize: 13.5 }}>دکتر {doc.name}</b>
|
||||
</div>
|
||||
<span className="badge blue"><span className="bdot" />{formatNumber(doc.today_count)} امروز</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inviteOpen && clinicUuid && (
|
||||
<InviteDoctorModal
|
||||
clinicUuid={clinicUuid}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => q.refetch()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user