From d9f96b68cdbf16caec4c3b003da17f8e9bb3c4b6 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 14 Jul 2026 13:54:50 +0330 Subject: [PATCH] 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. --- .../dashboard/NewAppointmentsTable.tsx | 136 ++++++++++++++ .../components/dashboard/TauriCharts.tsx | 175 ++++++++++++++++++ .../dashboard/TauriDashboardView.tsx | 112 +++++++++++ .../components/dashboard/TauriStatCards.tsx | 103 +++++++++++ .../components/dashboard/dashboardIcons.tsx | 77 ++++++++ assets/admin/pages/DashboardPage.test.tsx | 95 ++++++++++ assets/admin/pages/DashboardPage.tsx | 161 ++++------------ docs/api/dashboard.md | 10 +- .../Controller/DashboardController.php | 11 +- .../DashboardTodayAppointmentsTest.php | 130 +++++++++++++ 10 files changed, 883 insertions(+), 127 deletions(-) create mode 100644 assets/admin/components/dashboard/NewAppointmentsTable.tsx create mode 100644 assets/admin/components/dashboard/TauriCharts.tsx create mode 100644 assets/admin/components/dashboard/TauriDashboardView.tsx create mode 100644 assets/admin/components/dashboard/TauriStatCards.tsx create mode 100644 assets/admin/components/dashboard/dashboardIcons.tsx create mode 100644 assets/admin/pages/DashboardPage.test.tsx create mode 100644 tests/Dashboard/DashboardTodayAppointmentsTest.php diff --git a/assets/admin/components/dashboard/NewAppointmentsTable.tsx b/assets/admin/components/dashboard/NewAppointmentsTable.tsx new file mode 100644 index 00000000..610961c2 --- /dev/null +++ b/assets/admin/components/dashboard/NewAppointmentsTable.tsx @@ -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 = { + 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 ( +
+ + {s.label} + + +
+ ); +} + +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
; + } + if (!rows.length) { + return ( +

+ نوبتی برای امروز ثبت نشده +

+ ); + } + return ( +
+ + + + {HEAD.map((h, i) => ( + + ))} + + + + {rows.map((r, idx) => ( + + {new Intl.NumberFormat('fa-IR').format(idx + 1)} + {r.patient_name || '—'} + {r.patient_mobile || '—'} + {formatTime(r.slot_start)} + {formatTime(r.slot_end)} + {r.service_name || '—'} + {r.doctor_name ? `دکتر ${r.doctor_name}` : '—'} + + + + ))} + +
+ {h} +
+ + + + مشاهده + +
+
+ ); +} + +function Cell({ children, className = '', dir }: { children: React.ReactNode; className?: string; dir?: 'ltr' | 'rtl' }) { + return ( + + {children} + + ); +} diff --git a/assets/admin/components/dashboard/TauriCharts.tsx b/assets/admin/components/dashboard/TauriCharts.tsx new file mode 100644 index 00000000..96ccfb06 --- /dev/null +++ b/assets/admin/components/dashboard/TauriCharts.tsx @@ -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 ( +
+
+ {/* y-axis ticks, aligned to gridlines */} +
+ {ticks.map((t, i) => ( + + {faNum.format(t)} + + ))} +
+ {/* plot area */} +
+ {ticks.map((_, i) => ( +
+ ))} + {children} +
+
+ {/* x-axis labels */} +
+ {labels.map((l, i) => ( + + {l} + + ))} +
+
+ ); +} + +/** Bar chart — thin #5559CE columns (source: BarPlot, categoryGapRatio 0.7). */ +export function TauriBarChart({ data }: { data: ChartPoint[] }) { + if (!data.length) { + return ; + } + const max = Math.max(...data.map((d) => d.value), 1); + const ticks = niceTicks(max); + const top = ticks[0] || 1; + return ( + d.label)} yWidth={42}> +
+ {data.map((d, i) => ( +
+
0 ? 4 : 0, + animation: `tdgrowcol .9s ${i * 0.06}s cubic-bezier(.22,.61,.36,1) both`, + }} + /> +
+ ))} +
+ + + ); +} + +/** Line + area chart — #5559CE line over a #3A6FF8 gradient (source: LinePlot). */ +export function TauriLineChart({ data }: { data: ChartPoint[] }) { + if (data.length < 2) { + return ; + } + 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 ( + x.label)} yWidth={64}> + + + + + + + + + + + + ); +} + +function EmptyChart() { + return ( +
+ داده‌ای برای نمایش نیست +
+ ); +} diff --git a/assets/admin/components/dashboard/TauriDashboardView.tsx b/assets/admin/components/dashboard/TauriDashboardView.tsx new file mode 100644 index 00000000..440d8a62 --- /dev/null +++ b/assets/admin/components/dashboard/TauriDashboardView.tsx @@ -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 ( +
+ + + + +
+ ); +} + +function ChartTitle({ text }: { text: string }) { + return

{text}

; +} + +function ChartCard({ title, selectorOptions, children }: { title: string; selectorOptions: string[]; children: React.ReactNode }) { + return ( +
+
+ + +
+ {children} +
+ ); +} + +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 ( +
+ {/* Cards */} + + + {/* Charts */} +
+ + + + + + +
+ + {/* New appointments list */} +
+
+

لیست نوبت‌های جدید

+ + نوبت‌ها + + + + +
+
+ +
+
+
+ ); +} diff --git a/assets/admin/components/dashboard/TauriStatCards.tsx b/assets/admin/components/dashboard/TauriStatCards.tsx new file mode 100644 index 00000000..fe5b09c1 --- /dev/null +++ b/assets/admin/components/dashboard/TauriStatCards.tsx @@ -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 ( +
  • +
    + {data.icon} +
    +
    +

    + {data.value} +

    +

    + {data.label} +

    +
    +
  • + ); +} + +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: , + value: `${formatNumber(stats.totalPatients)}+`, + label: 'تعداد کل مراجعین', + }, + { + cardColor: 'bg-[rgba(0,157,121,0.10)]', + iconColor: 'bg-[#009D79]', + icon: , + value: formatRial(stats.totalPaymentsRials), + label: 'کل پرداختی‌ها', + }, + { + cardColor: 'bg-[rgba(85,89,206,0.08)]', + iconColor: 'bg-[#5559CE]', + icon: , + value: formatRial(stats.todayPaymentsRials), + label: 'پرداختی‌های امروز', + }, + { + cardColor: 'bg-[rgba(255,192,81,0.11)]', + iconColor: 'bg-[#FFC051]', + icon: , + value: `${formatNumber(stats.todayAppointments)}+`, + label: 'تعداد نوبت‌های امروز', + }, + ]; + + return ( +
      + {cards.map((c) => ( + + ))} +
    + ); +} diff --git a/assets/admin/components/dashboard/dashboardIcons.tsx b/assets/admin/components/dashboard/dashboardIcons.tsx new file mode 100644 index 00000000..da95f4fa --- /dev/null +++ b/assets/admin/components/dashboard/dashboardIcons.tsx @@ -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 ( + + + + + + + + ); +} + +/** کل پرداختی‌ها — total payments card */ +export function CardIcon() { + return ( + + + + + + + ); +} + +/** پرداختی‌های امروز — today payments card */ +export function CardTickIcon() { + return ( + + + + + + + + ); +} + +/** تعداد نوبت‌های امروز — today appointments card */ +export function CalendarIcon() { + return ( + + + + + + + + + + + + + ); +} + +/** فلش رو به پایین — status chip chevron (source: ArrowDownBlueP) */ +export function ChevronDownIcon({ color = '#5559CE' }: { color?: string }) { + return ( + + + + ); +} diff --git a/assets/admin/pages/DashboardPage.test.tsx b/assets/admin/pages/DashboardPage.test.tsx new file mode 100644 index 00000000..4fa34c7d --- /dev/null +++ b/assets/admin/pages/DashboardPage.test.tsx @@ -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; + +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(, { 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(, { route: '/admin/dashboard' }); + await screen.findByText('تعداد کل مراجعین'); + expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/dashboard/clinic')); + }); +}); diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index e4512fd8..e706b29a 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -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('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(() => (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 ; - 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: }, - { tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: }, - { tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: }, - { tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: }, - ]; - - 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 (
    -
    -
    -

    داشبورد کلینیک

    -
    {today} · {d?.clinic.name ?? context?.name ?? ''}
    -
    -
    -
    - - - -
    - - -
    -
    - -
    - {statCards.map(c => ( - - ))} -
    - -
    -
    -
    -

    نمودار تعداد مراجعین

    -
    - -
    -
    -
    -

    میزان درآمد

    -
    - -
    -
    - -
    -
    -

    لیست نوبت‌های امروز

    - مشاهده همه -
    - -
    + ({ 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، حفظ‌شده زیر نمای منتقل‌شده */}

    پزشکان کلینیک

    + @@ -853,8 +814,7 @@ interface DoctorDashboardData { } function DoctorDashboard() { - const { context } = useAuthStore(); - const [preset, setPreset] = useState('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(() => (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 ; - 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: }, - { tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: }, - { tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: }, - { tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: }, - ]; - - 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 (
    -
    -
    -

    داشبورد پزشک

    -
    {today} · دکتر {d?.doctor.name ?? context?.name ?? ''}
    -
    -
    -
    - - - -
    - -
    -
    - -
    - {statCards.map(c => ( - - ))} -
    - -
    -
    -
    -

    نمودار تعداد مراجعین

    -
    - -
    -
    -
    -

    میزان درآمد

    -
    - -
    -
    - -
    -
    -

    لیست نوبت‌های امروز

    - مشاهده همه -
    - -
    + ({ 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} + />
    diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md index 8baac33b..c649ef62 100644 --- a/docs/api/dashboard.md +++ b/docs/api/dashboard.md @@ -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 diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index 67543841..03534370 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -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([ diff --git a/tests/Dashboard/DashboardTodayAppointmentsTest.php b/tests/Dashboard/DashboardTodayAppointmentsTest.php new file mode 100644 index 00000000..342e3f15 --- /dev/null +++ b/tests/Dashboard/DashboardTodayAppointmentsTest.php @@ -0,0 +1,130 @@ +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']); + } +}