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
@@ -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 { 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<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 ─────────────────────────────────────────────
function MultiCheckList({ options, selected, onChange, placeholder }: {
@@ -176,7 +85,7 @@ function MultiCheckList({ options, selected, onChange, placeholder }: {
return (
<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 ?? 'جستجو...'}
className="input" style={{ fontSize: 13 }} />
</div>
@@ -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() {
<div className="fade-in">
{/* ── Header ── */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button className="btn ghost sm" onClick={() => navigate('/admin/clinics')} style={{ padding: '6px 10px' }}>
<ArrowRightIcon style={{ width: 16, height: 16 }} />
</button>
<div>
<h1 className="section-title">{clinic.name}</h1>
<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 }} /> ویرایش
<PageHeader
title={clinicName}
breadcrumbs={[{ label: 'کلینیک‌ها', to: '/admin/clinics' }, { label: clinicName }]}
action={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button className="btn ghost sm" onClick={() => navigate('/admin/clinics')}>
<ArrowRightIcon style={{ width: 15, height: 15 }} /> بازگشت
</button>
)}
{primaryRole === 'admin' && (
<>
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
{!isReadOnly && (
<button className="btn soft sm" onClick={() => openEdit()}>
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
</button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
</>
)}
</div>
</div>
)}
{primaryRole === 'admin' && (
<>
<button className={`btn sm ${clinic.is_active ? 'ghost' : 'primary'}`}
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
</button>
</>
)}
</div>
}
/>
{/* ── Main grid ── */}
<div className="split-2">
@@ -731,13 +639,19 @@ export default function ClinicDetailPage() {
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{clinic.name}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
{/* نام در PageHeader آمده — اینجا وضعیت و شهر، نه تکرار عنوان */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />{clinic.is_active ? 'فعال' : 'غیرفعال'}
</span>
{clinic['24_7'] && <span className="badge amber"><span className="bdot" />۲۴ ساعته</span>}
</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>
@@ -753,7 +667,7 @@ export default function ClinicDetailPage() {
</div>
{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>
<p style={{ fontSize: 13, lineHeight: 1.7 }}>{clinic.caption}</p>
</div>
@@ -788,7 +702,7 @@ export default function ClinicDetailPage() {
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}>
{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' }} />
{!isReadOnly && (
<button
@@ -928,11 +842,11 @@ export default function ClinicDetailPage() {
)}
</div>
{(isOwner || primaryRole === 'admin') && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => openAddrForm(addr)}>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button className="mini-btn" title="ویرایش آدرس" onClick={() => openAddrForm(addr)}>
<PencilIcon style={{ width: 14, height: 14 }} />
</button>
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }}
<button className="mini-btn danger" title="حذف آدرس"
onClick={() => setDeleteAddrConfirm(addr)}>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
@@ -949,93 +863,106 @@ export default function ClinicDetailPage() {
</div>
{/* Clinic Address Form Modal */}
{addrFormOpen && createPortal(
<div className="overlay" onClick={() => setAddrFormOpen(false)}>
<div className="modal" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</b>
<button className="mini-btn" onClick={() => setAddrFormOpen(false)}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام شعبه / عنوان</label>
<input className="input" placeholder="مثال: شعبه مرکزی"
value={addrForm.name}
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>استان</label>
<SearchableSelectField
options={provinces}
value={addrForm.province_id}
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>
<Modal
open={addrFormOpen}
title={editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}
size="md"
onClose={() => setAddrFormOpen(false)}
footer={
<>
<button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button>
<button className="btn primary" disabled={saveAddrMutation.isPending}
onClick={() => saveAddrMutation.mutate(addrForm)}>
{saveAddrMutation.isPending ? 'در حال ذخیره…' : 'ذخیره'}
</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="field-block">
<label>نام شعبه / عنوان</label>
<div className="field">
<input placeholder="مثال: شعبه مرکزی"
value={addrForm.name}
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
</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 */}
<ConfirmDialog
@@ -1094,7 +1021,7 @@ function InfoTile({ icon, label, value, fullWidth }: {
<div style={{
gridColumn: fullWidth ? '1 / -1' : undefined,
padding: '10px 12px', borderRadius: 8,
background: 'var(--surface-2, var(--bg))',
background: 'var(--surface-2)',
border: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 3, color: 'var(--text-3)' }}>
+3
View File
@@ -33,6 +33,7 @@ function useJalaliChartPeriod() {
return {
currentJalaliYear: now.jy,
currentJalaliMonth: now.jm,
patientsYear: now.jy,
patientsMonth,
setPatientsMonth,
@@ -671,6 +672,7 @@ function ClinicDashboard() {
revenueYear={chartPeriod.revenueYear}
onRevenueYearChange={chartPeriod.setRevenueYear}
currentJalaliYear={chartPeriod.currentJalaliYear}
currentJalaliMonth={chartPeriod.currentJalaliMonth}
/>
</div>
@@ -833,6 +835,7 @@ function DoctorDashboard() {
revenueYear={chartPeriod.revenueYear}
onRevenueYearChange={chartPeriod.setRevenueYear}
currentJalaliYear={chartPeriod.currentJalaliYear}
currentJalaliMonth={chartPeriod.currentJalaliMonth}
/>
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>