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:
@@ -29,6 +29,8 @@ import SecretariesPage from './pages/SecretariesPage';
|
||||
import MyClinicPage from './pages/MyClinicPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import FinancialReportPage from './pages/FinancialReportPage';
|
||||
import RepresentationSettlementPage from './pages/RepresentationSettlementPage';
|
||||
import RepresentationFinancePage from './pages/RepresentationFinancePage';
|
||||
import DoctorProfilePage from './pages/DoctorProfilePage';
|
||||
import MyPatientsPage from './pages/MyPatientsPage';
|
||||
import NewSessionPage from './pages/NewSessionPage';
|
||||
@@ -166,6 +168,8 @@ export default function App() {
|
||||
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
|
||||
|
||||
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه میکند */}
|
||||
<Route path="representation-settlement" element={<RoleRoute roles={['representation']}><RepresentationSettlementPage /></RoleRoute>} />
|
||||
<Route path="representation-finance" element={<RoleRoute roles={['representation']}><RepresentationFinancePage /></RoleRoute>} />
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>} />
|
||||
|
||||
@@ -383,6 +383,13 @@ function buildSections(
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتها" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "مالی",
|
||||
items: [
|
||||
{ to: "/admin/representation-finance", icon: CreditCardIcon, label: "گزارش مالی" },
|
||||
{ to: "/admin/representation-settlement", icon: BanknotesIcon, label: "تسویه حساب" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatRial, formatDate } from '../lib/utils';
|
||||
|
||||
interface WalletBalance { balance_rials: number }
|
||||
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
|
||||
interface SettlementRow {
|
||||
uuid: string;
|
||||
amount_rials: number;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'paid';
|
||||
admin_note: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: 'در انتظار بررسی', approved: 'تأیید شده', rejected: 'رد شده', paid: 'پرداخت شده',
|
||||
};
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
pending: 'gray', approved: 'green', rejected: 'red', paid: 'green',
|
||||
};
|
||||
|
||||
export default function RepresentationSettlementPage() {
|
||||
const qc = useQueryClient();
|
||||
const [amount, setAmount] = useState('');
|
||||
|
||||
const balanceQ = useQuery({
|
||||
queryKey: ['wallet-balance'],
|
||||
queryFn: () => api.get<ApiResponse<WalletBalance>>('/api/v1/wallet/balance'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const balance: number = ((balanceQ.data?.data as any)?.data ?? balanceQ.data?.data)?.balance_rials ?? 0;
|
||||
|
||||
const summaryQ = useQuery({
|
||||
queryKey: ['representation-summary'],
|
||||
queryFn: () => api.get<ApiResponse<RepSummary>>('/api/v1/representation/dashboard/summary'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const income = useMemo<any>(() => ((summaryQ.data?.data as any)?.data ?? summaryQ.data?.data)?.income, [summaryQ.data]);
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['settlements-mine'],
|
||||
queryFn: () => api.get<ApiResponse<SettlementRow[]>>('/api/v1/settlement'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const settlements: SettlementRow[] = (listQ.data?.data as any)?.data ?? listQ.data?.data ?? [];
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (amountRials: number) =>
|
||||
api.post<ApiResponse<SettlementRow>>('/api/v1/settlement', { amount_rials: amountRials }),
|
||||
onSuccess: () => {
|
||||
toast.success('درخواست تسویه ثبت شد');
|
||||
setAmount('');
|
||||
qc.invalidateQueries({ queryKey: ['settlements-mine'] });
|
||||
qc.invalidateQueries({ queryKey: ['wallet-balance'] });
|
||||
qc.invalidateQueries({ queryKey: ['representation-summary'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
const n = Number(amount);
|
||||
if (!n || n <= 0) { toast.error('مبلغ نامعتبر است'); return; }
|
||||
if (n > balance) { toast.error('مبلغ بیشتر از موجودی قابل برداشت است'); return; }
|
||||
createMut.mutate(n);
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{ label: 'موجودی قابل برداشت', value: formatRial(balance), color: 'var(--primary)' },
|
||||
{ label: 'مجموع تسویهشده', value: formatRial(income?.settled_rials ?? 0), color: 'var(--success)' },
|
||||
{ label: 'در انتظار تسویه', value: formatRial(income?.pending_rials ?? 0), color: 'var(--warning)' },
|
||||
];
|
||||
|
||||
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 className="stat-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||
{cards.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 className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 16 }}>ثبت درخواست جدید</h3>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="number" min={0} dir="ltr" value={amount} placeholder="مبلغ به ریال"
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ width: 240, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<button className="btn primary" onClick={submit} disabled={createMut.isPending}>
|
||||
{createMut.isPending ? 'در حال ثبت...' : 'ثبت درخواست'}
|
||||
</button>
|
||||
<span className="muted" style={{ fontSize: 12 }}>حداکثر: {formatRial(balance)}</span>
|
||||
</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></tr></thead>
|
||||
<tbody>
|
||||
{settlements.length === 0 && (
|
||||
<tr><td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 16 }}>درخواستی ثبت نشده است</td></tr>
|
||||
)}
|
||||
{settlements.map(s => (
|
||||
<tr key={s.uuid}>
|
||||
<td>{formatRial(s.amount_rials)}</td>
|
||||
<td><span className={`badge ${STATUS_CLASS[s.status] ?? 'gray'}`}>{STATUS_LABEL[s.status] ?? s.status}</span></td>
|
||||
<td>{s.admin_note ?? '—'}</td>
|
||||
<td>{formatDate(s.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -450,6 +450,13 @@ body {
|
||||
.badge.violet { color: var(--violet); background: var(--violet-bg); }
|
||||
.badge.gray { color: var(--text-2); background: var(--surface-3); }
|
||||
|
||||
/* ── Simple data table (rep dashboard/finance/settlement) ────── */
|
||||
.tbl { border-collapse: collapse; font-size: 13px; }
|
||||
.tbl thead tr { border-bottom: 1px solid var(--border); }
|
||||
.tbl th { text-align: right; padding: 8px 12px; color: var(--text-3); font-weight: 500; white-space: nowrap; }
|
||||
.tbl td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── Appointment status badges ───────────────────────────────── */
|
||||
.appt-status {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
|
||||
Reference in New Issue
Block a user