feat: implement domain guard for commission calculation and enhance representation dashboard

- Added domain guard in CommissionService to ensure commission is calculated only when the appointment is booked under the same representation as the doctor.
- Updated RepresentationController to filter statistics by representation, ensuring accurate data is shown for each representative.
- Introduced new endpoints for the representation dashboard to provide summary statistics, doctor performance, and financial reports.
- Created new pages for RepresentationFinance and RepresentationSettlement to display financial data and allow for settlement requests.
- Added migration to include booking_representation_id in appointments for tracking the representative under which the appointment was booked.
This commit is contained in:
hamed
2026-06-24 16:14:41 +03:30
parent 89e4a424f8
commit 9603b702c1
18 changed files with 928 additions and 54 deletions
+99 -32
View File
@@ -979,51 +979,66 @@ function SecretaryDashboard() {
);
}
function RepresentationDashboard() {
const now = new Date();
const jYear = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric' }).format(now));
const jMonth = Number(new Intl.DateTimeFormat('en-US-u-ca-persian', { month: 'numeric' }).format(now));
interface RepSummary {
appointments: { today: number; week: number; month: number; total: number };
income: {
today: number; week: number; month: number; total: number;
settlable_rials: number; settled_rials: number; pending_rials: number;
};
}
interface RepDoctorPerf {
uuid: string; name: string;
appointments: { today: number; week: number; month: number; total: number };
representation_income_rials: number;
subscription_status: 'active' | 'expired' | 'none';
}
function RepresentationDashboard() {
const meQ = useQuery({
queryKey: ['representation-me'],
queryFn: () => api.get<ApiResponse<{ data: { uuid: string; full_name: string; commission_percent: string } }>>('/api/v1/representation/me'),
queryFn: () => api.get<ApiResponse<{ data: { uuid: string; full_name: string } }>>('/api/v1/representation/me'),
staleTime: 300_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rep = useMemo<any>(() => (meQ.data?.data as any)?.data ?? meQ.data?.data, [meQ.data]);
const repUuid: string | undefined = rep?.uuid;
const monthlyQ = useQuery({
queryKey: ['representation-monthly', repUuid, jYear, jMonth],
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
`/api/v1/representation/${repUuid}/dashboard/monthly?year=${jYear}&month=${jMonth}`,
),
enabled: !!repUuid,
const summaryQ = useQuery({
queryKey: ['representation-summary'],
queryFn: () => api.get<ApiResponse<RepSummary>>('/api/v1/representation/dashboard/summary'),
staleTime: 120_000,
});
const yearlyQ = useQuery({
queryKey: ['representation-yearly', repUuid, jYear],
queryFn: () => api.get<ApiResponse<{ data: { stats: { total_appointments: number; total_revenue_rials: number; commission_rials: number } } }>>(
`/api/v1/representation/${repUuid}/dashboard/yearly?year=${jYear}`,
),
enabled: !!repUuid,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const summary = useMemo<RepSummary | undefined>(() => (summaryQ.data?.data as any)?.data ?? summaryQ.data?.data, [summaryQ.data]);
const perfQ = useQuery({
queryKey: ['representation-doctors-performance'],
queryFn: () => api.get<ApiResponse<RepDoctorPerf[]>>('/api/v1/representation/doctors/performance?limit=100'),
staleTime: 120_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const monthly = useMemo<any>(() => ((monthlyQ.data?.data as any)?.data ?? monthlyQ.data?.data)?.stats, [monthlyQ.data]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const yearly = useMemo<any>(() => ((yearlyQ.data?.data as any)?.data ?? yearlyQ.data?.data)?.stats, [yearlyQ.data]);
const doctors: RepDoctorPerf[] = perfQ.data?.data ?? [];
if (meQ.isLoading) return <LoadingSkeleton />;
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
const cards = [
{ label: 'نوبت‌های این ماه', value: formatNumber(monthly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'کمیسیون این ماه', value: formatRial(monthly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
{ label: 'نوبت‌های امسال', value: formatNumber(yearly?.total_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
{ label: 'کمیسیون امسال', value: formatRial(yearly?.commission_rials ?? 0), icon: CreditCardIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' },
const a = summary?.appointments;
const inc = summary?.income;
const apptCards = [
{ label: 'نوبت‌های امروز', value: formatNumber(a?.today ?? 0), color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'این هفته', value: formatNumber(a?.week ?? 0), color: 'var(--info)', bg: 'var(--info-bg)' },
{ label: 'این ماه', value: formatNumber(a?.month ?? 0), color: 'var(--success)', bg: 'var(--success-bg)' },
{ label: 'کل نوبت‌ها', value: formatNumber(a?.total ?? 0), color: 'var(--violet)', bg: 'var(--violet-bg)' },
];
const incomeCards = [
{ label: 'درآمد امروز', value: formatRial(inc?.today ?? 0), color: 'var(--warning)' },
{ label: 'درآمد این هفته', value: formatRial(inc?.week ?? 0), color: 'var(--info)' },
{ label: 'درآمد این ماه', value: formatRial(inc?.month ?? 0), color: 'var(--success)' },
{ label: 'درآمد کل', value: formatRial(inc?.total ?? 0), color: 'var(--violet)' },
{ label: 'قابل تسویه', value: formatRial(inc?.settlable_rials ?? 0),color: 'var(--primary)' },
{ label: 'تسویه‌شده', value: formatRial(inc?.settled_rials ?? 0), color: 'var(--text-2)' },
{ label: 'در انتظار تسویه', value: formatRial(inc?.pending_rials ?? 0), color: 'var(--text-3)' },
];
const subLabel: Record<string, string> = { active: 'فعال', expired: 'منقضی', none: 'بدون اشتراک' };
return (
<div className="fade-in">
@@ -1032,17 +1047,17 @@ function RepresentationDashboard() {
<h1 className="section-title">داشبورد نماینده</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {rep?.full_name ?? ''}</div>
</div>
<button className="btn ghost sm" onClick={() => { monthlyQ.refetch(); yearlyQ.refetch(); }}>
<button className="btn ghost sm" onClick={() => { summaryQ.refetch(); perfQ.refetch(); }}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
</div>
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
{cards.map(c => (
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
{apptCards.map(c => (
<div key={c.label} className="stat">
<div className="ico" style={{ background: c.bg, color: c.color }}>
<c.icon style={{ width: 21, height: 21 }} />
<CalendarDaysIcon style={{ width: 21, height: 21 }} />
</div>
<div className="lbl">{c.label}</div>
<div className="val">{c.value}</div>
@@ -1050,6 +1065,56 @@ function RepresentationDashboard() {
))}
</div>
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row" style={{ marginBottom: 12 }}>
<h3 style={{ fontSize: 16 }}>درآمد نماینده</h3>
</div>
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
{incomeCards.map(c => (
<div key={c.label} className="stat" style={{ background: 'var(--surface-3)' }}>
<div className="lbl">{c.label}</div>
<div className="val" style={{ color: c.color, fontSize: 15 }}>{c.value}</div>
</div>
))}
</div>
</div>
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row" style={{ marginBottom: 12 }}>
<h3 style={{ fontSize: 16 }}>عملکرد پزشکان</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="tbl" style={{ width: '100%' }}>
<thead>
<tr>
<th>پزشک</th><th>امروز</th><th>هفته</th><th>ماه</th><th>کل</th>
<th>درآمد نماینده</th><th>اشتراک</th>
</tr>
</thead>
<tbody>
{doctors.length === 0 && (
<tr><td colSpan={7} className="muted" style={{ textAlign: 'center', padding: 16 }}>پزشکی یافت نشد</td></tr>
)}
{doctors.map(d => (
<tr key={d.uuid}>
<td>{d.name}</td>
<td>{formatNumber(d.appointments.today)}</td>
<td>{formatNumber(d.appointments.week)}</td>
<td>{formatNumber(d.appointments.month)}</td>
<td>{formatNumber(d.appointments.total)}</td>
<td>{formatRial(d.representation_income_rials)}</td>
<td>
<span className={`badge ${d.subscription_status === 'active' ? 'green' : 'gray'}`}>
{subLabel[d.subscription_status] ?? d.subscription_status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>دسترسی سریع</h3>
@@ -1058,6 +1123,8 @@ function RepresentationDashboard() {
<Link to="/admin/doctors" className="btn sm">پزشکان من</Link>
<Link to="/admin/clinics" className="btn sm">کلینیکها</Link>
<Link to="/admin/appointments" className="btn sm">نوبتها</Link>
<Link to="/admin/representation-settlement" className="btn sm">تسویه حساب</Link>
<Link to="/admin/representation-finance" className="btn sm">گزارش مالی</Link>
</div>
</div>
</div>