Files
clinicpro/assets/admin/components/dashboard/TauriDashboardView.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

165 lines
6.5 KiB
TypeScript

/**
* Ported clinic/doctor dashboard view — pixel-for-pixel from clinic-pro-tauri
* `src/components/dashboard/index.jsx` (Cards → Charts → New-appointments list,
* stacked with 24px gaps). Purely presentational; both ClinicDashboard and
* DoctorDashboard feed it their (real-API) data.
*/
import React from 'react';
import { Link } from 'react-router';
import { TauriStatCards, type DashboardStats } from './TauriStatCards';
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
import SearchableSelect from '../ui/SearchableSelect';
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}
value={value}
onChange={(v) => onChange(Number(v))}
height={34}
/>
</div>
);
}
function ChartTitle({ text }: { text: string }) {
return <p className="text-[var(--text)] text-[14px] md:text-[16px] font-bold">{text}</p>;
}
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-[var(--border)] dark:border-transparent bg-[var(--surface)]">
<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} value={selectorValue} onChange={onSelectorChange} />
</div>
{children}
</div>
);
}
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;
/** «نمودار تعداد بیماران» series (appointments_by_day) */
patientBars: ChartPoint[];
/** «میزان درآمد» series (revenue_by_day) */
incomeLine: ChartPoint[];
appointments: ApptRow[];
/** جایگزین جدول ساده — برای نمایش نسخهٔ فیلتردار/قابل‌ویرایش. */
appointmentsSlot?: React.ReactNode;
loading: boolean;
formatNumber: (n: number) => string;
formatRial: (rial: number) => string;
/** ماه شمسی نمودار بیماران (۱..۱۲) */
patientsMonth: number;
onPatientsMonthChange: (m: number) => void;
/** سال شمسی نمودار درآمد */
revenueYear: number;
onRevenueYearChange: (y: number) => void;
/** سال شمسی جاری — مبنای گزینه‌های سلکتور سال */
currentJalaliYear: number;
/** ماه شمسی جاری (۱..۱۲) — مرز دادهٔ واقعی و پیش‌بینی در نمودار درآمد */
currentJalaliMonth: number;
}
export function TauriDashboardView({
stats,
patientBars,
incomeLine,
appointments,
appointmentsSlot,
loading,
formatNumber,
formatRial,
patientsMonth,
onPatientsMonthChange,
revenueYear,
onRevenueYearChange,
currentJalaliYear,
currentJalaliMonth,
}: TauriDashboardViewProps) {
const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]);
// در سالِ جاری فقط تا ماه جاری داده‌ی واقعی وجود دارد؛ بقیه پیش‌بینی می‌شود.
// سال‌های گذشته کامل‌اند و پیش‌بینی ندارند.
const incomeActualCount = revenueYear === currentJalaliYear ? currentJalaliMonth : undefined;
return (
<div className="flex flex-col items-stretch gap-y-[24px] w-full">
{/* Cards */}
<TauriStatCards stats={stats} formatNumber={formatNumber} formatRial={formatRial} />
{/* 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={MONTH_OPTIONS}
selectorValue={patientsMonth}
onSelectorChange={onPatientsMonthChange}
>
<TauriBarChart data={patientBars} />
</ChartCard>
<ChartCard
title="میزان درآمد"
selectorOptions={yearOpts}
selectorValue={revenueYear}
onSelectorChange={onRevenueYearChange}
>
<TauriLineChart data={incomeLine} actualCount={incomeActualCount} />
</ChartCard>
</div>
{/* New appointments list */}
<div className="w-full rounded-[8px] border border-solid border-transparent md:border-[var(--border)] dark:border-transparent bg-transparent md:bg-[var(--surface)] md:dark:bg-[var(--surface)]">
<div className="md:pt-[18px] md:px-[24px] md:pb-[20px] flex items-center justify-between">
<p className="text-[var(--text)] text-[14px] md:text-[16px] font-bold">لیست نوبت‌های جدید</p>
<Link
to="/admin/appointments"
className="flex items-center gap-[4px] p-1 text-[12px] md:text-[14px] text-[var(--text-2)] font-normal hover:text-[var(--primary)]"
>
نوبت‌ها
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M15 6L9 12L15 18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
</div>
<div className="px-0 md:px-[24px] pb-[16px]">
{/* والد می‌تواند نسخهٔ فیلتردار را جای جدول ساده بنشاند. */}
{appointmentsSlot ?? <NewAppointmentsTable rows={appointments} loading={loading} />}
</div>
</div>
</div>
);
}