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>
|
||||
|
||||
Reference in New Issue
Block a user