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:
hamed
2026-07-18 21:44:01 +03:30
parent 7921407f33
commit 62a9fd87c3
7 changed files with 479 additions and 100 deletions
@@ -3,12 +3,11 @@
* `src/components/dashboard/list/` (CustomTable + DetailT + Status). * `src/components/dashboard/list/` (CustomTable + DetailT + Status).
* *
* Source data was static mock; here it is fed by the real dashboard API * Source data was static mock; here it is fed by the real dashboard API
* (`/api/v1/dashboard/{clinic,doctor}` → `today_appointments`). Statuses are * (`/api/v1/dashboard/{clinic,doctor}` → `today_appointments`). Read-only —
* clinicpro's real appointment statuses, mapped to the source's pill palette. * status display/editing lives on the appointments page, not the dashboard.
*/ */
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ChevronDownIcon } from './dashboardIcons';
export interface ApptRow { export interface ApptRow {
uuid: string; uuid: string;
@@ -21,50 +20,12 @@ export interface ApptRow {
status: string; 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 { function formatTime(ts?: number | null): string {
if (!ts) return '—'; if (!ts) return '—';
return new Intl.DateTimeFormat('fa-IR', { timeZone: 'Asia/Tehran', hour: '2-digit', minute: '2-digit' }).format(new Date(ts * 1000)); 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 }) { export function NewAppointmentsTable({ rows, loading }: { rows: ApptRow[]; loading?: boolean }) {
if (loading) { 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" dir="ltr">{formatTime(r.slot_end)}</Cell>
<Cell className="text-right">{r.service_name || '—'}</Cell> <Cell className="text-right">{r.service_name || '—'}</Cell>
<Cell className="text-start">{r.doctor_name ? `دکتر ${r.doctor_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"> <td className="py-[10px] px-[18px] text-center">
<Link <Link
to="/admin/appointments" to="/admin/appointments"
@@ -11,15 +11,19 @@ import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable'; import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
import SearchableSelect from '../ui/SearchableSelect'; import SearchableSelect from '../ui/SearchableSelect';
/** small cosmetic dropdown — mirrors source SmSelector (does not drive data) */ export interface SelectorOption {
function SmSelector({ options }: { options: string[] }) { value: number;
const [value, setValue] = React.useState<string | number | null>(options[0] ?? null); label: string;
}
/** dropdown دوره‌ی نمودار — مقدارش داده‌ی نمودار را عوض می‌کند */
function SmSelector({ options, value, onChange }: { options: SelectorOption[]; value: number; onChange: (v: number) => void }) {
return ( return (
<div style={{ minWidth: 110 }}> <div style={{ minWidth: 110 }}>
<SearchableSelect <SearchableSelect
options={options.map((o) => ({ value: o, label: o }))} options={options}
value={value} value={value}
onChange={setValue} onChange={(v) => onChange(Number(v))}
height={34} height={34}
/> />
</div> </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>; 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 ( return (
<div className="w-full rounded-[8px] border border-solid border-[#EFEFEF] dark:border-transparent bg-[#FFF] dark:bg-[#222433]"> <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]"> <div className="flex px-[20px] py-[12px] md:py-[14px] lg:py-[16px] items-center justify-between gap-[12px]">
<ChartTitle text={title} /> <ChartTitle text={title} />
<SmSelector options={selectorOptions} /> <SmSelector options={selectorOptions} value={selectorValue} onChange={onSelectorChange} />
</div> </div>
{children} {children}
</div> </div>
); );
} }
const MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']; export const JALALI_MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
const YEARS = ['سال ۱۴۰۳', 'سال ۱۴۰۲', 'سال ۱۴۰۱', 'سال ۱۴۰۰'];
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 { export interface TauriDashboardViewProps {
stats: DashboardStats; stats: DashboardStats;
@@ -55,6 +80,14 @@ export interface TauriDashboardViewProps {
loading: boolean; loading: boolean;
formatNumber: (n: number) => string; formatNumber: (n: number) => string;
formatRial: (rial: number) => string; formatRial: (rial: number) => string;
/** ماه شمسی نمودار بیماران (۱..۱۲) */
patientsMonth: number;
onPatientsMonthChange: (m: number) => void;
/** سال شمسی نمودار درآمد */
revenueYear: number;
onRevenueYearChange: (y: number) => void;
/** سال شمسی جاری — مبنای گزینه‌های سلکتور سال */
currentJalaliYear: number;
} }
export function TauriDashboardView({ export function TauriDashboardView({
@@ -65,7 +98,13 @@ export function TauriDashboardView({
loading, loading,
formatNumber, formatNumber,
formatRial, formatRial,
patientsMonth,
onPatientsMonthChange,
revenueYear,
onRevenueYearChange,
currentJalaliYear,
}: TauriDashboardViewProps) { }: TauriDashboardViewProps) {
const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]);
return ( return (
<div className="flex flex-col items-stretch gap-y-[24px] w-full"> <div className="flex flex-col items-stretch gap-y-[24px] w-full">
{/* Cards */} {/* Cards */}
@@ -74,10 +113,20 @@ export function TauriDashboardView({
{/* Charts — grid-2 (1fr 1fr, collapses to 1fr on narrow) for reliable {/* Charts — grid-2 (1fr 1fr, collapses to 1fr on narrow) for reliable
full-width equal columns, matching the rest of the admin dashboard */} full-width equal columns, matching the rest of the admin dashboard */}
<div className="grid-2 w-full"> <div className="grid-2 w-full">
<ChartCard title="نمودار تعداد بیماران" selectorOptions={MONTHS}> <ChartCard
title="نمودار تعداد بیماران"
selectorOptions={MONTH_OPTIONS}
selectorValue={patientsMonth}
onSelectorChange={onPatientsMonthChange}
>
<TauriBarChart data={patientBars} /> <TauriBarChart data={patientBars} />
</ChartCard> </ChartCard>
<ChartCard title="میزان درآمد" selectorOptions={YEARS}> <ChartCard
title="میزان درآمد"
selectorOptions={yearOpts}
selectorValue={revenueYear}
onSelectorChange={onRevenueYearChange}
>
<TauriLineChart data={incomeLine} /> <TauriLineChart data={incomeLine} />
</ChartCard> </ChartCard>
</div> </div>
+36 -4
View File
@@ -10,6 +10,10 @@ vi.mock('../lib/api', () => ({
import { api } from '../lib/api'; import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import DashboardPage from './DashboardPage'; 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>; const get = api.get as ReturnType<typeof vi.fn>;
@@ -33,12 +37,18 @@ const clinicPayload = {
{ label: '۸ خرداد', amount_rials: 160_000 }, { label: '۸ خرداد', amount_rials: 160_000 },
{ label: '۹ خرداد', amount_rials: 90_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: [ appointments_by_day: [
{ label: '۷ خرداد', count: 15 }, { label: '۱', count: 15 },
{ label: '۸ خرداد', count: 40 }, { label: '۲', count: 40 },
{ label: '۹ خرداد', count: 48 }, { label: '۳', count: 48 },
], ],
}, },
charts_period: { patients_year: 1405, patients_month: 4, revenue_year: 1405 },
today_appointments: [ today_appointments: [
{ {
uuid: 'appt-1', uuid: 'appt-1',
@@ -84,7 +94,10 @@ describe('DashboardPage (ported clinic dashboard)', () => {
expect(screen.getByText('دنیا خلیلی')).toBeInTheDocument(); expect(screen.getByText('دنیا خلیلی')).toBeInTheDocument();
expect(screen.getByText('09136549874')).toBeInTheDocument(); expect(screen.getByText('09136549874')).toBeInTheDocument();
expect(screen.getByText('ویزیت عمومی')).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 () => { it('calls the real clinic dashboard endpoint', async () => {
@@ -92,4 +105,23 @@ describe('DashboardPage (ported clinic dashboard)', () => {
await screen.findByText('تعداد کل مراجعین'); await screen.findByText('تعداد کل مراجعین');
expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/dashboard/clinic')); 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();
});
}); });
+50 -6
View File
@@ -13,9 +13,37 @@ import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import InviteDoctorModal from '../components/ui/InviteDoctorModal'; import InviteDoctorModal from '../components/ui/InviteDoctorModal';
import { TauriDashboardView } from '../components/dashboard/TauriDashboardView'; 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 { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable';
import { usePermissions } from '../hooks/usePermissions'; 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 ──────────────────────────────────────────────────── // ── Shared Status Maps ────────────────────────────────────────────────────
const APPT_LABEL: Record<string, string> = { const APPT_LABEL: Record<string, string> = {
@@ -614,9 +642,10 @@ function ClinicDashboard() {
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const preset: DatePreset = 'this_month'; const preset: DatePreset = 'this_month';
const range = getRange(preset); const range = getRange(preset);
const chartPeriod = useJalaliChartPeriod();
const q = useQuery({ const q = useQuery({
queryKey: ['dashboard-clinic', preset], queryKey: ['dashboard-clinic', preset, chartPeriod.query],
queryFn: () => api.get<ApiResponse<ClinicDashboardData>>(`/api/v1/dashboard/clinic?from=${range.from}&to=${range.to}`), queryFn: () => api.get<ApiResponse<ClinicDashboardData>>(`/api/v1/dashboard/clinic?from=${range.from}&to=${range.to}&${chartPeriod.query}`),
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -636,11 +665,16 @@ function ClinicDashboard() {
todayAppointments: d?.stats.today_appointments ?? 0, todayAppointments: d?.stats.today_appointments ?? 0,
}} }}
patientBars={(d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }))} 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 ?? []} appointments={d?.today_appointments ?? []}
loading={q.isFetching} loading={q.isFetching}
formatNumber={formatNumber} formatNumber={formatNumber}
formatRial={formatRial} formatRial={formatRial}
patientsMonth={chartPeriod.patientsMonth}
onPatientsMonthChange={chartPeriod.setPatientsMonth}
revenueYear={chartPeriod.revenueYear}
onRevenueYearChange={chartPeriod.setRevenueYear}
currentJalaliYear={chartPeriod.currentJalaliYear}
/> />
{/* پزشکان کلینیک — بخش عملکردی موجود clinicpro، حفظ‌شده زیر نمای منتقل‌شده */} {/* پزشکان کلینیک — بخش عملکردی موجود clinicpro، حفظ‌شده زیر نمای منتقل‌شده */}
@@ -798,7 +832,11 @@ function DoctorClinicInvitationsCard() {
// ── Doctor Dashboard ────────────────────────────────────────────────────── // ── Doctor Dashboard ──────────────────────────────────────────────────────
interface DashboardCharts { interface DashboardCharts {
/** ۷ روز اخیر — فقط برای کارت‌های آماری */
revenue_by_day: { label: string; amount_rials: number }[]; revenue_by_day: { label: string; amount_rials: number }[];
/** ۱۲ ماه سال شمسی انتخاب‌شده — نمودار «میزان درآمد» */
revenue_by_month?: { label: string; amount_rials: number }[];
/** روزهای ماه شمسی انتخاب‌شده — نمودار «تعداد بیماران» */
appointments_by_day: { label: string; count: number }[]; appointments_by_day: { label: string; count: number }[];
} }
@@ -818,9 +856,10 @@ interface DoctorDashboardData {
function DoctorDashboard() { function DoctorDashboard() {
const preset: DatePreset = 'this_month'; const preset: DatePreset = 'this_month';
const range = getRange(preset); const range = getRange(preset);
const chartPeriod = useJalaliChartPeriod();
const q = useQuery({ const q = useQuery({
queryKey: ['dashboard-doctor', preset], queryKey: ['dashboard-doctor', preset, chartPeriod.query],
queryFn: () => api.get<ApiResponse<DoctorDashboardData>>(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`), queryFn: () => api.get<ApiResponse<DoctorDashboardData>>(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}&${chartPeriod.query}`),
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -839,11 +878,16 @@ function DoctorDashboard() {
todayAppointments: d?.stats.today_appointments ?? 0, todayAppointments: d?.stats.today_appointments ?? 0,
}} }}
patientBars={(d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }))} 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 ?? []} appointments={d?.today_appointments ?? []}
loading={q.isFetching} loading={q.isFetching}
formatNumber={formatNumber} formatNumber={formatNumber}
formatRial={formatRial} 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)' }}> <div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
+23 -5
View File
@@ -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 | | `from` | int (unix) | start of current month | Period start for patient/revenue stats |
| `to` | int (unix) | now | Period end 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` ### Response `200`
@@ -45,10 +50,14 @@ Returns stats and today's schedule for the authenticated clinic owner.
"revenue_by_day": [ "revenue_by_day": [
{ "label": "۷ خرداد", "amount_rials": 3200000 } { "label": "۷ خرداد", "amount_rials": 3200000 }
], ],
"revenue_by_month": [
{ "label": "فروردین", "amount_rials": 3200000 }
],
"appointments_by_day": [ "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 }, "period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [ "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) - `total_patients` — distinct patients ever (no period filter)
- `revenue_period_rials` — sum of `final_price_rials` from all patient sessions in the period - `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 - `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 - `charts.appointments_by_day` — «نمودار تعداد بیماران»: one entry per day of the requested Jalali month (2831 entries), label = Jalali day number in Persian digits, empty days are `count: 0`
- `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.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 - `doctors` — all doctors belonging to this clinic; each includes their appointment count for today
### Errors ### 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 | | `from` | int (unix) | start of current month | Period start for patient/revenue stats |
| `to` | int (unix) | now | Period end 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` ### Response `200`
@@ -130,8 +145,10 @@ Returns stats and today's schedule for the authenticated doctor.
}, },
"charts": { "charts": {
"revenue_by_day": [ { "label": "۷ خرداد", "amount_rials": 3200000 } ], "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 }, "period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [ "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 - `avg_rating` — rounded to 1 decimal; `null` if no ratings yet
- `clinics` — all clinics the doctor belongs to - `clinics` — all clinics the doctor belongs to
- `sms_wallet_balance`, `unique_patients_count`, `revenue_period_rials` — same semantics as clinic dashboard - `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 ### Errors
@@ -281,7 +299,7 @@ In a **clinic context** the response is restricted to that clinic:
to that clinic; to that clinic;
* the financial fields are **omitted entirely** — `revenue_period_rials`, `today_payments_rials`, * the financial fields are **omitted entirely** — `revenue_period_rials`, `today_payments_rials`,
`week_payments_rials`, `sms_wallet_balance`, `unique_patients_count`, `total_patients`, and `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 someone else's clinic. They return only in the personal context, or for the clinic's own owner
holding `payments.view`; holding `payments.view`;
* `clinics` comes back as `[]` — the "کلینیک‌های من" list belongs to the personal dashboard. * `clinics` comes back as `[]` — the "کلینیک‌های من" list belongs to the personal dashboard.
+176 -29
View File
@@ -140,15 +140,23 @@ class DashboardController extends BaseController
$revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to); $revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to);
$totalPatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, 0, time()); $totalPatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, 0, time());
$rev = $this->revenueDaily('clinic', $clinicId); $chartPeriod = $this->chartPeriodParams($request);
$apptByDay = $this->appointmentsDaily(function (int $ds, int $de) use ($clinicId): int { $rev = $this->revenueDaily('clinic', $clinicId);
return (int) $this->em->createQuery(' $revenueByMon = $this->revenueByJalaliYear('clinic', $clinicId, $chartPeriod['revenue_year']);
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a $apptByDay = $this->appointmentsByJalaliMonth(
JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d $chartPeriod['patients_year'],
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors $chartPeriod['patients_month'],
WHERE c.id = :clinicId AND a.slotStart >= :s AND a.slotStart <= :e function (int $s, int $e) use ($clinicId): array {
')->setParameters(['clinicId' => $clinicId, 's' => $ds, 'e' => $de])->getSingleScalarResult(); $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([ return $this->success([
'clinic' => [ 'clinic' => [
@@ -171,8 +179,10 @@ class DashboardController extends BaseController
], ],
'charts' => [ 'charts' => [
'revenue_by_day' => $rev['revenue'], 'revenue_by_day' => $rev['revenue'],
'revenue_by_month' => $revenueByMon,
'appointments_by_day' => $apptByDay, 'appointments_by_day' => $apptByDay,
], ],
'charts_period' => $chartPeriod,
'period' => ['from' => $from, 'to' => $to], 'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts, 'today_appointments' => $todayAppts,
'doctors' => $doctors, 'doctors' => $doctors,
@@ -245,8 +255,11 @@ class DashboardController extends BaseController
WHERE d.id = :doctorId WHERE d.id = :doctorId
')->setParameter('doctorId', $doctorId)->getArrayResult(); ')->setParameter('doctorId', $doctorId)->getArrayResult();
$apptByDay = $this->appointmentsDaily( $chartPeriod = $this->chartPeriodParams($request);
fn(int $ds, int $de): int => $this->countAppointments($doctor, $clinic, $ds, $de) $apptByDay = $this->appointmentsByJalaliMonth(
$chartPeriod['patients_year'],
$chartPeriod['patients_month'],
fn(int $s, int $e): array => $this->appointmentSlotStarts($doctor, $clinic, $s, $e)
); );
$stats = [ $stats = [
@@ -272,7 +285,8 @@ class DashboardController extends BaseController
$stats['today_payments_rials'] = $rev['today_payments_rials']; $stats['today_payments_rials'] = $rev['today_payments_rials'];
$stats['week_payments_rials'] = $rev['week_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([ return $this->success([
@@ -288,6 +302,7 @@ class DashboardController extends BaseController
], ],
'stats' => $stats, 'stats' => $stats,
'charts' => $charts, 'charts' => $charts,
'charts_period' => $chartPeriod,
'period' => ['from' => $from, 'to' => $to], 'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts, 'today_appointments' => $todayAppts,
// فهرست کلینیک‌ها فقط در محیط شخصی معنا دارد. // فهرست کلینیک‌ها فقط در محیط شخصی معنا دارد.
@@ -315,6 +330,28 @@ class DashboardController extends BaseController
return (int) $qb->getQuery()->getSingleScalarResult(); 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 private function restrictToClinicAddresses(\Doctrine\ORM\QueryBuilder $qb, ?\App\Clinic\Entity\Clinic $clinic): void
{ {
if ($clinic === null) { if ($clinic === null) {
@@ -341,6 +378,133 @@ class DashboardController extends BaseController
&& $this->permChecker->can($user, $clinic, 'payments', 'view'); && $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) + پرداختی امروز و هفته. * سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته.
* @return array{revenue: array<int, array{label:string, amount_rials:int}>, today_payments_rials:int, week_payments_rials:int} * @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]; 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 ────────────────────────────────────────────────── // ── Secretary Dashboard ──────────────────────────────────────────────────
#[Route('/api/v1/dashboard/secretary', methods: ['GET'])] #[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']);
}
}