diff --git a/assets/admin/components/dashboard/TauriCharts.test.tsx b/assets/admin/components/dashboard/TauriCharts.test.tsx index ee8a0e68..59a484bc 100644 --- a/assets/admin/components/dashboard/TauriCharts.test.tsx +++ b/assets/admin/components/dashboard/TauriCharts.test.tsx @@ -88,4 +88,80 @@ describe('TauriLineChart', () => { expect(screen.queryByText(EMPTY)).not.toBeInTheDocument(); expect(container.querySelector('svg path')).toBeTruthy(); }); + + /** سبک «gradient line»: خط از گرادیان افقی رنگ می‌گیرد، نه یک رنگ ثابت. */ + it('paints the line with the horizontal gradient and a glow', () => { + const { container } = render(); + const line = container.querySelectorAll('svg path')[1] as SVGPathElement; + + expect(line.getAttribute('stroke')).toBe('url(#tdIncomeStroke)'); + expect(line.getAttribute('filter')).toBe('url(#tdIncomeGlow)'); + expect(container.querySelector('#tdIncomeStroke')).toBeTruthy(); + expect(container.querySelector('#tdIncomeGlow feDropShadow')).toBeTruthy(); + }); + + /** نقطه و تولتیپ برای هر داده ساخته می‌شود و با هاور نمایان می‌شود. */ + it('renders a hover marker and value tooltip per point', () => { + const { container } = render(); + + expect(container.querySelectorAll('.td-hit')).toHaveLength(real.length); + expect(container.querySelectorAll('.td-dot')).toHaveLength(real.length); + // مقدارها با ارقام فارسی در تولتیپ + expect(screen.getByText('۴۸')).toBeInTheDocument(); + }); +}); + +/** + * ماه‌های نیامده صفر برمی‌گردند؛ رسم‌کردنشان یک خط صاف تا انتهای سال می‌ساخت. + * از آخرین دادهٔ واقعی به بعد باید روند نقطه‌چین ادامه پیدا کند. + */ +describe('TauriLineChart — ادامهٔ پیش‌بینی', () => { + /** ۴ ماه واقعیِ صعودی + ۸ ماه نیامده (صفر) */ + const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({ + label: `ماه ${i + 1}`, + value: i < 4 ? (i + 1) * 10 : 0, + })); + + it('بدون actualCount خط پیش‌بینی ندارد (رفتار قبلی حفظ می‌شود)', () => { + const { container } = render(); + expect(container.querySelector('.td-forecast')).toBeNull(); + expect(screen.queryByText('نقطه‌چین: پیش‌بینی')).not.toBeInTheDocument(); + }); + + it('با actualCount ماه‌های باقی‌مانده را نقطه‌چین ادامه می‌دهد', () => { + const { container } = render(); + + const forecast = container.querySelector('.td-forecast'); + expect(forecast).toBeTruthy(); + expect(forecast!.getAttribute('stroke')).toBe('var(--accent)'); + expect(screen.getByText('نقطه‌چین: پیش‌بینی')).toBeInTheDocument(); + }); + + it('روند صعودی را ادامه می‌دهد و صفرها را رسم نمی‌کند', () => { + render(); + // شیب ۱۰ در ماه، آخرین واقعی ۴۰ ⇒ ماه پنجم ۵۰ + expect(screen.getByText('پیش‌بینی: ۵۰')).toBeInTheDocument(); + // آخرین ماه: ۴۰ + ۸×۱۰ = ۱۲۰ + expect(screen.getByText('پیش‌بینی: ۱۲۰')).toBeInTheDocument(); + }); + + it('پیش‌بینی هرگز منفی نمی‌شود', () => { + const falling: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({ + label: `ماه ${i + 1}`, + value: i < 3 ? 100 - i * 45 : 0, + })); + const { container } = render(); + + const tips = Array.from(container.querySelectorAll('.td-tip')) + .map((el) => el.textContent ?? '') + .filter((t) => t.startsWith('پیش‌بینی')); + expect(tips.length).toBeGreaterThan(0); + expect(tips.some((t) => t.includes('-') || t.includes('−'))).toBe(false); + }); + + it('سال کامل (actualCount برابر طول داده) پیش‌بینی ندارد', () => { + const full: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({ label: `ماه ${i + 1}`, value: 10 + i })); + const { container } = render(); + expect(container.querySelector('.td-forecast')).toBeNull(); + }); }); diff --git a/assets/admin/components/dashboard/TauriCharts.tsx b/assets/admin/components/dashboard/TauriCharts.tsx index 95b649c8..1073e5b1 100644 --- a/assets/admin/components/dashboard/TauriCharts.tsx +++ b/assets/admin/components/dashboard/TauriCharts.tsx @@ -4,7 +4,9 @@ * The source (clinic-pro-tauri) draws these with @mui/x-charts. MUI is not used * in clinicpro, so they are reproduced with plain DOM + inline SVG, matching the * source visuals: bars #5559CE, dashed horizontal grid, y-axis ticks #858D9D, - * x-axis labels #7E7E7E, and a line #5559CE over a #3A6FF8 gradient area. + * x-axis labels #7E7E7E. The income line follows the ApexCharts «gradient line» + * look instead — gradient stroke, glow, fading area and hover markers — drawn + * from the theme tokens so it tracks light/dark mode. */ import React from 'react'; @@ -31,6 +33,40 @@ function niceTicks(max: number, count = 4): number[] { const faNum = new Intl.NumberFormat('fa-IR'); +/** منحنی نرم از میان نقاط (کنترل‌پوینت وسط هر بازه — همان شکل قبلی خط). */ +function smoothPath(pts: [number, number][]): string { + return pts + .map((p, i) => { + if (i === 0) return `M${p[0]},${p[1]}`; + const prev = pts[i - 1]; + const cx = (prev[0] + p[0]) / 2; + return `C${cx},${prev[1]} ${cx},${p[1]} ${p[0]},${p[1]}`; + }) + .join(' '); +} + +/** + * ادامهٔ فرضیِ سری برای ماه‌هایی که هنوز نرسیده‌اند: برازش خطی روی حداکثر ۶ نقطهٔ + * آخر (روند اخیر، نه کل سال) و ادامه دادن همان شیب. خروجی هرگز منفی نمی‌شود. + * + * این یک عدد واقعی نیست — نمودار آن را نقطه‌چین و با برچسب «پیش‌بینی» نشان می‌دهد. + */ +function projectTrend(actual: number[], count: number): number[] { + const window = actual.slice(-6); + const n = window.length; + const meanX = (n - 1) / 2; + const meanY = window.reduce((s, v) => s + v, 0) / n; + let num = 0; + let den = 0; + window.forEach((v, i) => { + num += (i - meanX) * (v - meanY); + den += (i - meanX) ** 2; + }); + const slope = den === 0 ? 0 : num / den; + const last = actual[actual.length - 1]; + return Array.from({ length: count }, (_, k) => Math.max(0, Math.round(last + slope * (k + 1)))); +} + /** * Shared plot frame: left y-axis tick column + dashed gridlines + bottom x-axis * labels. `render(top, bottom)` receives the plot-area vertical bounds (px kept @@ -124,12 +160,27 @@ export function TauriBarChart({ data }: { data: ChartPoint[] }) { ); } -/** Line + area chart — #5559CE line over a #3A6FF8 gradient (source: LinePlot). */ -export function TauriLineChart({ data }: { data: ChartPoint[] }) { +/** + * Line + area chart in the ApexCharts «gradient line» style: a smooth curve whose + * stroke runs through a horizontal gradient (--primary → --accent), a soft glow + * beneath it, a fading area fill, and markers + tooltip that appear on hover. + * + * Still hand-drawn SVG — no charting dependency. The plot is scaled non-uniformly + * (`preserveAspectRatio="none"`), so anything that must stay round (markers) or + * readable (tooltip) lives in an HTML overlay positioned in percentages instead. + */ +export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actualCount?: number }) { if (data.length < 2 || !data.some((d) => d.value > 0)) { return ; } - const values = data.map((d) => d.value); + // ماه‌های نیامده صفر برمی‌گردند؛ رسم‌کردنشان یک خط صاف زشت تا انتهای سال می‌سازد. + // به‌جای آن، از آخرین دادهٔ واقعی به بعد روند را ادامه می‌دهیم و نقطه‌چین می‌کشیم. + const nActual = Math.min(actualCount ?? data.length, data.length); + const hasForecast = nActual >= 2 && nActual < data.length; + const values = hasForecast + ? [...data.slice(0, nActual).map((d) => d.value), ...projectTrend(data.slice(0, nActual).map((d) => d.value), data.length - nActual)] + : data.map((d) => d.value); + const max = Math.max(...values, 1); const ticks = niceTicks(max); const top = ticks[0] || 1; @@ -139,15 +190,11 @@ export function TauriLineChart({ data }: { data: ChartPoint[] }) { const H = 100; const stepX = data.length > 1 ? W / (data.length - 1) : W; const pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]); - const d = pts - .map((p, i) => { - if (i === 0) return `M${p[0]},${p[1]}`; - const prev = pts[i - 1]; - const cx = (prev[0] + p[0]) / 2; - return `C${cx},${prev[1]} ${cx},${p[1]} ${p[0]},${p[1]}`; - }) - .join(' '); - const area = `${d} L${pts[pts.length - 1][0]},${H} L${pts[0][0]},${H} Z`; + // خط پیش‌بینی از آخرین نقطهٔ واقعی شروع می‌شود تا وصله‌ی دو بخش دیده نشود. + const solid = smoothPath(hasForecast ? pts.slice(0, nActual) : pts); + const dashed = hasForecast ? smoothPath(pts.slice(nActual - 1)) : ''; + const areaPts = hasForecast ? pts.slice(0, nActual) : pts; + const area = `${smoothPath(areaPts)} L${areaPts[areaPts.length - 1][0]},${H} L${areaPts[0][0]},${H} Z`; return ( x.label)} yWidth={64}> @@ -157,22 +204,98 @@ export function TauriLineChart({ data }: { data: ChartPoint[] }) { preserveAspectRatio="none" > - - - + {/* رنگِ خط در طول محور افقی از برند به اکسنت می‌رود (سبک دموی apex) */} + + + + + + + + + + + + {hasForecast && ( + + )} + + {hasForecast && ( + + نقطه‌چین: پیش‌بینی + + )} + + {/* لایهٔ تعامل: هر ستون یک نقطه را هاور می‌کند (بدون state، فقط CSS) */} +
+ {data.map((p, i) => { + const isForecast = hasForecast && i >= nActual; + return ( +
+ + + {isForecast ? `پیش‌بینی: ${faNum.format(values[i])}` : faNum.format(p.value)} + +
+ ); + })} +
+ +
); } diff --git a/assets/admin/components/dashboard/TauriDashboardView.tsx b/assets/admin/components/dashboard/TauriDashboardView.tsx index b5540d56..09326d60 100644 --- a/assets/admin/components/dashboard/TauriDashboardView.tsx +++ b/assets/admin/components/dashboard/TauriDashboardView.tsx @@ -88,6 +88,8 @@ export interface TauriDashboardViewProps { onRevenueYearChange: (y: number) => void; /** سال شمسی جاری — مبنای گزینه‌های سلکتور سال */ currentJalaliYear: number; + /** ماه شمسی جاری (۱..۱۲) — مرز دادهٔ واقعی و پیش‌بینی در نمودار درآمد */ + currentJalaliMonth: number; } export function TauriDashboardView({ @@ -103,8 +105,12 @@ export function TauriDashboardView({ revenueYear, onRevenueYearChange, currentJalaliYear, + currentJalaliMonth, }: TauriDashboardViewProps) { const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]); + // در سالِ جاری فقط تا ماه جاری داده‌ی واقعی وجود دارد؛ بقیه پیش‌بینی می‌شود. + // سال‌های گذشته کامل‌اند و پیش‌بینی ندارند. + const incomeActualCount = revenueYear === currentJalaliYear ? currentJalaliMonth : undefined; return (
{/* Cards */} @@ -127,7 +133,7 @@ export function TauriDashboardView({ selectorValue={revenueYear} onSelectorChange={onRevenueYearChange} > - +
diff --git a/assets/admin/pages/ClinicDetailPage.test.tsx b/assets/admin/pages/ClinicDetailPage.test.tsx new file mode 100644 index 00000000..fdd8ac00 --- /dev/null +++ b/assets/admin/pages/ClinicDetailPage.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +// react-leaflet به DOM واقعی نقشه نیاز دارد؛ در jsdom با یک placeholder جایگزین می‌شود. +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: any) =>
{children}
, + TileLayer: () => null, + Marker: () => null, + useMapEvents: () => null, + useMap: () => ({ flyTo: vi.fn() }), +})); +vi.mock('leaflet', () => ({ + default: { Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } } }, +})); +vi.mock('leaflet/dist/leaflet.css', () => ({})); + +vi.mock('../components/ClinicDoctorsManager', () => ({ + default: () =>
, +})); + +import { Routes, Route } from 'react-router-dom'; +import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import ClinicDetailPage from './ClinicDetailPage'; + +/** صفحه از useParams می‌خواند، پس باید زیر یک Route واقعی رندر شود. */ +function renderPage() { + return renderWithProviders( + + } /> + , + { route: '/admin/clinics/c1' }, + ); +} + +const get = api.get as ReturnType; + +const clinic = { + uuid: 'c1', + name: 'کلینیک نمونه', + is_active: true, + phone: '02112345678', + specialties: [{ id: 1, name: 'قلب' }], + list_bime: [], + services: [], + images_clinic: [], +}; + +const address = { + id: '1', uuid: 'a1', name: 'شعبه مرکزی', address: 'خیابان اول', + telephone: '02100000000', + map: { latitude: '35.7', longitude: '51.4' }, + city: { id: '1', name: 'تهران' }, + province: { id: '1', name: 'تهران' }, +}; + +function mockApi({ addresses = [address] as any[] } = {}) { + get.mockImplementation((url: string) => { + if (url.includes('/addresses')) return Promise.resolve({ success: true, data: { data: addresses } }); + if (url.includes('/api/v1/clinic/')) return Promise.resolve({ success: true, data: { data: clinic } }); + return Promise.resolve({ success: true, data: { data: [] } }); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ primaryRole: 'admin', dbUuid: 'someone', token: 't' } as any); +}); + +describe('ClinicDetailPage', () => { + it('نام کلینیک را یک‌بار به‌عنوان عنوان صفحه نشان می‌دهد، نه تکراری در کارت', async () => { + mockApi(); + renderPage(); + + // عنوان صفحه + آخرین بردکرامب = دو نمونه؛ کارت هویت دیگر نام را تکرار نمی‌کند + await waitFor(() => expect(screen.getAllByText('کلینیک نمونه')).toHaveLength(2)); + }); + + it('بردکرامب به فهرست کلینیک‌ها لینک می‌دهد', async () => { + mockApi(); + renderPage(); + + const crumb = await screen.findByRole('link', { name: 'کلینیک‌ها' }); + expect(crumb).toHaveAttribute('href', '/admin/clinics'); + }); + + it('شهر و استان را در کارت هویت نشان می‌دهد', async () => { + mockApi(); + renderPage(); + await waitFor(() => expect(screen.getAllByText('تهران، تهران').length).toBeGreaterThan(0)); + }); + + it('فرم آدرس با کامپوننت Modal مشترک باز می‌شود', async () => { + mockApi({ addresses: [] }); + renderPage(); + + const addBtn = await screen.findByRole('button', { name: /افزودن آدرس/ }); + fireEvent.click(addBtn); + + expect(await screen.findByText('افزودن آدرس جدید')).toBeInTheDocument(); + // لیبل‌های فرم به‌جای input دست‌ساز، از field-block طرح استفاده می‌کنند + expect(screen.getByText('نام شعبه / عنوان')).toBeInTheDocument(); + expect(screen.getByText('استان')).toBeInTheDocument(); + }); + + it('دکمهٔ حذف آدرس با تم danger رندر می‌شود (نه رنگ ناموجود --error)', async () => { + mockApi(); + const { container } = renderPage(); + + await screen.findByText('شعبه مرکزی'); + const del = container.querySelector('button[title="حذف آدرس"]') as HTMLElement; + expect(del).toBeTruthy(); + expect(del.className).toContain('mini-btn'); + expect(del.className).toContain('danger'); + expect(del.getAttribute('style') ?? '').not.toContain('--error'); + }); +}); diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 12a8afc4..1f2fedfc 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -19,6 +19,9 @@ import type { ApiResponse } from '../lib/api'; import type { ClinicDetail } from '../types'; import { formatNumber } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; +import Modal from '../components/ui/Modal'; +import PageHeader from '../components/ui/PageHeader'; +import SearchableSelect from '../components/ui/SearchableSelect'; import NotificationMobileCard from '../components/ui/NotificationMobileCard'; import ClinicDoctorsManager from '../components/ClinicDoctorsManager'; import { useAuthStore } from '../stores/authStore'; @@ -70,100 +73,6 @@ const editSchema = z.object({ }); type EditForm = z.infer; -// ── Searchable Select (template CSS) ────────────────────────────────────── - -function SearchableSelectField({ options, value, onChange, placeholder, disabled = false }: { - options: Opt[]; - value: number | null; - onChange: (v: number | null, label?: string) => void; - placeholder?: string; - disabled?: boolean; -}) { - const [open, setOpen] = useState(false); - const [q, setQ] = useState(''); - const [rect, setRect] = useState(null); - const btnRef = useRef(null); - const dropRef = useRef(null); - - const filtered = useMemo( - () => q ? options.filter(o => o.name.includes(q)) : options, - [options, q], - ); - const selected = useMemo(() => options.find(o => o.id === value) ?? null, [options, value]); - - const openDD = () => { - if (disabled || !btnRef.current) return; - setRect(btnRef.current.getBoundingClientRect()); - setOpen(v => !v); - setQ(''); - }; - - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent) => { - if (dropRef.current && !dropRef.current.contains(e.target as Node) && - btnRef.current && !btnRef.current.contains(e.target as Node)) - setOpen(false); - }; - document.addEventListener('mousedown', onDown); - return () => document.removeEventListener('mousedown', onDown); - }, [open]); - - const dropStyle: React.CSSProperties = rect - ? { position: 'fixed', top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999 } - : {}; - - return ( -
- - {open && createPortal( -
-
- setQ(e.target.value)} placeholder="جستجو..." - className="input" style={{ fontSize: 13 }} /> -
-
- - {filtered.map(o => ( - - ))} - {filtered.length === 0 && ( -

نتیجه‌ای یافت نشد

- )} -
-
, - document.body, - )} -
- ); -} - // ── Multi-select checkbox list ───────────────────────────────────────────── function MultiCheckList({ options, selected, onChange, placeholder }: { @@ -176,7 +85,7 @@ function MultiCheckList({ options, selected, onChange, placeholder }: { return (
-
+
setQ(e.target.value)} placeholder={placeholder ?? 'جستجو...'} className="input" style={{ fontSize: 13 }} />
@@ -627,6 +536,7 @@ export default function ClinicDetailPage() { } catch (e: any) { toast.error(e?.message ?? 'خطا در حذف تصویر'); } }; + const clinicName = clinic?.name ?? 'کلینیک'; const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; const logo = clinic?.logo ?? clinic?.clinic_logo; // شهر و استان از آدرس DoctorAddress (منبع واقعی) نه از Clinic entity @@ -667,35 +577,33 @@ export default function ClinicDetailPage() {
{/* ── Header ── */} -
-
- -
-

{clinic.name}

-
جزئیات کلینیک
-
-
-
- {!isReadOnly && ( - - )} - {primaryRole === 'admin' && ( - <> - - - - )} -
-
+ )} + {primaryRole === 'admin' && ( + <> + + + + )} +
+ } + /> {/* ── Main grid ── */}
@@ -731,13 +639,19 @@ export default function ClinicDetailPage() { )}
-
{clinic.name}
-
+ {/* نام در PageHeader آمده — اینجا وضعیت و شهر، نه تکرار عنوان */} +
{clinic.is_active ? 'فعال' : 'غیرفعال'} {clinic['24_7'] && ۲۴ ساعته}
+ {(cityName || provinceName) && ( +
+ + {[cityName, provinceName].filter(Boolean).join('، ')} +
+ )}
@@ -753,7 +667,7 @@ export default function ClinicDetailPage() {
{clinic.caption && ( -
+
توضیحات

{clinic.caption}

@@ -788,7 +702,7 @@ export default function ClinicDetailPage() { ) : (
{clinic.images_clinic.filter(img => img?.url).map((img, i) => ( -
+
{!isReadOnly && ( - @@ -949,93 +863,106 @@ export default function ClinicDetailPage() {
{/* Clinic Address Form Modal */} - {addrFormOpen && createPortal( -
setAddrFormOpen(false)}> -
e.stopPropagation()}> -
- {editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'} - -
-
-
- - setAddrForm(f => ({ ...f, name: e.target.value }))} /> -
-
-
- - setAddrForm(f => ({ ...f, province_id: val, city_id: null }))} - /> -
-
- - { - setAddrForm(f => ({ ...f, city_id: val })); - if (label) geocodeCity(label).then(c => { if (c) setAddrMapFlyTarget(c); }); - }} - /> -
-
-
- -