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' && (
+ <>
+
toggleMut.mutate()} disabled={toggleMut.isPending}>
+ {clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
+
+
setDeleteOpen(true)}>
+ حذف
+
+ >
+ )}
+
+ }
+ />
{/* ── 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 && (
-
+
@@ -788,7 +702,7 @@ export default function ClinicDetailPage() {
) : (
{clinic.images_clinic.filter(img => img?.url).map((img, i) => (
-
+

{!isReadOnly && (
{(isOwner || primaryRole === 'admin') && (
-
-
openAddrForm(addr)}>
+
+
openAddrForm(addr)}>
-
setDeleteAddrConfirm(addr)}>
@@ -949,93 +863,106 @@ export default function ClinicDetailPage() {
{/* Clinic Address Form Modal */}
- {addrFormOpen && createPortal(
- setAddrFormOpen(false)}>
-
e.stopPropagation()}>
-
- {editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}
- setAddrFormOpen(false)}>
-
-
-
-
-
-
- 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); });
- }}
- />
-
-
-
-
-
-
-
-
- {addrForm.latitude && addrForm.longitude && (
-
- {addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
-
- )}
-
-
برای تعیین موقعیت دقیق روی نقشه کلیک کنید
-
setAddrForm(f => ({ ...f, latitude: lt, longitude: ln }))}
- />
- {addrForm.latitude && addrForm.longitude && (
- setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
- حذف موقعیت
-
- )}
-
-
-
- setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
-
-
-
-
setAddrFormOpen(false)}>انصراف
-
saveAddrMutation.mutate(addrForm)}>
- {saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
-
+
setAddrFormOpen(false)}
+ footer={
+ <>
+ setAddrFormOpen(false)}>انصراف
+ saveAddrMutation.mutate(addrForm)}>
+ {saveAddrMutation.isPending ? 'در حال ذخیره…' : 'ذخیره'}
+
+ >
+ }
+ >
+
+
+
+
+ setAddrForm(f => ({ ...f, name: e.target.value }))} />
-
,
- document.body,
- )}
+
+
+
+
+ ({ 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,
+ }))}
+ />
+
+
+
+ ({ 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); });
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
+
+
+
+
+
+ setAddrForm(f => ({ ...f, latitude: lt, longitude: ln }))}
+ />
+ برای تعیین موقعیت دقیق، روی نقشه کلیک کنید
+ {addrForm.latitude && addrForm.longitude && (
+ setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
+ حذف موقعیت
+
+ )}
+
+
+
{/* Delete Address Confirm */}
diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx
index 5ac8edc9..4fa95aec 100644
--- a/assets/admin/pages/DashboardPage.tsx
+++ b/assets/admin/pages/DashboardPage.tsx
@@ -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}
/>
@@ -833,6 +835,7 @@ function DoctorDashboard() {
revenueYear={chartPeriod.revenueYear}
onRevenueYearChange={chartPeriod.setRevenueYear}
currentJalaliYear={chartPeriod.currentJalaliYear}
+ currentJalaliMonth={chartPeriod.currentJalaliMonth}
/>