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:
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatRial, formatDate } from '../lib/utils';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
|
||||
interface FinanceRow {
|
||||
uuid: string;
|
||||
appointment_uuid: string | null;
|
||||
doctor_name: string | null;
|
||||
gross_rials: number;
|
||||
tax_rials: number;
|
||||
sms_fee_rials: number;
|
||||
commission_percent: number;
|
||||
representation_share_rials: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type RangeKey = 'today' | 'week' | 'month' | 'all';
|
||||
|
||||
function rangeFrom(key: RangeKey): number | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (key === 'today') return Math.floor(new Date().setHours(0, 0, 0, 0) / 1000);
|
||||
if (key === 'week') return now - 7 * 86400;
|
||||
if (key === 'month') return now - 30 * 86400;
|
||||
return null;
|
||||
}
|
||||
|
||||
const RANGE_LABEL: Record<RangeKey, string> = {
|
||||
today: 'امروز', week: 'این هفته', month: 'این ماه', all: 'همه',
|
||||
};
|
||||
|
||||
export default function RepresentationFinancePage() {
|
||||
const [range, setRange] = useState<RangeKey>('month');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['representation-finance', range, page],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
const from = rangeFrom(range);
|
||||
if (from !== null) params.set('from', String(from));
|
||||
return api.get<PaginatedResponse<FinanceRow>>(`/api/v1/representation/finance/report?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const items: FinanceRow[] = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">گزارش مالی</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>درآمد ثبتشده از پورسانت نوبتها</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 'var(--gap)' }}>
|
||||
{(['today', 'week', 'month', 'all'] as RangeKey[]).map(k => (
|
||||
<button key={k} className={range === k ? 'on' : ''} onClick={() => { setRange(k); setPage(1); }}>
|
||||
{RANGE_LABEL[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad">
|
||||
<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>
|
||||
{isLoading && (
|
||||
<tr><td colSpan={7} className="muted" style={{ textAlign: 'center', padding: 16 }}>در حال بارگذاری...</td></tr>
|
||||
)}
|
||||
{!isLoading && items.length === 0 && (
|
||||
<tr><td colSpan={7} className="muted" style={{ textAlign: 'center', padding: 16 }}>درآمدی در این بازه ثبت نشده است</td></tr>
|
||||
)}
|
||||
{items.map(r => (
|
||||
<tr key={r.uuid}>
|
||||
<td>{r.doctor_name ?? '—'}</td>
|
||||
<td>{formatRial(r.gross_rials)}</td>
|
||||
<td>{formatRial(r.tax_rials)}</td>
|
||||
<td>{formatRial(r.sms_fee_rials)}</td>
|
||||
<td>{r.commission_percent}٪</td>
|
||||
<td>{formatRial(r.representation_share_rials)}</td>
|
||||
<td>{formatDate(r.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user