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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Dashboard charts — bar (تعداد بیماران) + line/area (میزان درآمد).
|
||||
*
|
||||
* The source (clinic-pro-tauri) draws these with @mui/x-charts. MUI is not used
|
||||
* in clinicpro, so they are reproduced with plain DOM + inline SVG, matching the
|
||||
* source visuals: bars #5559CE, dashed horizontal grid, y-axis ticks #858D9D,
|
||||
* x-axis labels #7E7E7E, and a line #5559CE over a #3A6FF8 gradient area.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/** ~5 rounded gridline ticks covering [0, max], top → bottom. */
|
||||
function niceTicks(max: number, count = 4): number[] {
|
||||
const safeMax = max > 0 ? max : 1;
|
||||
const rawStep = safeMax / count;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const norm = rawStep / mag;
|
||||
const niceNorm = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
||||
const step = niceNorm * mag;
|
||||
const top = step * count;
|
||||
const ticks: number[] = [];
|
||||
for (let i = count; i >= 0; i--) ticks.push(Math.round(step * i));
|
||||
return ticks; // e.g. [400,300,200,100,0]
|
||||
}
|
||||
|
||||
const faNum = new Intl.NumberFormat('fa-IR');
|
||||
|
||||
/**
|
||||
* Shared plot frame: left y-axis tick column + dashed gridlines + bottom x-axis
|
||||
* labels. `render(top, bottom)` receives the plot-area vertical bounds (px kept
|
||||
* implicit via fl/percentages) and returns the plot content.
|
||||
*/
|
||||
function ChartFrame({
|
||||
ticks,
|
||||
labels,
|
||||
yWidth,
|
||||
children,
|
||||
}: {
|
||||
ticks: number[];
|
||||
labels: string[];
|
||||
yWidth: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-[300px] w-full flex flex-col px-[20px] pb-[12px]">
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* 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 }}
|
||||
>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="leading-none -translate-y-1/2 first:translate-y-0 last:translate-y-0">
|
||||
{faNum.format(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* plot area */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{ticks.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute left-0 right-0 border-t border-dashed border-[#E7E7E7] dark:border-[#35343D]"
|
||||
style={{ top: `${(i / (ticks.length - 1)) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{/* x-axis labels */}
|
||||
<div className="flex pt-[8px]" style={{ paddingRight: yWidth }}>
|
||||
{labels.map((l, i) => (
|
||||
<span key={i} className="flex-1 text-center text-[10px] font-normal text-[#7E7E7E] whitespace-nowrap">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bar chart — thin #5559CE columns (source: BarPlot, categoryGapRatio 0.7). */
|
||||
export function TauriBarChart({ data }: { data: ChartPoint[] }) {
|
||||
if (!data.length) {
|
||||
return <EmptyChart />;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const ticks = niceTicks(max);
|
||||
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">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex items-end justify-center">
|
||||
<div
|
||||
title={faNum.format(d.value)}
|
||||
className="w-[24px] max-w-[30%] rounded-t-[3px] bg-[#5559CE]"
|
||||
style={{
|
||||
height: `${(d.value / top) * 100}%`,
|
||||
minHeight: d.value > 0 ? 4 : 0,
|
||||
animation: `tdgrowcol .9s ${i * 0.06}s cubic-bezier(.22,.61,.36,1) both`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes tdgrowcol{from{height:0}}`}</style>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** Line + area chart — #5559CE line over a #3A6FF8 gradient (source: LinePlot). */
|
||||
export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
||||
if (data.length < 2) {
|
||||
return <EmptyChart />;
|
||||
}
|
||||
const values = data.map((d) => d.value);
|
||||
const max = Math.max(...values, 1);
|
||||
const ticks = niceTicks(max);
|
||||
const top = ticks[0] || 1;
|
||||
|
||||
// SVG plot: 0..100 in both axes, non-uniform scaling (path has no text).
|
||||
const W = 100;
|
||||
const H = 100;
|
||||
const stepX = data.length > 1 ? W / (data.length - 1) : W;
|
||||
const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]);
|
||||
const d = pts
|
||||
.map((p, i) => {
|
||||
if (i === 0) return `M${p[0]},${p[1]}`;
|
||||
const prev = pts[i - 1];
|
||||
const cx = (prev[0] + p[0]) / 2;
|
||||
return `C${cx},${prev[1]} ${cx},${p[1]} ${p[0]},${p[1]}`;
|
||||
})
|
||||
.join(' ');
|
||||
const area = `${d} L${pts[pts.length - 1][0]},${H} L${pts[0][0]},${H} Z`;
|
||||
|
||||
return (
|
||||
<ChartFrame ticks={ticks} labels={data.map((x) => x.label)} yWidth={64}>
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full overflow-visible"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3A6FF8" stopOpacity={0.1} />
|
||||
<stop offset="100%" stopColor="#3A6FF8" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill="url(#tdIncomeGrad)" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="#5559CE"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyChart() {
|
||||
return (
|
||||
<div className="h-[300px] w-full grid place-items-center text-[13px] text-[#7E7E7E]">
|
||||
دادهای برای نمایش نیست
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Ported clinic/doctor dashboard view — pixel-for-pixel from clinic-pro-tauri
|
||||
* `src/components/dashboard/index.jsx` (Cards → Charts → New-appointments list,
|
||||
* stacked with 24px gaps). Purely presentational; both ClinicDashboard and
|
||||
* DoctorDashboard feed it their (real-API) data.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TauriStatCards, type DashboardStats } from './TauriStatCards';
|
||||
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
|
||||
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
|
||||
|
||||
/** small cosmetic dropdown — mirrors source SmSelector (does not drive data) */
|
||||
function SmSelector({ options }: { options: string[] }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
className="appearance-none bg-transparent text-[#7E7E7E] dark:text-[#A1A1A1] text-[12px] font-normal min-w-[92px] rounded-[6px] border border-solid border-[#D7D7D7] dark:border-[#35343D] py-[8px] pr-[8px] pl-[24px] cursor-pointer"
|
||||
defaultValue={options[0]}
|
||||
aria-label="بازه"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
<svg
|
||||
className="pointer-events-none absolute left-[6px] top-1/2 -translate-y-1/2"
|
||||
width="16" height="16" viewBox="0 0 20 20" fill="none"
|
||||
>
|
||||
<path d="M16.6004 7.4585L11.1671 12.8918C10.5254 13.5335 9.47539 13.5335 8.83372 12.8918L3.40039 7.4585"
|
||||
stroke="#7E7E7E" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartTitle({ text }: { text: string }) {
|
||||
return <p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">{text}</p>;
|
||||
}
|
||||
|
||||
function ChartCard({ title, selectorOptions, children }: { title: string; selectorOptions: string[]; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-full lg:w-1/2 rounded-[8px] border border-solid border-[#EFEFEF] dark:border-transparent bg-[#FFF] dark:bg-[#222433]">
|
||||
<div className="flex px-[20px] py-[12px] md:py-[14px] lg:py-[16px] items-center justify-between gap-[12px]">
|
||||
<ChartTitle text={title} />
|
||||
<SmSelector options={selectorOptions} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
|
||||
const YEARS = ['سال ۱۴۰۳', 'سال ۱۴۰۲', 'سال ۱۴۰۱', 'سال ۱۴۰۰'];
|
||||
|
||||
export interface TauriDashboardViewProps {
|
||||
stats: DashboardStats;
|
||||
/** «نمودار تعداد بیماران» series (appointments_by_day) */
|
||||
patientBars: ChartPoint[];
|
||||
/** «میزان درآمد» series (revenue_by_day) */
|
||||
incomeLine: ChartPoint[];
|
||||
appointments: ApptRow[];
|
||||
loading: boolean;
|
||||
formatNumber: (n: number) => string;
|
||||
formatRial: (rial: number) => string;
|
||||
}
|
||||
|
||||
export function TauriDashboardView({
|
||||
stats,
|
||||
patientBars,
|
||||
incomeLine,
|
||||
appointments,
|
||||
loading,
|
||||
formatNumber,
|
||||
formatRial,
|
||||
}: TauriDashboardViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-[24px]">
|
||||
{/* Cards */}
|
||||
<TauriStatCards stats={stats} formatNumber={formatNumber} formatRial={formatRial} />
|
||||
|
||||
{/* Charts */}
|
||||
<div className="flex flex-col lg:flex-row items-stretch justify-center gap-[16px]">
|
||||
<ChartCard title="نمودار تعداد بیماران" selectorOptions={MONTHS}>
|
||||
<TauriBarChart data={patientBars} />
|
||||
</ChartCard>
|
||||
<ChartCard title="میزان درآمد" selectorOptions={YEARS}>
|
||||
<TauriLineChart data={incomeLine} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* New appointments list */}
|
||||
<div className="rounded-[8px] border border-solid border-transparent md:border-[#EFEFEF] dark:border-transparent bg-transparent md:bg-[#FFF] md:dark:bg-[#222433]">
|
||||
<div className="md:pt-[18px] md:px-[24px] md:pb-[20px] flex items-center justify-between">
|
||||
<p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">لیست نوبتهای جدید</p>
|
||||
<Link
|
||||
to="/admin/appointments"
|
||||
className="flex items-center gap-[4px] p-1 text-[12px] md:text-[14px] text-[#616161] dark:text-[#A1A1A1] font-normal hover:text-[#5559ce]"
|
||||
>
|
||||
نوبتها
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 6L9 12L15 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="px-0 md:px-[24px] pb-[16px]">
|
||||
<NewAppointmentsTable rows={appointments} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Stat cards row — ported pixel-for-pixel from clinic-pro-tauri
|
||||
* `src/components/dashboard/cards/` (index + Card). Exact Tailwind arbitrary
|
||||
* classes / colors kept; icons are the ported SVGs in `dashboardIcons`.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { UserAddIcon, CardIcon, CardTickIcon, CalendarIcon } from './dashboardIcons';
|
||||
|
||||
export interface StatCardModel {
|
||||
/** background tint of the whole card (source `card_color`) */
|
||||
cardColor: string;
|
||||
/** solid background of the round icon bubble (source `icon_color`) */
|
||||
iconColor: string;
|
||||
icon: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function TauriStatCard({ data }: { data: StatCardModel }) {
|
||||
return (
|
||||
<li
|
||||
className={`px-[8px] md:px-[16px] lg:px-[24px] py-[18px] sm:py-[20px] md:py-[22px] lg:py-[24px] rounded-[8px] flex items-center justify-start gap-[4px] md:gap-[8px] lg:gap-[12px] ${data.cardColor} dark:bg-[#222433]`}
|
||||
>
|
||||
<div
|
||||
className={`p-[8px] w-[calc(18px+8px)] sm:w-[calc(20px+8px)] md:w-[calc(22px+8px)] lg:w-[calc(24px+8px)] min-w-[calc(18px+8px)] sm:min-w-[calc(20px+8px)] md:min-w-[calc(22px+8px)] lg:min-w-[calc(24px+8px)] h-[calc(18px+8px)] sm:h-[calc(20px+8px)] md:h-[calc(22px+8px)] lg:h-[calc(24px+8px)] grid place-items-center rounded-full ${data.iconColor}`}
|
||||
>
|
||||
{data.icon}
|
||||
</div>
|
||||
<div className="flex flex-col w-full items-start justify-center gap-[8px]">
|
||||
<p className="text-[#3B3B3B] text-wrap dark:text-[#D7D8ED] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
|
||||
{data.value}
|
||||
</p>
|
||||
<p className="text-[#616161] dark:text-[#A1A1A1] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
|
||||
{data.label}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
/** تعداد کل مراجعین */
|
||||
totalPatients: number;
|
||||
/** کل پرداختیها (ریال) */
|
||||
totalPaymentsRials: number;
|
||||
/** پرداختیهای امروز (ریال) */
|
||||
todayPaymentsRials: number;
|
||||
/** تعداد نوبتهای امروز */
|
||||
todayAppointments: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4-card grid. `formatNumber`/`formatRial` are passed in so this stays a pure
|
||||
* presentational component (SRP) — no coupling to the app's utils.
|
||||
*/
|
||||
export function TauriStatCards({
|
||||
stats,
|
||||
formatNumber,
|
||||
formatRial,
|
||||
}: {
|
||||
stats: DashboardStats;
|
||||
formatNumber: (n: number) => string;
|
||||
formatRial: (rial: number) => string;
|
||||
}) {
|
||||
const cards: StatCardModel[] = [
|
||||
{
|
||||
cardColor: 'bg-[rgba(241,119,50,0.10)]',
|
||||
iconColor: 'bg-[#F17732]',
|
||||
icon: <UserAddIcon />,
|
||||
value: `${formatNumber(stats.totalPatients)}+`,
|
||||
label: 'تعداد کل مراجعین',
|
||||
},
|
||||
{
|
||||
cardColor: 'bg-[rgba(0,157,121,0.10)]',
|
||||
iconColor: 'bg-[#009D79]',
|
||||
icon: <CardIcon />,
|
||||
value: formatRial(stats.totalPaymentsRials),
|
||||
label: 'کل پرداختیها',
|
||||
},
|
||||
{
|
||||
cardColor: 'bg-[rgba(85,89,206,0.08)]',
|
||||
iconColor: 'bg-[#5559CE]',
|
||||
icon: <CardTickIcon />,
|
||||
value: formatRial(stats.todayPaymentsRials),
|
||||
label: 'پرداختیهای امروز',
|
||||
},
|
||||
{
|
||||
cardColor: 'bg-[rgba(255,192,81,0.11)]',
|
||||
iconColor: 'bg-[#FFC051]',
|
||||
icon: <CalendarIcon />,
|
||||
value: `${formatNumber(stats.todayAppointments)}+`,
|
||||
label: 'تعداد نوبتهای امروز',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ul className="grid justify-center grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-[16px] sm:gap-[23px] md:gap-[30px] lg:gap-[36px]">
|
||||
{cards.map((c) => (
|
||||
<TauriStatCard key={c.label} data={c} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Dashboard card icons — ported 1:1 (exact SVG paths) from clinic-pro-tauri
|
||||
* (`src/assets/icon/{UserAddCD,CardCD,CardTickCD,CalendarCD,ArrowDownBlueP}.jsx`)
|
||||
* to keep the ported dashboard pixel-identical. Plain SVG, no icon library.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
const S = {
|
||||
className: 'w-full h-full',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
fill: 'none',
|
||||
} as const;
|
||||
|
||||
/** مراجعین — total patients card */
|
||||
export function UserAddIcon() {
|
||||
return (
|
||||
<svg {...S} width="24" height="24" viewBox="0 0 24 24">
|
||||
<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="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M3.40991 22C3.40991 18.13 7.25991 15 11.9999 15C12.9599 15 13.8899 15.13 14.7599 15.37" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M22 18C22 18.32 21.96 18.63 21.88 18.93C21.79 19.33 21.63 19.72 21.42 20.06C20.73 21.22 19.46 22 18 22C16.97 22 16.04 21.61 15.34 20.97C15.04 20.71 14.78 20.4 14.58 20.06C14.21 19.46 14 18.75 14 18C14 16.92 14.43 15.93 15.13 15.21C15.86 14.46 16.88 14 18 14C19.18 14 20.25 14.51 20.97 15.33C21.61 16.04 22 16.98 22 18Z" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.4897 17.98H16.5098" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18 16.52V19.51" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** کل پرداختیها — total payments card */
|
||||
export function CardIcon() {
|
||||
return (
|
||||
<svg {...S} width="25" height="24" viewBox="0 0 25 24">
|
||||
<path d="M2.5 8.50488H22.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.5 16.5049H8.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11 16.5049H15" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.94 3.50488H18.05C21.61 3.50488 22.5 4.38488 22.5 7.89488V16.1049C22.5 19.6149 21.61 20.4949 18.06 20.4949H6.94C3.39 20.5049 2.5 19.6249 2.5 16.1149V7.89488C2.5 4.38488 3.39 3.50488 6.94 3.50488Z" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** پرداختیهای امروز — today payments card */
|
||||
export function CardTickIcon() {
|
||||
return (
|
||||
<svg {...S} width="25" height="24" viewBox="0 0 25 24">
|
||||
<path d="M2.5 8.5H14" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.5 16.5H8.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11 16.5H15" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M22.5 11.03V16.11C22.5 19.62 21.61 20.5 18.06 20.5H6.94C3.39 20.5 2.5 19.62 2.5 16.11V7.89C2.5 4.38 3.39 3.5 6.94 3.5H14" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M17 6L18.5 7.5L22.5 3.5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** تعداد نوبتهای امروز — today appointments card */
|
||||
export function CalendarIcon() {
|
||||
return (
|
||||
<svg {...S} width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8 2V5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M16 2V5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M3.5 9.08984H20.5" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18 23C20.2091 23 22 21.2091 22 19C22 16.7909 20.2091 15 18 15C15.7909 15 14 16.7909 14 19C14 21.2091 15.7909 23 18 23Z" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.49 19.0498H16.51" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18 17.5898V20.5798" stroke="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 8.5V16.36C20.27 15.53 19.2 15 18 15C15.79 15 14 16.79 14 19C14 19.75 14.21 20.46 14.58 21.06C14.79 21.42 15.06 21.74 15.37 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="white" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.9955 13.7002H12.0045" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.29431 13.7002H8.30329" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.29431 16.7002H8.30329" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** فلش رو به پایین — status chip chevron (source: ArrowDownBlueP) */
|
||||
export function ChevronDownIcon({ color = '#5559CE' }: { color?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M16.6004 7.4585L11.1671 12.8918C10.5254 13.5335 9.47539 13.5335 8.83372 12.8918L3.40039 7.4585" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
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 { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const clinicPayload = {
|
||||
success: true,
|
||||
data: {
|
||||
clinic: { uuid: 'clinic-1', name: 'کلینیک نمونه', is_active: true, logo: null },
|
||||
stats: {
|
||||
total_doctors: 2,
|
||||
today_appointments: 15,
|
||||
this_month_appointments: 40,
|
||||
pending_invitations: 0,
|
||||
total_patients: 151,
|
||||
revenue_period_rials: 5_600_002_000,
|
||||
today_payments_rials: 5_225_000,
|
||||
week_payments_rials: 12_000_000,
|
||||
},
|
||||
charts: {
|
||||
revenue_by_day: [
|
||||
{ label: '۷ خرداد', amount_rials: 60_000 },
|
||||
{ label: '۸ خرداد', amount_rials: 160_000 },
|
||||
{ label: '۹ خرداد', amount_rials: 90_000 },
|
||||
],
|
||||
appointments_by_day: [
|
||||
{ label: '۷ خرداد', count: 15 },
|
||||
{ label: '۸ خرداد', count: 40 },
|
||||
{ label: '۹ خرداد', count: 48 },
|
||||
],
|
||||
},
|
||||
today_appointments: [
|
||||
{
|
||||
uuid: 'appt-1',
|
||||
patient_name: 'دنیا خلیلی',
|
||||
patient_mobile: '09136549874',
|
||||
doctor_name: 'حمیدی',
|
||||
service_name: 'ویزیت عمومی',
|
||||
slot_start: 1_718_000_000,
|
||||
slot_end: 1_718_001_800,
|
||||
status: 'visited',
|
||||
},
|
||||
],
|
||||
doctors: [],
|
||||
period: { from: 0, to: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
describe('DashboardPage (ported clinic dashboard)', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic-1', context: null } as never);
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.startsWith('/api/v1/dashboard/clinic')) return Promise.resolve(clinicPayload);
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the ported stat cards, charts and new-appointments list', async () => {
|
||||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||||
|
||||
// stat card labels (ported 1:1)
|
||||
expect(await screen.findByText('تعداد کل مراجعین')).toBeInTheDocument();
|
||||
expect(screen.getByText('کل پرداختیها')).toBeInTheDocument();
|
||||
expect(screen.getByText('پرداختیهای امروز')).toBeInTheDocument();
|
||||
expect(screen.getByText('تعداد نوبتهای امروز')).toBeInTheDocument();
|
||||
|
||||
// chart titles
|
||||
expect(screen.getByText('نمودار تعداد بیماران')).toBeInTheDocument();
|
||||
expect(screen.getByText('میزان درآمد')).toBeInTheDocument();
|
||||
|
||||
// new-appointments list + row data (mobile/service columns come from the extended API)
|
||||
expect(screen.getByText('لیست نوبتهای جدید')).toBeInTheDocument();
|
||||
expect(screen.getByText('دنیا خلیلی')).toBeInTheDocument();
|
||||
expect(screen.getByText('09136549874')).toBeInTheDocument();
|
||||
expect(screen.getByText('ویزیت عمومی')).toBeInTheDocument();
|
||||
expect(screen.getByText('ویزیت شده')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the real clinic dashboard endpoint', async () => {
|
||||
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
|
||||
await screen.findByText('تعداد کل مراجعین');
|
||||
expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/dashboard/clinic'));
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ 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 StatCard from '../components/ui/StatCard';
|
||||
import { TauriDashboardView } from '../components/dashboard/TauriDashboardView';
|
||||
|
||||
// ── Shared Status Maps ────────────────────────────────────────────────────
|
||||
|
||||
@@ -240,7 +240,10 @@ interface ApptRow {
|
||||
uuid: string;
|
||||
patient_name: string | null;
|
||||
patient_mobile?: string;
|
||||
doctor_name?: string | null;
|
||||
service_name?: string | null;
|
||||
slot_start: number;
|
||||
slot_end?: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -607,7 +610,7 @@ interface ClinicDashboardData {
|
||||
function ClinicDashboard() {
|
||||
const { context, dbUuid } = useAuthStore();
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [preset, setPreset] = useState<DatePreset>('this_month');
|
||||
const preset: DatePreset = 'this_month';
|
||||
const range = getRange(preset);
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-clinic', preset],
|
||||
@@ -617,79 +620,37 @@ 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 today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const clinicUuid = d?.clinic.uuid ?? dbUuid ?? '';
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const statCards: { tone: 'amber' | 'violet' | 'green' | 'pink'; label: string; value: React.ReactNode; icon: React.ReactNode }[] = [
|
||||
{ tone: 'amber', label: 'تعداد نوبتهای امروز', value: `${formatNumber(d?.stats.today_appointments ?? 0)}+`, icon: <CalendarDaysIcon style={{ width: 22, height: 22 }} /> },
|
||||
{ tone: 'violet', label: 'پرداختیهای امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: <CreditCardIcon style={{ width: 22, height: 22 }} /> },
|
||||
{ tone: 'green', label: 'پرداختیهای هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: <BanknotesIcon style={{ width: 22, height: 22 }} /> },
|
||||
{ tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
|
||||
];
|
||||
|
||||
const revSeries = (d?.charts?.revenue_by_day ?? []).map(x => x.amount_rials);
|
||||
const patientBars = (d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }));
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">داشبورد کلینیک</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {d?.clinic.name ?? context?.name ?? ''}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div className="seg">
|
||||
<button className={preset === 'this_week' ? 'on' : ''} onClick={() => setPreset('this_week')}>این هفته</button>
|
||||
<button className={preset === 'this_month' ? 'on' : ''} onClick={() => setPreset('this_month')}>این ماه</button>
|
||||
<button className={preset === '3_months' ? 'on' : ''} onClick={() => setPreset('3_months')}>۳ ماه</button>
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
|
||||
<UserIcon style={{ width: 14, height: 14 }} />
|
||||
دعوت پزشک
|
||||
</button>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||
{statCards.map(c => (
|
||||
<StatCard key={c.label} tone={c.tone} label={c.label} value={c.value} icon={c.icon} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نمودار تعداد مراجعین</h3>
|
||||
</div>
|
||||
<SvgVBars data={patientBars} />
|
||||
</div>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>میزان درآمد</h3>
|
||||
</div>
|
||||
<SvgLineChart data={revSeries} color="var(--primary)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>لیست نوبتهای امروز</h3>
|
||||
<Link to="/admin/appointments" className="link">مشاهده همه</Link>
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
<TauriDashboardView
|
||||
stats={{
|
||||
totalPatients: d?.stats.total_patients ?? 0,
|
||||
totalPaymentsRials: d?.stats.revenue_period_rials ?? 0,
|
||||
todayPaymentsRials: d?.stats.today_payments_rials ?? 0,
|
||||
todayAppointments: d?.stats.today_appointments ?? 0,
|
||||
}}
|
||||
patientBars={(d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }))}
|
||||
incomeLine={(d?.charts?.revenue_by_day ?? []).map(x => ({ label: x.label, value: x.amount_rials }))}
|
||||
appointments={d?.today_appointments ?? []}
|
||||
loading={q.isFetching}
|
||||
formatNumber={formatNumber}
|
||||
formatRial={formatRial}
|
||||
/>
|
||||
|
||||
{/* پزشکان کلینیک — بخش عملکردی موجود 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>
|
||||
@@ -853,8 +814,7 @@ interface DoctorDashboardData {
|
||||
}
|
||||
|
||||
function DoctorDashboard() {
|
||||
const { context } = useAuthStore();
|
||||
const [preset, setPreset] = useState<DatePreset>('this_month');
|
||||
const preset: DatePreset = 'this_month';
|
||||
const range = getRange(preset);
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-doctor', preset],
|
||||
@@ -864,68 +824,25 @@ function DoctorDashboard() {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<DoctorDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
const statCards: { tone: 'amber' | 'violet' | 'green' | 'pink'; label: string; value: React.ReactNode; icon: React.ReactNode }[] = [
|
||||
{ tone: 'amber', label: 'تعداد نوبتهای امروز', value: `${formatNumber(d?.stats.today_appointments ?? 0)}+`, icon: <CalendarDaysIcon style={{ width: 22, height: 22 }} /> },
|
||||
{ tone: 'violet', label: 'پرداختیهای امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: <CreditCardIcon style={{ width: 22, height: 22 }} /> },
|
||||
{ tone: 'green', label: 'پرداختیهای هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: <BanknotesIcon style={{ width: 22, height: 22 }} /> },
|
||||
{ tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
|
||||
];
|
||||
|
||||
const revSeries = (d?.charts?.revenue_by_day ?? []).map(x => x.amount_rials);
|
||||
const patientBars = (d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }));
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">داشبورد پزشک</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · دکتر {d?.doctor.name ?? context?.name ?? ''}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div className="seg">
|
||||
<button className={preset === 'this_week' ? 'on' : ''} onClick={() => setPreset('this_week')}>این هفته</button>
|
||||
<button className={preset === 'this_month' ? 'on' : ''} onClick={() => setPreset('this_month')}>این ماه</button>
|
||||
<button className={preset === '3_months' ? 'on' : ''} onClick={() => setPreset('3_months')}>۳ ماه</button>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||
{statCards.map(c => (
|
||||
<StatCard key={c.label} tone={c.tone} label={c.label} value={c.value} icon={c.icon} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نمودار تعداد مراجعین</h3>
|
||||
</div>
|
||||
<SvgVBars data={patientBars} />
|
||||
</div>
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>میزان درآمد</h3>
|
||||
</div>
|
||||
<SvgLineChart data={revSeries} color="var(--primary)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>لیست نوبتهای امروز</h3>
|
||||
<Link to="/admin/appointments" className="link">مشاهده همه</Link>
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
<TauriDashboardView
|
||||
stats={{
|
||||
totalPatients: d?.stats.total_patients ?? 0,
|
||||
totalPaymentsRials: d?.stats.revenue_period_rials ?? 0,
|
||||
todayPaymentsRials: d?.stats.today_payments_rials ?? 0,
|
||||
todayAppointments: d?.stats.today_appointments ?? 0,
|
||||
}}
|
||||
patientBars={(d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }))}
|
||||
incomeLine={(d?.charts?.revenue_by_day ?? []).map(x => ({ label: x.label, value: x.amount_rials }))}
|
||||
appointments={d?.today_appointments ?? []}
|
||||
loading={q.isFetching}
|
||||
formatNumber={formatNumber}
|
||||
formatRial={formatRial}
|
||||
/>
|
||||
|
||||
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||
<DoctorClinicInvitationsCard />
|
||||
|
||||
@@ -54,8 +54,11 @@ Returns stats and today's schedule for the authenticated clinic owner.
|
||||
{
|
||||
"uuid": "string",
|
||||
"patient_name": "string | null",
|
||||
"patient_mobile": "string | null",
|
||||
"doctor_name": "string",
|
||||
"service_name": "string | null",
|
||||
"slot_start": 1700000000,
|
||||
"slot_end": 1700001800,
|
||||
"status": "reserved"
|
||||
}
|
||||
],
|
||||
@@ -77,7 +80,7 @@ Returns stats and today's schedule for the authenticated clinic owner.
|
||||
- `revenue_period_rials` — sum of `final_price_rials` from all patient sessions in the period
|
||||
- `today_payments_rials` / `week_payments_rials` — revenue for today / the last 7 days
|
||||
- `charts.revenue_by_day` / `charts.appointments_by_day` — last 7 days series (Jalali day label); revenue drives the «میزان درآمد» area chart, appointments the «نمودار تعداد مراجعین» bar chart
|
||||
- `today_appointments` — up to 5 records, ordered by `slot_start ASC`
|
||||
- `today_appointments` — up to 5 records, ordered by `slot_start ASC`; each row carries `patient_mobile`, `doctor_name` (personnel), `service_name` (nullable — booked service item), and `slot_end` for the «لیست نوبتهای جدید» dashboard table
|
||||
- `doctors` — all doctors belonging to this clinic; each includes their appointment count for today
|
||||
|
||||
### Errors
|
||||
@@ -135,7 +138,10 @@ Returns stats and today's schedule for the authenticated doctor.
|
||||
"uuid": "string",
|
||||
"patient_name": "string | null",
|
||||
"patient_mobile": "string",
|
||||
"doctor_name": "string",
|
||||
"service_name": "string | null",
|
||||
"slot_start": 1700000000,
|
||||
"slot_end": 1700001800,
|
||||
"status": "reserved"
|
||||
}
|
||||
],
|
||||
@@ -151,7 +157,7 @@ Returns stats and today's schedule for the authenticated doctor.
|
||||
```
|
||||
|
||||
**Field notes:**
|
||||
- `today_appointments` — up to 10 records, ordered by `slot_start ASC`
|
||||
- `today_appointments` — up to 10 records, ordered by `slot_start ASC`; each row carries `patient_mobile`, `doctor_name`, `service_name` (nullable), and `slot_end` for the dashboard appointments table
|
||||
- `avg_rating` — rounded to 1 decimal; `null` if no ratings yet
|
||||
- `clinics` — all clinics the doctor belongs to
|
||||
- `sms_wallet_balance`, `unique_patients_count`, `revenue_period_rials` — same semantics as clinic dashboard
|
||||
|
||||
@@ -96,11 +96,13 @@ class DashboardController extends BaseController
|
||||
|
||||
// ۵ نوبت امروز این کلینیک
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, d.name AS doctor_name,
|
||||
a.slotStart AS slot_start, a.status
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
d.name AS doctor_name, si.name AS service_name,
|
||||
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.doctor d
|
||||
JOIN a.user u
|
||||
LEFT JOIN a.serviceItem si
|
||||
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
|
||||
WHERE c.id = :clinicId
|
||||
AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd
|
||||
@@ -223,9 +225,12 @@ class DashboardController extends BaseController
|
||||
// نوبتهای امروز
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
a.slotStart AS slot_start, a.status
|
||||
d.name AS doctor_name, si.name AS service_name,
|
||||
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.user u
|
||||
JOIN a.doctor d
|
||||
LEFT JOIN a.serviceItem si
|
||||
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setMaxResults(10)->setParameters([
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Dashboard;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/dashboard/{doctor,clinic} — `today_appointments` rows must carry
|
||||
* the extended fields (patient_mobile, doctor_name, service_name, slot_end)
|
||||
* added for the ported «لیست نوبتهای جدید» dashboard table.
|
||||
*
|
||||
* NOTE: db_test is never reset, so patient mobiles use the ApiTestCase random
|
||||
* generator (never hardcoded) to avoid unique-constraint clashes across runs.
|
||||
*/
|
||||
class DashboardTodayAppointmentsTest extends ApiTestCase
|
||||
{
|
||||
private function todayAppointment(Doctor $doctor, User $patient): Appointment
|
||||
{
|
||||
// now() is guaranteed within [today midnight, tomorrow midnight-1]
|
||||
$start = time();
|
||||
$appt = new Appointment($doctor, $patient, $start, $start + 1_800);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
return $appt;
|
||||
}
|
||||
|
||||
public function testDoctorTodayAppointmentsExposeExtendedFields(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'حمیدی');
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$section = new ServiceSection('doctor', 1, 'عمومی');
|
||||
$item = new ServiceItem($section, 'ویزیت عمومی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$appt = $this->todayAppointment($doctor, $patient);
|
||||
$appt->setServiceItem($item);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/doctor', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$rows = $res['data']['today_appointments'];
|
||||
self::assertNotEmpty($rows, 'today_appointments should contain the booked slot');
|
||||
$row = $rows[0];
|
||||
|
||||
self::assertSame($patient->getMobileNumber(), $row['patient_mobile']);
|
||||
self::assertSame('حمیدی', $row['doctor_name']);
|
||||
self::assertSame('ویزیت عمومی', $row['service_name']);
|
||||
self::assertSame($appt->getSlotStart(), $row['slot_start']);
|
||||
self::assertSame($appt->getSlotEnd(), $row['slot_end']);
|
||||
self::assertArrayHasKey('status', $row);
|
||||
}
|
||||
|
||||
public function testServiceNameNullWhenNoServiceItem(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'حمیدی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$this->todayAppointment($doctor, $patient);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/doctor', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$row = $res['data']['today_appointments'][0];
|
||||
|
||||
self::assertNull($row['service_name'], 'service_name is null when no service item is booked');
|
||||
self::assertSame($patient->getMobileNumber(), $row['patient_mobile']);
|
||||
}
|
||||
|
||||
public function testClinicTodayAppointmentsExposeExtendedFields(): void
|
||||
{
|
||||
$clinicOwner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($clinicOwner);
|
||||
$clinic->setName('کلینیک نمونه');
|
||||
|
||||
$docOwner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($docOwner, 'حمیدی');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$section = new ServiceSection('doctor', 1, 'عمومی');
|
||||
$item = new ServiceItem($section, 'ویزیت عمومی');
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$appt = $this->todayAppointment($doctor, $patient);
|
||||
$appt->setServiceItem($item);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/clinic', $clinicOwner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$rows = $res['data']['today_appointments'];
|
||||
self::assertNotEmpty($rows);
|
||||
$row = $rows[0];
|
||||
|
||||
self::assertSame($patient->getMobileNumber(), $row['patient_mobile']);
|
||||
self::assertSame('حمیدی', $row['doctor_name']);
|
||||
self::assertSame('ویزیت عمومی', $row['service_name']);
|
||||
self::assertSame($appt->getSlotEnd(), $row['slot_end']);
|
||||
}
|
||||
|
||||
public function testDoctorNotFoundReturns404(): void
|
||||
{
|
||||
// ROLE_DOCTOR user without a Doctor entity
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/doctor', $user);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
self::assertFalse($res['success']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user