feat: Implement Jalali calendar support for dashboard charts
- Added support for Jalali calendar in the dashboard, allowing charts to display data based on the current Jalali month and year. - Updated the API to return `patients_year`, `patients_month`, and `revenue_year` parameters for the dashboard charts. - Refactored the dashboard controller to handle Jalali date calculations and queries. - Modified the frontend components to utilize the new Jalali date parameters and reflect changes in the UI. - Removed the status column from the NewAppointmentsTable as status management is now handled on the appointments page. - Added tests to ensure the correct functioning of the new Jalali chart period features.
This commit is contained in:
@@ -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<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 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
|
||||
<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"
|
||||
|
||||
@@ -11,15 +11,19 @@ import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
|
||||
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
|
||||
/** small cosmetic dropdown — mirrors source SmSelector (does not drive data) */
|
||||
function SmSelector({ options }: { options: string[] }) {
|
||||
const [value, setValue] = React.useState<string | number | null>(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 (
|
||||
<div style={{ minWidth: 110 }}>
|
||||
<SearchableSelect
|
||||
options={options.map((o) => ({ value: o, label: o }))}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onChange={(v) => onChange(Number(v))}
|
||||
height={34}
|
||||
/>
|
||||
</div>
|
||||
@@ -30,20 +34,41 @@ 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 }) {
|
||||
function ChartCard({
|
||||
title,
|
||||
selectorOptions,
|
||||
selectorValue,
|
||||
onSelectorChange,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
selectorOptions: SelectorOption[];
|
||||
selectorValue: number;
|
||||
onSelectorChange: (v: number) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full 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} />
|
||||
<SmSelector options={selectorOptions} value={selectorValue} onChange={onSelectorChange} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-stretch gap-y-[24px] w-full">
|
||||
{/* 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 */}
|
||||
<div className="grid-2 w-full">
|
||||
<ChartCard title="نمودار تعداد بیماران" selectorOptions={MONTHS}>
|
||||
<ChartCard
|
||||
title="نمودار تعداد بیماران"
|
||||
selectorOptions={MONTH_OPTIONS}
|
||||
selectorValue={patientsMonth}
|
||||
onSelectorChange={onPatientsMonthChange}
|
||||
>
|
||||
<TauriBarChart data={patientBars} />
|
||||
</ChartCard>
|
||||
<ChartCard title="میزان درآمد" selectorOptions={YEARS}>
|
||||
<ChartCard
|
||||
title="میزان درآمد"
|
||||
selectorOptions={yearOpts}
|
||||
selectorValue={revenueYear}
|
||||
onSelectorChange={onRevenueYearChange}
|
||||
>
|
||||
<TauriLineChart data={incomeLine} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
@@ -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(<DashboardPage />, { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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<ApiResponse<ClinicDashboardData>>(`/api/v1/dashboard/clinic?from=${range.from}&to=${range.to}`),
|
||||
queryKey: ['dashboard-clinic', preset, chartPeriod.query],
|
||||
queryFn: () => api.get<ApiResponse<ClinicDashboardData>>(`/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<ApiResponse<DoctorDashboardData>>(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`),
|
||||
queryKey: ['dashboard-doctor', preset, chartPeriod.query],
|
||||
queryFn: () => api.get<ApiResponse<DoctorDashboardData>>(`/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}
|
||||
/>
|
||||
|
||||
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
||||
|
||||
+23
-5
@@ -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.
|
||||
|
||||
@@ -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<int, int>
|
||||
*/
|
||||
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<int, int> $fetchSlotStarts
|
||||
* @return array<int, array{label:string, count:int}>
|
||||
*/
|
||||
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<int, array{label:string, amount_rials:int}>
|
||||
*/
|
||||
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<int, array{label:string, amount_rials:int}>, 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<int, array{label:string, count:int}>
|
||||
*/
|
||||
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'])]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Dashboard;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/dashboard/{clinic,doctor} — chart series periods.
|
||||
*
|
||||
* «نمودار تعداد بیماران» is one Jalali month (day-by-day) and «میزان درآمد» is
|
||||
* one Jalali year (month-by-month); both default to the current Jalali period
|
||||
* and are overridable via patients_year / patients_month / revenue_year.
|
||||
*/
|
||||
class DashboardChartPeriodTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0:int, 1:int} [jalaliYear, jalaliMonth] */
|
||||
private function currentJalali(): array
|
||||
{
|
||||
$cal = \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian');
|
||||
|
||||
return [$cal->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user