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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
Reference in New Issue
Block a user