diff --git a/assets/admin/components/dashboard/NewAppointmentsTable.tsx b/assets/admin/components/dashboard/NewAppointmentsTable.tsx index d47bc04a..4451e170 100644 --- a/assets/admin/components/dashboard/NewAppointmentsTable.tsx +++ b/assets/admin/components/dashboard/NewAppointmentsTable.tsx @@ -3,12 +3,11 @@ * `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. + * (`/api/v1/dashboard/{clinic,doctor}` → `today_appointments`). Read-only — + * status display/editing lives on the appointments page, not the dashboard. */ import React from 'react'; import { Link } from 'react-router-dom'; -import { ChevronDownIcon } from './dashboardIcons'; export interface ApptRow { uuid: string; @@ -21,50 +20,12 @@ export interface ApptRow { 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 Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000)); } -const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'وضعیت', 'عملیات']; +const HEAD = ['ردیف', 'نام بیمار', 'شماره تماس', 'شروع', 'پایان', 'سرویس', 'پرسنل', 'عملیات']; export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loading?: boolean }) { if (loading) { @@ -105,9 +66,6 @@ export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loadi {formatTime(r.slot_end)} {r.service_name || '—'} {r.doctor_name ? `دکتر ${r.doctor_name}` : '—'} - - - (options[0] ?? null); +export interface SelectorOption { + value: number; + label: string; +} + +/** dropdown دوره‌ی نمودار — مقدارش داده‌ی نمودار را عوض می‌کند */ +function SmSelector({ options, value, onChange }: { options: SelectorOption[]; value: number; onChange: (v: number) => void }) { return (
({ value: o, label: o }))} + options={options} value={value} - onChange={setValue} + onChange={(v) => onChange(Number(v))} height={34} />
@@ -30,20 +34,41 @@ function ChartTitle({ text }: { text: string }) { return

{text}

; } -function ChartCard({ title, selectorOptions, children }: { title: string; selectorOptions: string[]; children: React.ReactNode }) { +function ChartCard({ + title, + selectorOptions, + selectorValue, + onSelectorChange, + children, +}: { + title: string; + selectorOptions: SelectorOption[]; + selectorValue: number; + onSelectorChange: (v: number) => void; + children: React.ReactNode; +}) { return (
- +
{children}
); } -const MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']; -const YEARS = ['سال ۱۴۰۳', 'سال ۱۴۰۲', 'سال ۱۴۰۱', 'سال ۱۴۰۰']; +export const JALALI_MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']; + +const MONTH_OPTIONS: SelectorOption[] = JALALI_MONTHS.map((label, i) => ({ value: i + 1, label })); + +/** سال جاری و ۳ سال گذشته‌ی شمسی */ +function yearOptions(currentYear: number): SelectorOption[] { + return [0, 1, 2, 3].map((back) => ({ + value: currentYear - back, + label: `سال ${new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(currentYear - back)}`, + })); +} export interface TauriDashboardViewProps { stats: DashboardStats; @@ -55,6 +80,14 @@ export interface TauriDashboardViewProps { loading: boolean; formatNumber: (n: number) => string; formatRial: (rial: number) => string; + /** ماه شمسی نمودار بیماران (۱..۱۲) */ + patientsMonth: number; + onPatientsMonthChange: (m: number) => void; + /** سال شمسی نمودار درآمد */ + revenueYear: number; + onRevenueYearChange: (y: number) => void; + /** سال شمسی جاری — مبنای گزینه‌های سلکتور سال */ + currentJalaliYear: number; } export function TauriDashboardView({ @@ -65,7 +98,13 @@ export function TauriDashboardView({ loading, formatNumber, formatRial, + patientsMonth, + onPatientsMonthChange, + revenueYear, + onRevenueYearChange, + currentJalaliYear, }: TauriDashboardViewProps) { + const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]); return (
{/* Cards */} @@ -74,10 +113,20 @@ export function TauriDashboardView({ {/* Charts — grid-2 (1fr 1fr, collapses to 1fr on narrow) for reliable full-width equal columns, matching the rest of the admin dashboard */}
- + - +
diff --git a/assets/admin/pages/DashboardPage.test.tsx b/assets/admin/pages/DashboardPage.test.tsx index 4fa34c7d..2d96bed4 100644 --- a/assets/admin/pages/DashboardPage.test.tsx +++ b/assets/admin/pages/DashboardPage.test.tsx @@ -10,6 +10,10 @@ vi.mock('../lib/api', () => ({ import { api } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import DashboardPage from './DashboardPage'; +import { JALALI_MONTHS } from '../components/dashboard/TauriDashboardView'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const jalaali = require('jalaali-js') as { toJalaali: (d: Date) => { jy: number; jm: number } }; const get = api.get as ReturnType; @@ -33,12 +37,18 @@ const clinicPayload = { { label: '۸ خرداد', amount_rials: 160_000 }, { label: '۹ خرداد', amount_rials: 90_000 }, ], + revenue_by_month: [ + { 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 }, + { label: '۱', count: 15 }, + { label: '۲', count: 40 }, + { label: '۳', count: 48 }, ], }, + charts_period: { patients_year: 1405, patients_month: 4, revenue_year: 1405 }, today_appointments: [ { uuid: 'appt-1', @@ -84,7 +94,10 @@ describe('DashboardPage (ported clinic dashboard)', () => { expect(screen.getByText('دنیا خلیلی')).toBeInTheDocument(); expect(screen.getByText('09136549874')).toBeInTheDocument(); expect(screen.getByText('ویزیت عمومی')).toBeInTheDocument(); - expect(screen.getByText('ویزیت شده')).toBeInTheDocument(); + + // ستون «وضعیت» از داشبورد حذف شده — تغییر وضعیت فقط در صفحه نوبت‌هاست + expect(screen.queryByText('وضعیت')).not.toBeInTheDocument(); + expect(screen.queryByText('ویزیت شده')).not.toBeInTheDocument(); }); it('calls the real clinic dashboard endpoint', async () => { @@ -92,4 +105,23 @@ describe('DashboardPage (ported clinic dashboard)', () => { await screen.findByText('تعداد کل مراجعین'); expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/dashboard/clinic')); }); + + it('نمودارها را روی ماه و سال شمسی جاری درخواست می‌کند', async () => { + renderWithProviders(, { route: '/admin/dashboard' }); + await screen.findByText('تعداد کل مراجعین'); + + const url = get.mock.calls.map(c => String(c[0])).find(u => u.startsWith('/api/v1/dashboard/clinic'))!; + const params = new URLSearchParams(url.split('?')[1]); + const now = jalaali.toJalaali(new Date()); + + expect(params.get('patients_year')).toBe(String(now.jy)); + expect(params.get('patients_month')).toBe(String(now.jm)); + expect(params.get('revenue_year')).toBe(String(now.jy)); + + // سلکتورها همان دوره را نشان می‌دهند + expect(screen.getByText(JALALI_MONTHS[now.jm - 1])).toBeInTheDocument(); + expect( + screen.getByText(`سال ${new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(now.jy)}`) + ).toBeInTheDocument(); + }); }); diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index b15695cd..f3323048 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -13,9 +13,37 @@ 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 +const jalaali = require('jalaali-js') as { + toJalaali: (date: Date) => { jy: number; jm: number; jd: number }; +}; import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable'; import { usePermissions } from '../hooks/usePermissions'; +// ── Chart period (Jalali) ───────────────────────────────────────────────── + +/** + * دوره‌ی نمودارهای داشبورد: نمودار بیماران روی یک ماه شمسی و نمودار درآمد روی یک + * سال شمسی. پیش‌فرض = ماه/سال جاری؛ تغییر سلکتور باعث fetch مجدد می‌شود. + */ +function useJalaliChartPeriod() { + const now = useMemo(() => jalaali.toJalaali(new Date()), []); + const [patientsMonth, setPatientsMonth] = useState(now.jm); + const [revenueYear, setRevenueYear] = useState(now.jy); + + return { + currentJalaliYear: now.jy, + patientsYear: now.jy, + patientsMonth, + setPatientsMonth, + revenueYear, + setRevenueYear, + /** query string پارامترهای دوره‌ی نمودار */ + query: `patients_year=${now.jy}&patients_month=${patientsMonth}&revenue_year=${revenueYear}`, + }; +} + // ── Shared Status Maps ──────────────────────────────────────────────────── const APPT_LABEL: Record = { @@ -614,9 +642,10 @@ function ClinicDashboard() { const [inviteOpen, setInviteOpen] = useState(false); const preset: DatePreset = 'this_month'; const range = getRange(preset); + const chartPeriod = useJalaliChartPeriod(); const q = useQuery({ - queryKey: ['dashboard-clinic', preset], - queryFn: () => api.get>(`/api/v1/dashboard/clinic?from=${range.from}&to=${range.to}`), + queryKey: ['dashboard-clinic', preset, chartPeriod.query], + queryFn: () => api.get>(`/api/v1/dashboard/clinic?from=${range.from}&to=${range.to}&${chartPeriod.query}`), staleTime: 60_000, }); @@ -636,11 +665,16 @@ function ClinicDashboard() { 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 }))} + incomeLine={(d?.charts?.revenue_by_month ?? []).map(x => ({ label: x.label, value: x.amount_rials }))} appointments={d?.today_appointments ?? []} loading={q.isFetching} formatNumber={formatNumber} formatRial={formatRial} + patientsMonth={chartPeriod.patientsMonth} + onPatientsMonthChange={chartPeriod.setPatientsMonth} + revenueYear={chartPeriod.revenueYear} + onRevenueYearChange={chartPeriod.setRevenueYear} + currentJalaliYear={chartPeriod.currentJalaliYear} /> {/* پزشکان کلینیک — بخش عملکردی موجود clinicpro، حفظ‌شده زیر نمای منتقل‌شده */} @@ -798,7 +832,11 @@ function DoctorClinicInvitationsCard() { // ── Doctor Dashboard ────────────────────────────────────────────────────── interface DashboardCharts { + /** ۷ روز اخیر — فقط برای کارت‌های آماری */ revenue_by_day: { label: string; amount_rials: number }[]; + /** ۱۲ ماه سال شمسی انتخاب‌شده — نمودار «میزان درآمد» */ + revenue_by_month?: { label: string; amount_rials: number }[]; + /** روزهای ماه شمسی انتخاب‌شده — نمودار «تعداد بیماران» */ appointments_by_day: { label: string; count: number }[]; } @@ -818,9 +856,10 @@ interface DoctorDashboardData { function DoctorDashboard() { const preset: DatePreset = 'this_month'; const range = getRange(preset); + const chartPeriod = useJalaliChartPeriod(); const q = useQuery({ - queryKey: ['dashboard-doctor', preset], - queryFn: () => api.get>(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`), + queryKey: ['dashboard-doctor', preset, chartPeriod.query], + queryFn: () => api.get>(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}&${chartPeriod.query}`), staleTime: 60_000, }); @@ -839,11 +878,16 @@ function DoctorDashboard() { 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 }))} + incomeLine={(d?.charts?.revenue_by_month ?? []).map(x => ({ label: x.label, value: x.amount_rials }))} appointments={d?.today_appointments ?? []} loading={q.isFetching} formatNumber={formatNumber} formatRial={formatRial} + patientsMonth={chartPeriod.patientsMonth} + onPatientsMonthChange={chartPeriod.setPatientsMonth} + revenueYear={chartPeriod.revenueYear} + onRevenueYearChange={chartPeriod.setRevenueYear} + currentJalaliYear={chartPeriod.currentJalaliYear} />
diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md index 026f6b60..474523da 100644 --- a/docs/api/dashboard.md +++ b/docs/api/dashboard.md @@ -16,6 +16,11 @@ Returns stats and today's schedule for the authenticated clinic owner. |-------|------|---------|-------------| | `from` | int (unix) | start of current month | Period start for patient/revenue stats | | `to` | int (unix) | now | Period end for patient/revenue stats | +| `patients_year` | int (Jalali) | current Jalali year | Year of the month drawn by `charts.appointments_by_day` | +| `patients_month` | int `1..12` | current Jalali month | Jalali month drawn by `charts.appointments_by_day` | +| `revenue_year` | int (Jalali) | current Jalali year | Jalali year drawn by `charts.revenue_by_month` | + +`patients_year` / `revenue_year` are clamped to `1300..1500` and `patients_month` to `1..12`. ### Response `200` @@ -45,10 +50,14 @@ Returns stats and today's schedule for the authenticated clinic owner. "revenue_by_day": [ { "label": "۷ خرداد", "amount_rials": 3200000 } ], + "revenue_by_month": [ + { "label": "فروردین", "amount_rials": 3200000 } + ], "appointments_by_day": [ - { "label": "۷ خرداد", "count": 9 } + { "label": "۱", "count": 9 } ] }, + "charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 }, "period": { "from": 1717200000, "to": 1719792000 }, "today_appointments": [ { @@ -79,8 +88,11 @@ Returns stats and today's schedule for the authenticated clinic owner. - `total_patients` — distinct patients ever (no period filter) - `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`; each row carries `patient_mobile`, `doctor_name` (personnel), `service_name` (nullable — booked service item), and `slot_end` for the «لیست نوبت‌های جدید» dashboard table +- `charts.appointments_by_day` — «نمودار تعداد بیماران»: one entry per day of the requested Jalali month (28–31 entries), label = Jalali day number in Persian digits, empty days are `count: 0` +- `charts.revenue_by_month` — «میزان درآمد»: exactly 12 entries, one per Jalali month of `revenue_year`, label = Persian month name +- `charts.revenue_by_day` — last 7 days; kept only as the source of `today_payments_rials` / `week_payments_rials`, not drawn by the dashboard charts +- `charts_period` — the effective (post-clamp) chart period, so the UI can reflect what was actually rendered +- `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 (that table renders read-only — `status` is still returned but no longer shown) - `doctors` — all doctors belonging to this clinic; each includes their appointment count for today ### Errors @@ -103,6 +115,9 @@ Returns stats and today's schedule for the authenticated doctor. |-------|------|---------|-------------| | `from` | int (unix) | start of current month | Period start for patient/revenue stats | | `to` | int (unix) | now | Period end for patient/revenue stats | +| `patients_year` | int (Jalali) | current Jalali year | Year of the month drawn by `charts.appointments_by_day` | +| `patients_month` | int `1..12` | current Jalali month | Jalali month drawn by `charts.appointments_by_day` | +| `revenue_year` | int (Jalali) | current Jalali year | Jalali year drawn by `charts.revenue_by_month` | ### Response `200` @@ -130,8 +145,10 @@ Returns stats and today's schedule for the authenticated doctor. }, "charts": { "revenue_by_day": [ { "label": "۷ خرداد", "amount_rials": 3200000 } ], - "appointments_by_day": [ { "label": "۷ خرداد", "count": 4 } ] + "revenue_by_month": [ { "label": "فروردین", "amount_rials": 3200000 } ], + "appointments_by_day": [ { "label": "۱", "count": 4 } ] }, + "charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 }, "period": { "from": 1717200000, "to": 1719792000 }, "today_appointments": [ { @@ -161,6 +178,7 @@ Returns stats and today's schedule for the authenticated doctor. - `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 +- `charts.*`, `charts_period` — same Jalali-period semantics as the clinic dashboard; `revenue_by_day` / `revenue_by_month` are omitted entirely when the caller may not see financials (see the note at the end of this file) ### Errors @@ -281,7 +299,7 @@ In a **clinic context** the response is restricted to that clinic: to that clinic; * the financial fields are **omitted entirely** — `revenue_period_rials`, `today_payments_rials`, `week_payments_rials`, `sms_wallet_balance`, `unique_patients_count`, `total_patients`, and - `charts.revenue_by_day`. They describe the doctor's personal practice and have no meaning inside + `charts.revenue_by_day`, and `charts.revenue_by_month`. They describe the doctor's personal practice and have no meaning inside someone else's clinic. They return only in the personal context, or for the clinic's own owner holding `payments.view`; * `clinics` comes back as `[]` — the "کلینیک‌های من" list belongs to the personal dashboard. diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index 9936949d..52186770 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -140,15 +140,23 @@ class DashboardController extends BaseController $revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to); $totalPatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, 0, time()); - $rev = $this->revenueDaily('clinic', $clinicId); - $apptByDay = $this->appointmentsDaily(function (int $ds, int $de) use ($clinicId): int { - return (int) $this->em->createQuery(' - SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a - JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d - JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors - WHERE c.id = :clinicId AND a.slotStart >= :s AND a.slotStart <= :e - ')->setParameters(['clinicId' => $clinicId, 's' => $ds, 'e' => $de])->getSingleScalarResult(); - }); + $chartPeriod = $this->chartPeriodParams($request); + $rev = $this->revenueDaily('clinic', $clinicId); + $revenueByMon = $this->revenueByJalaliYear('clinic', $clinicId, $chartPeriod['revenue_year']); + $apptByDay = $this->appointmentsByJalaliMonth( + $chartPeriod['patients_year'], + $chartPeriod['patients_month'], + function (int $s, int $e) use ($clinicId): array { + $rows = $this->em->createQuery(' + SELECT a.slotStart FROM App\Appointment\Entity\Appointment a + JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d + JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors + WHERE c.id = :clinicId AND a.slotStart >= :s AND a.slotStart <= :e + ')->setParameters(['clinicId' => $clinicId, 's' => $s, 'e' => $e])->getArrayResult(); + + return array_column($rows, 'slotStart'); + } + ); return $this->success([ 'clinic' => [ @@ -171,8 +179,10 @@ class DashboardController extends BaseController ], 'charts' => [ 'revenue_by_day' => $rev['revenue'], + 'revenue_by_month' => $revenueByMon, 'appointments_by_day' => $apptByDay, ], + 'charts_period' => $chartPeriod, 'period' => ['from' => $from, 'to' => $to], 'today_appointments' => $todayAppts, 'doctors' => $doctors, @@ -245,8 +255,11 @@ class DashboardController extends BaseController WHERE d.id = :doctorId ')->setParameter('doctorId', $doctorId)->getArrayResult(); - $apptByDay = $this->appointmentsDaily( - fn(int $ds, int $de): int => $this->countAppointments($doctor, $clinic, $ds, $de) + $chartPeriod = $this->chartPeriodParams($request); + $apptByDay = $this->appointmentsByJalaliMonth( + $chartPeriod['patients_year'], + $chartPeriod['patients_month'], + fn(int $s, int $e): array => $this->appointmentSlotStarts($doctor, $clinic, $s, $e) ); $stats = [ @@ -272,7 +285,8 @@ class DashboardController extends BaseController $stats['today_payments_rials'] = $rev['today_payments_rials']; $stats['week_payments_rials'] = $rev['week_payments_rials']; - $charts['revenue_by_day'] = $rev['revenue']; + $charts['revenue_by_day'] = $rev['revenue']; + $charts['revenue_by_month'] = $this->revenueByJalaliYear('doctor', $doctorId, $chartPeriod['revenue_year']); } return $this->success([ @@ -288,6 +302,7 @@ class DashboardController extends BaseController ], 'stats' => $stats, 'charts' => $charts, + 'charts_period' => $chartPeriod, 'period' => ['from' => $from, 'to' => $to], 'today_appointments' => $todayAppts, // فهرست کلینیک‌ها فقط در محیط شخصی معنا دارد. @@ -315,6 +330,28 @@ class DashboardController extends BaseController return (int) $qb->getQuery()->getSingleScalarResult(); } + /** + * تایم‌استمپ شروع نوبت‌های یک پزشک در بازه — نسخه‌ی لیستی countAppointments + * برای سری‌های روزانه (یک کوئری به جای یکی به ازای هر روز). + * + * @return array + */ + private function appointmentSlotStarts(\App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic, int $from, int $to): array + { + $qb = $this->em->createQueryBuilder() + ->select('a.slotStart') + ->from(\App\Appointment\Entity\Appointment::class, 'a') + ->where('a.doctor = :doctor') + ->andWhere('a.slotStart >= :s AND a.slotStart <= :e') + ->setParameter('doctor', $doctor) + ->setParameter('s', $from) + ->setParameter('e', $to); + + $this->restrictToClinicAddresses($qb, $clinic); + + return array_column($qb->getQuery()->getArrayResult(), 'slotStart'); + } + private function restrictToClinicAddresses(\Doctrine\ORM\QueryBuilder $qb, ?\App\Clinic\Entity\Clinic $clinic): void { if ($clinic === null) { @@ -341,6 +378,133 @@ class DashboardController extends BaseController && $this->permChecker->can($user, $clinic, 'payments', 'view'); } + // ── Jalali chart helpers ──────────────────────────────────────────────── + + /** تقویم شمسی تهران — پایه‌ی همه‌ی محاسبات بازه‌ی نمودارها. */ + private function jalaliCalendar(): \IntlCalendar + { + return \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian'); + } + + /** + * سال/ماه شمسی جاری. + * @return array{0:int, 1:int} [year, month] با ماه ۱..۱۲ + */ + private function currentJalaliYearMonth(): array + { + $cal = $this->jalaliCalendar(); + + return [ + $cal->get(\IntlCalendar::FIELD_YEAR), + $cal->get(\IntlCalendar::FIELD_MONTH) + 1, + ]; + } + + /** + * بازه‌ی یونیکس یک ماه شمسی و تعداد روزهای آن. + * @return array{start:int, end:int, days:int} + */ + private function jalaliMonthRange(int $year, int $month): array + { + $cal = $this->jalaliCalendar(); + $cal->set(\IntlCalendar::FIELD_YEAR, $year); + $cal->set(\IntlCalendar::FIELD_MONTH, $month - 1); + $cal->set(\IntlCalendar::FIELD_DAY_OF_MONTH, 1); + $cal->set(\IntlCalendar::FIELD_HOUR_OF_DAY, 0); + $cal->set(\IntlCalendar::FIELD_MINUTE, 0); + $cal->set(\IntlCalendar::FIELD_SECOND, 0); + $cal->set(\IntlCalendar::FIELD_MILLISECOND, 0); + + $start = (int) ($cal->getTime() / 1000); + $days = $cal->getActualMaximum(\IntlCalendar::FIELD_DAY_OF_MONTH); + + return ['start' => $start, 'end' => $start + $days * 86400 - 1, 'days' => $days]; + } + + /** + * سری تعداد نوبت به تفکیک روزهای یک ماه شمسی (نمودار «تعداد بیماران»). + * روزهای بدون نوبت صفر می‌مانند تا طول سری برابر طول ماه باشد. + * + * `$fetchSlotStarts` باید تایم‌استمپ شروع همه‌ی نوبت‌های بازه را برگرداند — + * یک کوئری برای کل ماه، نه یکی به ازای هر روز. + * + * @param callable(int $from, int $to): array $fetchSlotStarts + * @return array + */ + private function appointmentsByJalaliMonth(int $year, int $month, callable $fetchSlotStarts): array + { + $range = $this->jalaliMonthRange($year, $month); + $buckets = array_fill(0, $range['days'], 0); + $dayFmt = new \IntlDateFormatter( + 'fa_IR@calendar=persian', + \IntlDateFormatter::NONE, + \IntlDateFormatter::NONE, + 'Asia/Tehran', + \IntlDateFormatter::TRADITIONAL, + 'd' + ); + + foreach ($fetchSlotStarts($range['start'], $range['end']) as $slotStart) { + $day = intdiv($slotStart - $range['start'], 86400); + if ($day >= 0 && $day < $range['days']) { + $buckets[$day]++; + } + } + + $series = []; + foreach ($buckets as $day => $count) { + $series[] = ['label' => $dayFmt->format($range['start'] + $day * 86400), 'count' => $count]; + } + + return $series; + } + + /** + * سری درآمد به تفکیک ۱۲ ماه یک سال شمسی (نمودار «میزان درآمد»). + * @return array + */ + private function revenueByJalaliYear(string $entityType, int $entityId, int $year): array + { + $fmt = new \IntlDateFormatter( + 'fa_IR@calendar=persian', + \IntlDateFormatter::NONE, + \IntlDateFormatter::NONE, + 'Asia/Tehran', + \IntlDateFormatter::TRADITIONAL, + 'MMMM' + ); + + $series = []; + for ($m = 1; $m <= 12; $m++) { + $range = $this->jalaliMonthRange($year, $m); + $series[] = [ + 'label' => $fmt->format($range['start']), + 'amount_rials' => (int) $this->patientSessionRepo->sumRevenue($entityType, $entityId, $range['start'], $range['end']), + ]; + } + + return $series; + } + + /** + * پارامترهای بازه‌ی نمودارها از query string، با fallback به دوره‌ی جاری شمسی. + * @return array{patients_year:int, patients_month:int, revenue_year:int} + */ + private function chartPeriodParams(Request $request): array + { + [$curYear, $curMonth] = $this->currentJalaliYearMonth(); + + $patientsYear = (int) ($request->query->get('patients_year') ?: $curYear); + $patientsMonth = (int) ($request->query->get('patients_month') ?: $curMonth); + $revenueYear = (int) ($request->query->get('revenue_year') ?: $curYear); + + return [ + 'patients_year' => max(1300, min(1500, $patientsYear)), + 'patients_month' => max(1, min(12, $patientsMonth)), + 'revenue_year' => max(1300, min(1500, $revenueYear)), + ]; + } + /** * سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته. * @return array{revenue: array, today_payments_rials:int, week_payments_rials:int} @@ -362,23 +526,6 @@ class DashboardController extends BaseController return ['revenue' => $series, 'today_payments_rials' => $todayPay, 'week_payments_rials' => $weekPay]; } - /** - * سری ۷ روز اخیر تعداد نوبت با شمارنده‌ی دلخواه (doctor/clinic). - * @param callable(int $dayStart, int $dayEnd): int $counter - * @return array - */ - private function appointmentsDaily(callable $counter): array - { - $fmt = new \IntlDateFormatter('fa_IR@calendar=persian', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'Asia/Tehran', \IntlDateFormatter::TRADITIONAL, 'd MMMM'); - $series = []; - for ($i = 6; $i >= 0; $i--) { - $ds = strtotime('today midnight') - $i * 86400; - $de = $ds + 86399; - $series[] = ['label' => $fmt->format($ds), 'count' => $counter($ds, $de)]; - } - return $series; - } - // ── Secretary Dashboard ────────────────────────────────────────────────── #[Route('/api/v1/dashboard/secretary', methods: ['GET'])] diff --git a/tests/Dashboard/DashboardChartPeriodTest.php b/tests/Dashboard/DashboardChartPeriodTest.php new file mode 100644 index 00000000..cbd7bbe5 --- /dev/null +++ b/tests/Dashboard/DashboardChartPeriodTest.php @@ -0,0 +1,131 @@ +get(\IntlCalendar::FIELD_YEAR), $cal->get(\IntlCalendar::FIELD_MONTH) + 1]; + } + + private function jalaliMonthLength(int $year, int $month): int + { + $cal = \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian'); + $cal->set(\IntlCalendar::FIELD_YEAR, $year); + $cal->set(\IntlCalendar::FIELD_MONTH, $month - 1); + $cal->set(\IntlCalendar::FIELD_DAY_OF_MONTH, 1); + + return $cal->getActualMaximum(\IntlCalendar::FIELD_DAY_OF_MONTH); + } + + private function clinicWithDoctor(): array + { + $clinicOwner = $this->createUser(['ROLE_CLINIC']); + $clinic = new Clinic($clinicOwner); + $clinic->setName('کلینیک نمودار'); + + $docOwner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($docOwner, 'حمیدی'); + $clinic->getDoctors()->add($doctor); + + $this->em->persist($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + return [$clinicOwner, $doctor]; + } + + public function testClinicChartsDefaultToCurrentJalaliPeriod(): void + { + [$owner] = $this->clinicWithDoctor(); + [$year, $month] = $this->currentJalali(); + + $res = $this->authJson('GET', '/api/v1/dashboard/clinic', $owner); + self::assertSame(200, $this->responseCode()); + + self::assertSame( + ['patients_year' => $year, 'patients_month' => $month, 'revenue_year' => $year], + $res['data']['charts_period'] + ); + self::assertCount($this->jalaliMonthLength($year, $month), $res['data']['charts']['appointments_by_day']); + self::assertCount(12, $res['data']['charts']['revenue_by_month']); + } + + public function testClinicChartsHonourRequestedPeriod(): void + { + [$owner] = $this->clinicWithDoctor(); + + // اسفند ۱۴۰۳ سال کبیسه است → ۳۰ روز + $res = $this->authJson('GET', '/api/v1/dashboard/clinic?patients_year=1403&patients_month=12&revenue_year=1402', $owner); + self::assertSame(200, $this->responseCode()); + + self::assertSame( + ['patients_year' => 1403, 'patients_month' => 12, 'revenue_year' => 1402], + $res['data']['charts_period'] + ); + self::assertCount(30, $res['data']['charts']['appointments_by_day']); + } + + public function testTodayAppointmentIsCountedOnItsJalaliDay(): void + { + [$owner, $doctor] = $this->clinicWithDoctor(); + $patient = $this->createUser(['ROLE_USER']); + + $start = time(); + $this->em->persist(new Appointment($doctor, $patient, $start, $start + 1_800)); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/dashboard/clinic', $owner); + $days = $res['data']['charts']['appointments_by_day']; + + $cal = \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian'); + $todayIndex = $cal->get(\IntlCalendar::FIELD_DAY_OF_MONTH) - 1; + + self::assertGreaterThanOrEqual(1, $days[$todayIndex]['count'], 'today\'s slot must land on today\'s bar'); + } + + public function testOutOfRangeParamsAreClamped(): void + { + [$owner] = $this->clinicWithDoctor(); + [$year] = $this->currentJalali(); + + $res = $this->authJson('GET', '/api/v1/dashboard/clinic?patients_month=99&patients_year=9999&revenue_year=0', $owner); + self::assertSame(200, $this->responseCode()); + + $period = $res['data']['charts_period']; + self::assertSame(12, $period['patients_month']); + self::assertSame(1500, $period['patients_year']); + // revenue_year=0 is falsy → falls back to the current Jalali year + self::assertSame($year, $period['revenue_year']); + } + + public function testDoctorChartsUseSameJalaliPeriod(): void + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'حمیدی'); + $this->em->persist($doctor); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/dashboard/doctor?patients_year=1403&patients_month=1', $owner); + self::assertSame(200, $this->responseCode()); + + self::assertSame(1403, $res['data']['charts_period']['patients_year']); + self::assertCount(31, $res['data']['charts']['appointments_by_day']); + } +}