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:
@@ -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(<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <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 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 (
|
||||
<ChartFrame ticks={ticks} labels={data.map((x) => x.label)} yWidth={64}>
|
||||
@@ -157,22 +204,98 @@ export function TauriLineChart({ data }: { data: ChartPoint[] }) {
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="tdIncomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3A6FF8" stopOpacity={0.1} />
|
||||
<stop offset="100%" stopColor="#3A6FF8" stopOpacity={0.02} />
|
||||
{/* رنگِ خط در طول محور افقی از برند به اکسنت میرود (سبک دموی apex) */}
|
||||
<linearGradient id="tdIncomeStroke" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="var(--primary)" />
|
||||
<stop offset="55%" stopColor="var(--primary-600)" />
|
||||
<stop offset="100%" stopColor="var(--accent)" />
|
||||
</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>
|
||||
<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
|
||||
d={d}
|
||||
d={solid}
|
||||
fill="none"
|
||||
stroke="#5559CE"
|
||||
stroke="url(#tdIncomeStroke)"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
filter="url(#tdIncomeGlow)"
|
||||
style={{ strokeDasharray: 1200, animation: 'tddraw 1.1s var(--ease) both' }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-stretch gap-y-[24px] w-full">
|
||||
{/* Cards */}
|
||||
@@ -127,7 +133,7 @@ export function TauriDashboardView({
|
||||
selectorValue={revenueYear}
|
||||
onSelectorChange={onRevenueYearChange}
|
||||
>
|
||||
<TauriLineChart data={incomeLine} />
|
||||
<TauriLineChart data={incomeLine} actualCount={incomeActualCount} />
|
||||
</ChartCard>
|
||||
</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');
|
||||
});
|
||||
});
|
||||
@@ -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