feat: enhance Tauri charts with smooth curves and forecasting

- Implemented a smooth curve rendering for the TauriLineChart using a cubic Bezier path.
- Added a forecasting feature to project trends based on recent data points.
- Updated TauriDashboardView to pass the current month for accurate forecasting.
- Refactored TauriLineChart to handle actual and forecasted data points distinctly.
- Introduced gradient strokes and glow effects to align with ApexCharts styling.
- Enhanced user interaction with hover markers and tooltips for forecasted data.
- Added a new test suite for ClinicDetailPage to ensure proper rendering and functionality.
This commit is contained in:
hamed
2026-07-19 11:25:45 +03:30
parent 15366788d5
commit e7de7aa88b
6 changed files with 492 additions and 234 deletions
@@ -88,4 +88,80 @@ describe('TauriLineChart', () => {
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument(); expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
expect(container.querySelector('svg path')).toBeTruthy(); expect(container.querySelector('svg path')).toBeTruthy();
}); });
/** سبک «gradient line»: خط از گرادیان افقی رنگ می‌گیرد، نه یک رنگ ثابت. */
it('paints the line with the horizontal gradient and a glow', () => {
const { container } = render(<TauriLineChart data={real} />);
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(<TauriLineChart data={real} />);
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(<TauriLineChart data={year} />);
expect(container.querySelector('.td-forecast')).toBeNull();
expect(screen.queryByText('نقطه‌چین: پیش‌بینی')).not.toBeInTheDocument();
});
it('با actualCount ماه‌های باقی‌مانده را نقطه‌چین ادامه می‌دهد', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const forecast = container.querySelector('.td-forecast');
expect(forecast).toBeTruthy();
expect(forecast!.getAttribute('stroke')).toBe('var(--accent)');
expect(screen.getByText('نقطه‌چین: پیش‌بینی')).toBeInTheDocument();
});
it('روند صعودی را ادامه می‌دهد و صفرها را رسم نمی‌کند', () => {
render(<TauriLineChart data={year} actualCount={4} />);
// شیب ۱۰ در ماه، آخرین واقعی ۴۰ ⇒ ماه پنجم ۵۰
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(<TauriLineChart data={falling} actualCount={3} />);
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(<TauriLineChart data={full} actualCount={12} />);
expect(container.querySelector('.td-forecast')).toBeNull();
});
}); });
+141 -18
View File
@@ -4,7 +4,9 @@
* The source (clinic-pro-tauri) draws these with @mui/x-charts. MUI is not used * 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 * in clinicpro, so they are reproduced with plain DOM + inline SVG, matching the
* source visuals: bars #5559CE, dashed horizontal grid, y-axis ticks #858D9D, * 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'; import React from 'react';
@@ -31,6 +33,40 @@ function niceTicks(max: number, count = 4): number[] {
const faNum = new Intl.NumberFormat('fa-IR'); 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 * 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 * 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)) { if (data.length < 2 || !data.some((d) => d.value > 0)) {
return <EmptyChart />; return <EmptyChart />;
} }
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 max = Math.max(...values, 1);
const ticks = niceTicks(max); const ticks = niceTicks(max);
const top = ticks[0] || 1; const top = ticks[0] || 1;
@@ -139,15 +190,11 @@ export function TauriLineChart({ data }: { data: ChartPoint[] }) {
const H = 100; const H = 100;
const stepX = data.length > 1 ? W / (data.length - 1) : W; 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 pts = values.map((v, i) => [i * stepX, H - (v / top) * H] as [number, number]);
const d = pts // خط پیش‌بینی از آخرین نقطهٔ واقعی شروع می‌شود تا وصله‌ی دو بخش دیده نشود.
.map((p, i) => { const solid = smoothPath(hasForecast ? pts.slice(0, nActual) : pts);
if (i === 0) return `M${p[0]},${p[1]}`; const dashed = hasForecast ? smoothPath(pts.slice(nActual - 1)) : '';
const prev = pts[i - 1]; const areaPts = hasForecast ? pts.slice(0, nActual) : pts;
const cx = (prev[0] + p[0]) / 2; const area = `${smoothPath(areaPts)} L${areaPts[areaPts.length - 1][0]},${H} L${areaPts[0][0]},${H} Z`;
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`;
return ( return (
<ChartFrame ticks={ticks} labels={data.map((x) => x.label)} yWidth={64}> <ChartFrame ticks={ticks} labels={data.map((x) => x.label)} yWidth={64}>
@@ -157,22 +204,98 @@ export function TauriLineChart({ data }: { data: ChartPoint[] }) {
preserveAspectRatio="none" preserveAspectRatio="none"
> >
<defs> <defs>
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1"> {/* رنگِ خط در طول محور افقی از برند به اکسنت می‌رود (سبک دموی apex) */}
<stop offset="0%" stopColor="#3A6FF8" stopOpacity={0.1} /> <linearGradient id="tdIncomeStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="100%" stopColor="#3A6FF8" stopOpacity={0.02} /> <stop offset="0%" stopColor="var(--primary)" />
<stop offset="55%" stopColor="var(--primary-600)" />
<stop offset="100%" stopColor="var(--accent)" />
</linearGradient> </linearGradient>
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.22} />
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0} />
</linearGradient>
<filter id="tdIncomeGlow" x="-20%" y="-40%" width="140%" height="200%">
<feDropShadow dx="0" dy="4" stdDeviation="4" floodColor="var(--primary)" floodOpacity="0.28" />
</filter>
</defs> </defs>
<path d={area} fill="url(#tdIncomeGrad)" /> <path d={area} fill="url(#tdIncomeGrad)" />
{hasForecast && (
<path
className="td-forecast"
d={dashed}
fill="none"
stroke="var(--accent)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeOpacity={0.75}
vectorEffect="non-scaling-stroke"
/>
)}
<path <path
d={d} d={solid}
fill="none" fill="none"
stroke="#5559CE" stroke="url(#tdIncomeStroke)"
strokeWidth={3} strokeWidth={3}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
filter="url(#tdIncomeGlow)"
style={{ strokeDasharray: 1200, animation: 'tddraw 1.1s var(--ease) both' }}
/> />
</svg> </svg>
{hasForecast && (
<span
className="absolute top-0 left-0 rounded-[var(--r-pill)] px-2 py-[3px] text-[10.5px] font-bold"
style={{ background: 'var(--accent-bg)', color: 'var(--accent-600)' }}
>
نقطه‌چین: پیش‌بینی
</span>
)}
{/* لایهٔ تعامل: هر ستون یک نقطه را هاور می‌کند (بدون state، فقط CSS) */}
<div className="absolute inset-0 flex" style={{ alignItems: 'stretch' }}>
{data.map((p, i) => {
const isForecast = hasForecast && i >= nActual;
return (
<div key={i} className="td-hit relative flex-1 min-w-0">
<span
className="td-dot absolute block rounded-full border-2 border-[var(--surface)]"
style={{
width: 10,
height: 10,
background: isForecast ? 'var(--accent)' : 'var(--primary)',
left: `${(i * stepX / W) * 100}%`,
top: `${(pts[i][1] / H) * 100}%`,
transform: 'translate(-50%, -50%)',
}}
/>
<span
className="td-tip absolute whitespace-nowrap rounded-[var(--r-xs)] px-2 py-1 text-[11px] font-bold"
style={{
left: `${(i * stepX / W) * 100}%`,
top: `${(pts[i][1] / H) * 100}%`,
transform: 'translate(-50%, calc(-100% - 12px))',
background: isForecast ? 'var(--accent)' : 'var(--text)',
color: '#fff',
boxShadow: 'var(--shadow)',
}}
>
{isForecast ? `پیش‌بینی: ${faNum.format(values[i])}` : faNum.format(p.value)}
</span>
</div>
);
})}
</div>
<style>{`
@keyframes tddraw { from { stroke-dashoffset: 1200 } to { stroke-dashoffset: 0 } }
/* نقطه‌چینِ بخش پیش‌بینی — با non-scaling-stroke در محور کشیده نمی‌شود */
.td-forecast { stroke-dasharray: 0.1 7; stroke-linecap: round; }
.td-dot, .td-tip { opacity: 0; transition: opacity .14s var(--ease); pointer-events: none; }
.td-hit:hover .td-dot, .td-hit:hover .td-tip { opacity: 1; }
`}</style>
</ChartFrame> </ChartFrame>
); );
} }
@@ -88,6 +88,8 @@ export interface TauriDashboardViewProps {
onRevenueYearChange: (y: number) => void; onRevenueYearChange: (y: number) => void;
/** سال شمسی جاری — مبنای گزینه‌های سلکتور سال */ /** سال شمسی جاری — مبنای گزینه‌های سلکتور سال */
currentJalaliYear: number; currentJalaliYear: number;
/** ماه شمسی جاری (۱..۱۲) — مرز دادهٔ واقعی و پیش‌بینی در نمودار درآمد */
currentJalaliMonth: number;
} }
export function TauriDashboardView({ export function TauriDashboardView({
@@ -103,8 +105,12 @@ export function TauriDashboardView({
revenueYear, revenueYear,
onRevenueYearChange, onRevenueYearChange,
currentJalaliYear, currentJalaliYear,
currentJalaliMonth,
}: TauriDashboardViewProps) { }: TauriDashboardViewProps) {
const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]); const yearOpts = React.useMemo(() => yearOptions(currentJalaliYear), [currentJalaliYear]);
// در سالِ جاری فقط تا ماه جاری داده‌ی واقعی وجود دارد؛ بقیه پیش‌بینی می‌شود.
// سال‌های گذشته کامل‌اند و پیش‌بینی ندارند.
const incomeActualCount = revenueYear === currentJalaliYear ? currentJalaliMonth : undefined;
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 */}
@@ -127,7 +133,7 @@ export function TauriDashboardView({
selectorValue={revenueYear} selectorValue={revenueYear}
onSelectorChange={onRevenueYearChange} onSelectorChange={onRevenueYearChange}
> >
<TauriLineChart data={incomeLine} /> <TauriLineChart data={incomeLine} actualCount={incomeActualCount} />
</ChartCard> </ChartCard>
</div> </div>
@@ -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) => <div data-testid="map">{children}</div>,
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: () => <div data-testid="doctors-manager" />,
}));
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(
<Routes>
<Route path="/admin/clinics/:uuid" element={<ClinicDetailPage />} />
</Routes>,
{ route: '/admin/clinics/c1' },
);
}
const get = api.get as ReturnType<typeof vi.fn>;
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');
});
});
+142 -215
View File
@@ -19,6 +19,9 @@ import type { ApiResponse } from '../lib/api';
import type { ClinicDetail } from '../types'; import type { ClinicDetail } from '../types';
import { formatNumber } from '../lib/utils'; import { formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog'; 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 NotificationMobileCard from '../components/ui/NotificationMobileCard';
import ClinicDoctorsManager from '../components/ClinicDoctorsManager'; import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
@@ -70,100 +73,6 @@ const editSchema = z.object({
}); });
type EditForm = z.infer<typeof editSchema>; type EditForm = z.infer<typeof editSchema>;
// ── 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<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(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 (
<div>
<button ref={btnRef} type="button" disabled={disabled} onClick={openDD}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '8px 12px', borderRadius: 8,
border: '1px solid var(--border)', background: disabled ? 'var(--surface-2, var(--bg))' : 'var(--surface)',
color: selected ? 'var(--text)' : 'var(--text-3)', cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 14, opacity: disabled ? 0.6 : 1,
}}>
<span>{selected?.name ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon style={{ width: 14, height: 14, flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
</button>
{open && createPortal(
<div ref={dropRef} style={{
...dropStyle,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,.12)', overflow: 'hidden',
}}>
<div style={{ padding: '8px 8px 0' }}>
<input autoFocus value={q} onChange={e => setQ(e.target.value)} placeholder="جستجو..."
className="input" style={{ fontSize: 13 }} />
</div>
<div style={{ maxHeight: 200, overflowY: 'auto', padding: '4px 0' }}>
<button type="button"
onClick={() => { onChange(null); setOpen(false); }}
style={{ width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13, color: 'var(--text-3)', background: 'none', border: 'none', cursor: 'pointer' }}>
{placeholder ?? 'انتخاب کنید'}
</button>
{filtered.map(o => (
<button key={o.id} type="button"
onClick={() => { onChange(o.id, o.name); setOpen(false); setQ(''); }}
style={{
width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13,
background: value === o.id ? 'var(--primary-light, oklch(0.95 0.04 256))' : 'none',
color: value === o.id ? 'var(--primary)' : 'var(--text)', border: 'none', cursor: 'pointer',
}}>
{o.name}
</button>
))}
{filtered.length === 0 && (
<p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجهای یافت نشد</p>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
// ── Multi-select checkbox list ───────────────────────────────────────────── // ── Multi-select checkbox list ─────────────────────────────────────────────
function MultiCheckList({ options, selected, onChange, placeholder }: { function MultiCheckList({ options, selected, onChange, placeholder }: {
@@ -176,7 +85,7 @@ function MultiCheckList({ options, selected, onChange, placeholder }: {
return ( return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}> <div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '6px 8px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2, var(--bg))' }}> <div style={{ padding: '6px 8px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<input value={q} onChange={e => setQ(e.target.value)} placeholder={placeholder ?? 'جستجو...'} <input value={q} onChange={e => setQ(e.target.value)} placeholder={placeholder ?? 'جستجو...'}
className="input" style={{ fontSize: 13 }} /> className="input" style={{ fontSize: 13 }} />
</div> </div>
@@ -627,6 +536,7 @@ export default function ClinicDetailPage() {
} catch (e: any) { toast.error(e?.message ?? 'خطا در حذف تصویر'); } } catch (e: any) { toast.error(e?.message ?? 'خطا در حذف تصویر'); }
}; };
const clinicName = clinic?.name ?? 'کلینیک';
const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const logo = clinic?.logo ?? clinic?.clinic_logo; const logo = clinic?.logo ?? clinic?.clinic_logo;
// شهر و استان از آدرس DoctorAddress (منبع واقعی) نه از Clinic entity // شهر و استان از آدرس DoctorAddress (منبع واقعی) نه از Clinic entity
@@ -667,35 +577,33 @@ export default function ClinicDetailPage() {
<div className="fade-in"> <div className="fade-in">
{/* ── Header ── */} {/* ── Header ── */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <PageHeader
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> title={clinicName}
<button className="btn ghost sm" onClick={() => navigate('/admin/clinics')} style={{ padding: '6px 10px' }}> breadcrumbs={[{ label: 'کلینیک‌ها', to: '/admin/clinics' }, { label: clinicName }]}
<ArrowRightIcon style={{ width: 16, height: 16 }} /> action={
</button> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<div> <button className="btn ghost sm" onClick={() => navigate('/admin/clinics')}>
<h1 className="section-title">{clinic.name}</h1> <ArrowRightIcon style={{ width: 15, height: 15 }} /> بازگشت
<div className="muted">جزئیات کلینیک</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{!isReadOnly && (
<button className="btn ghost sm" onClick={() => openEdit()}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button> </button>
)} {!isReadOnly && (
{primaryRole === 'admin' && ( <button className="btn soft sm" onClick={() => openEdit()}>
<> <PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button> </button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}> )}
<TrashIcon style={{ width: 15, height: 15 }} /> حذف {primaryRole === 'admin' && (
</button> <>
</> <button className={`btn sm ${clinic.is_active ? 'ghost' : 'primary'}`}
)} onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
</div> {clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</div> </button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
</>
)}
</div>
}
/>
{/* ── Main grid ── */} {/* ── Main grid ── */}
<div className="split-2"> <div className="split-2">
@@ -731,13 +639,19 @@ export default function ClinicDetailPage() {
)} )}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{clinic.name}</div> {/* نام در PageHeader آمده — اینجا وضعیت و شهر، نه تکرار عنوان */}
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`}> <span className={`badge ${clinic.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />{clinic.is_active ? 'فعال' : 'غیرفعال'} <span className="bdot" />{clinic.is_active ? 'فعال' : 'غیرفعال'}
</span> </span>
{clinic['24_7'] && <span className="badge amber"><span className="bdot" />۲۴ ساعته</span>} {clinic['24_7'] && <span className="badge amber"><span className="bdot" />۲۴ ساعته</span>}
</div> </div>
{(cityName || provinceName) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 8, color: 'var(--text-3)', fontSize: 12.5 }}>
<MapPinIcon style={{ width: 14, height: 14, flexShrink: 0 }} />
{[cityName, provinceName].filter(Boolean).join('، ')}
</div>
)}
</div> </div>
</div> </div>
@@ -753,7 +667,7 @@ export default function ClinicDetailPage() {
</div> </div>
{clinic.caption && ( {clinic.caption && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--surface-2, var(--bg))', borderRadius: 8 }}> <div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--surface-2)', borderRadius: 8 }}>
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>توضیحات</div> <div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>توضیحات</div>
<p style={{ fontSize: 13, lineHeight: 1.7 }}>{clinic.caption}</p> <p style={{ fontSize: 13, lineHeight: 1.7 }}>{clinic.caption}</p>
</div> </div>
@@ -788,7 +702,7 @@ export default function ClinicDetailPage() {
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}>
{clinic.images_clinic.filter(img => img?.url).map((img, i) => ( {clinic.images_clinic.filter(img => img?.url).map((img, i) => (
<div key={i} style={{ position: 'relative', borderRadius: 8, overflow: 'hidden', aspectRatio: '1', background: 'var(--surface-2, var(--bg))' }}> <div key={i} style={{ position: 'relative', borderRadius: 8, overflow: 'hidden', aspectRatio: '1', background: 'var(--surface-2)' }}>
<img src={img.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <img src={img.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{!isReadOnly && ( {!isReadOnly && (
<button <button
@@ -928,11 +842,11 @@ export default function ClinicDetailPage() {
)} )}
</div> </div>
{(isOwner || primaryRole === 'admin') && ( {(isOwner || primaryRole === 'admin') && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => openAddrForm(addr)}> <button className="mini-btn" title="ویرایش آدرس" onClick={() => openAddrForm(addr)}>
<PencilIcon style={{ width: 14, height: 14 }} /> <PencilIcon style={{ width: 14, height: 14 }} />
</button> </button>
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }} <button className="mini-btn danger" title="حذف آدرس"
onClick={() => setDeleteAddrConfirm(addr)}> onClick={() => setDeleteAddrConfirm(addr)}>
<TrashIcon style={{ width: 14, height: 14 }} /> <TrashIcon style={{ width: 14, height: 14 }} />
</button> </button>
@@ -949,93 +863,106 @@ export default function ClinicDetailPage() {
</div> </div>
{/* Clinic Address Form Modal */} {/* Clinic Address Form Modal */}
{addrFormOpen && createPortal( <Modal
<div className="overlay" onClick={() => setAddrFormOpen(false)}> open={addrFormOpen}
<div className="modal" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}> title={editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}
<div className="modal-head"> size="md"
<b>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</b> onClose={() => setAddrFormOpen(false)}
<button className="mini-btn" onClick={() => setAddrFormOpen(false)}> footer={
<XMarkIcon style={{ width: 16, height: 16 }} /> <>
</button> <button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button>
</div> <button className="btn primary" disabled={saveAddrMutation.isPending}
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> onClick={() => saveAddrMutation.mutate(addrForm)}>
<div> {saveAddrMutation.isPending ? 'در حال ذخیره…' : 'ذخیره'}
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام شعبه / عنوان</label> </button>
<input className="input" placeholder="مثال: شعبه مرکزی" </>
value={addrForm.name} }
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} /> >
</div> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div className="field-block">
<div> <label>نام شعبه / عنوان</label>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>استان</label> <div className="field">
<SearchableSelectField <input placeholder="مثال: شعبه مرکزی"
options={provinces} value={addrForm.name}
value={addrForm.province_id} onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
placeholder="انتخاب استان"
onChange={val => setAddrForm(f => ({ ...f, province_id: val, city_id: null }))}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شهر</label>
<SearchableSelectField
options={addrCities}
value={addrForm.city_id}
placeholder={addrForm.province_id ? 'انتخاب شهر' : 'ابتدا استان'}
disabled={!addrForm.province_id}
onChange={(val, label) => {
setAddrForm(f => ({ ...f, city_id: val }));
if (label) geocodeCity(label).then(c => { if (c) setAddrMapFlyTarget(c); });
}}
/>
</div>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>آدرس کامل</label>
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک..."
value={addrForm.address}
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>موقعیت روی نقشه</label>
{addrForm.latitude && addrForm.longitude && (
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>
{addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
</span>
)}
</div>
<p className="muted" style={{ fontSize: 12, marginBottom: 6 }}>برای تعیین موقعیت دقیق روی نقشه کلیک کنید</p>
<MapPicker
lat={addrForm.latitude} lng={addrForm.longitude}
flyTarget={addrMapFlyTarget}
initialCenter={addrForm.latitude && addrForm.longitude ? [addrForm.latitude, addrForm.longitude] : null}
onChange={(lt, ln) => setAddrForm(f => ({ ...f, latitude: lt, longitude: ln }))}
/>
{addrForm.latitude && addrForm.longitude && (
<button type="button" className="btn ghost sm" style={{ marginTop: 6, fontSize: 12 }}
onClick={() => setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
</button>
)}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="مثال: 02112345678"
value={addrForm.telephone}
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
</div>
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>انصراف</button>
<button className="btn primary sm" disabled={saveAddrMutation.isPending}
onClick={() => saveAddrMutation.mutate(addrForm)}>
{saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div> </div>
</div> </div>
</div>,
document.body, <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 14 }}>
)} <div className="field-block">
<label>استان</label>
<SearchableSelect
options={provinces.map(p => ({ value: p.id, label: p.name }))}
value={addrForm.province_id}
placeholder="انتخاب استان"
isClearable
isLoading={provincesQ.isLoading}
onChange={val => setAddrForm(f => ({
...f, province_id: val === null ? null : Number(val), city_id: null,
}))}
/>
</div>
<div className="field-block">
<label>شهر</label>
<SearchableSelect
options={addrCities.map(c => ({ value: c.id, label: c.name }))}
value={addrForm.city_id}
placeholder={addrForm.province_id ? 'انتخاب شهر' : 'ابتدا استان را انتخاب کنید'}
isDisabled={!addrForm.province_id}
isClearable
isLoading={citiesQ.isLoading}
onChange={val => {
const id = val === null ? null : Number(val);
setAddrForm(f => ({ ...f, city_id: id }));
// نقشه روی شهر انتخابی می‌پرد تا کاربر از وسط ایران شروع نکند.
const label = addrCities.find(c => c.id === id)?.name;
if (label) geocodeCity(label).then(c => { if (c) setAddrMapFlyTarget(c); });
}}
/>
</div>
</div>
<div className="field-block">
<label>آدرس کامل</label>
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک…"
value={addrForm.address}
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
</div>
<div className="field-block">
<label>تلفن</label>
<div className="field">
<input dir="ltr" placeholder="مثال: 02112345678"
value={addrForm.telephone}
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
</div>
</div>
<div className="field-block">
<label style={{ justifyContent: 'space-between' }}>
<span>موقعیت روی نقشه</span>
{addrForm.latitude && addrForm.longitude && (
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-3)', direction: 'ltr' }}>
{addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
</span>
)}
</label>
<MapPicker
lat={addrForm.latitude} lng={addrForm.longitude}
flyTarget={addrMapFlyTarget}
initialCenter={addrForm.latitude && addrForm.longitude ? [addrForm.latitude, addrForm.longitude] : null}
onChange={(lt, ln) => setAddrForm(f => ({ ...f, latitude: lt, longitude: ln }))}
/>
<span className="field-hint">برای تعیین موقعیت دقیق، روی نقشه کلیک کنید</span>
{addrForm.latitude && addrForm.longitude && (
<button type="button" className="btn ghost sm" style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={() => setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
</button>
)}
</div>
</div>
</Modal>
{/* Delete Address Confirm */} {/* Delete Address Confirm */}
<ConfirmDialog <ConfirmDialog
@@ -1094,7 +1021,7 @@ function InfoTile({ icon, label, value, fullWidth }: {
<div style={{ <div style={{
gridColumn: fullWidth ? '1 / -1' : undefined, gridColumn: fullWidth ? '1 / -1' : undefined,
padding: '10px 12px', borderRadius: 8, padding: '10px 12px', borderRadius: 8,
background: 'var(--surface-2, var(--bg))', background: 'var(--surface-2)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 3, color: 'var(--text-3)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 3, color: 'var(--text-3)' }}>
+3
View File
@@ -33,6 +33,7 @@ function useJalaliChartPeriod() {
return { return {
currentJalaliYear: now.jy, currentJalaliYear: now.jy,
currentJalaliMonth: now.jm,
patientsYear: now.jy, patientsYear: now.jy,
patientsMonth, patientsMonth,
setPatientsMonth, setPatientsMonth,
@@ -671,6 +672,7 @@ function ClinicDashboard() {
revenueYear={chartPeriod.revenueYear} revenueYear={chartPeriod.revenueYear}
onRevenueYearChange={chartPeriod.setRevenueYear} onRevenueYearChange={chartPeriod.setRevenueYear}
currentJalaliYear={chartPeriod.currentJalaliYear} currentJalaliYear={chartPeriod.currentJalaliYear}
currentJalaliMonth={chartPeriod.currentJalaliMonth}
/> />
</div> </div>
@@ -833,6 +835,7 @@ function DoctorDashboard() {
revenueYear={chartPeriod.revenueYear} revenueYear={chartPeriod.revenueYear}
onRevenueYearChange={chartPeriod.setRevenueYear} onRevenueYearChange={chartPeriod.setRevenueYear}
currentJalaliYear={chartPeriod.currentJalaliYear} currentJalaliYear={chartPeriod.currentJalaliYear}
currentJalaliMonth={chartPeriod.currentJalaliMonth}
/> />
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}> <div className="grid-2" style={{ marginTop: 'var(--gap)' }}>