- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience. - Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel. - Updated documentation to reflect the addition of tours and their implementation details.
186 lines
7.8 KiB
TypeScript
186 lines
7.8 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import {
|
||
BanknotesIcon, ReceiptPercentIcon, ChatBubbleLeftRightIcon,
|
||
CalculatorIcon, ArrowPathIcon, DocumentTextIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import { api } from '../lib/api';
|
||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
||
import Pagination from '../components/ui/Pagination';
|
||
import TourButton from '../components/ui/TourButton';
|
||
|
||
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 RANGES: { key: RangeKey; label: string }[] = [
|
||
{ key: 'today', label: 'امروز' },
|
||
{ key: 'week', label: '۷ روز اخیر' },
|
||
{ key: 'month', label: '۳۰ روز اخیر' },
|
||
{ key: 'all', label: 'همه' },
|
||
];
|
||
|
||
export default function RepresentationFinancePage() {
|
||
const [range, setRange] = useState<RangeKey>('month');
|
||
const [page, setPage] = useState(1);
|
||
const limit = 15;
|
||
|
||
const { data, isLoading, isFetching, refetch } = 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;
|
||
|
||
// جمعِ ردیفهای صفحهی جاری (نمایش سریع؛ دقیق برای همین صفحه).
|
||
const pageSums = useMemo(() => items.reduce(
|
||
(acc, r) => {
|
||
acc.share += r.representation_share_rials;
|
||
acc.gross += r.gross_rials;
|
||
acc.tax += r.tax_rials;
|
||
acc.sms += r.sms_fee_rials;
|
||
return acc;
|
||
},
|
||
{ share: 0, gross: 0, tax: 0, sms: 0 },
|
||
), [items]);
|
||
|
||
const cards = [
|
||
{ label: 'سهم نماینده (این صفحه)', value: formatRial(pageSums.share), icon: BanknotesIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
|
||
{ label: 'مبلغ نوبتها', value: formatRial(pageSums.gross), icon: CalculatorIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||
{ label: 'مالیات', value: formatRial(pageSums.tax), icon: ReceiptPercentIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||
{ label: 'هزینه پیامک', value: formatRial(pageSums.sms), icon: ChatBubbleLeftRightIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' },
|
||
];
|
||
|
||
return (
|
||
<div className="fade-in">
|
||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title"><h1 className="section-title">گزارش مالی</h1><TourButton tourId="representation-finance" ready /></div>
|
||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>
|
||
درآمد ثبتشده از پورسانت نوبتها — تفکیک هر تراکنش
|
||
</div>
|
||
</div>
|
||
<button className="btn ghost sm" onClick={() => refetch()} disabled={isFetching}>
|
||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||
بهروزرسانی
|
||
</button>
|
||
</div>
|
||
|
||
{/* فیلتر بازه — segmented */}
|
||
<div className="seg" style={{ marginBottom: 'var(--gap)' }}>
|
||
{RANGES.map(r => (
|
||
<button
|
||
key={r.key}
|
||
className={range === r.key ? 'on' : ''}
|
||
onClick={() => { setRange(r.key); setPage(1); }}
|
||
>
|
||
{r.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* کارتهای خلاصه */}
|
||
<div className="stat-grid" style={{ marginBottom: 'var(--gap)' }}>
|
||
{cards.map(c => (
|
||
<div key={c.label} className="stat">
|
||
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||
<c.icon style={{ width: 20, height: 20 }} />
|
||
</div>
|
||
<div className="lbl">{c.label}</div>
|
||
<div className="val" style={{ fontSize: 15, color: c.color }}>
|
||
{isLoading ? '—' : c.value}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* جدول تراکنشها */}
|
||
<div className="card" style={{ overflow: 'hidden' }}>
|
||
<div className="card-pad" style={{ borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<h3 style={{ fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<DocumentTextIcon style={{ width: 18, height: 18, color: 'var(--text-3)' }} />
|
||
تراکنشها
|
||
</h3>
|
||
{!isLoading && <span className="muted" style={{ fontSize: 12.5 }}>{formatNumber(total)} ردیف</span>}
|
||
</div>
|
||
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<div className="table-wrap"><table className="tbl tbl-zebra" style={{ width: '100%', minWidth: 720 }}>
|
||
<thead>
|
||
<tr>
|
||
<th>پزشک</th>
|
||
<th>مبلغ نوبت</th>
|
||
<th>مالیات</th>
|
||
<th>هزینه پیامک</th>
|
||
<th>درصد پورسانت</th>
|
||
<th>سهم نماینده</th>
|
||
<th>تاریخ</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading && Array.from({ length: 5 }).map((_, i) => (
|
||
<tr key={i}>
|
||
{Array.from({ length: 7 }).map((__, j) => (
|
||
<td key={j}><div className="skeleton" style={{ height: 13, borderRadius: 6, width: `${50 + (j * 13) % 40}%` }} /></td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
|
||
{!isLoading && items.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7}>
|
||
<div style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-3)' }}>
|
||
<BanknotesIcon style={{ width: 36, height: 36, margin: '0 auto 10px', opacity: .4 }} />
|
||
<div style={{ fontSize: 14 }}>در این بازه درآمدی ثبت نشده است</div>
|
||
<div style={{ fontSize: 12.5, marginTop: 4 }}>با ثبت و پرداخت نوبت از دامنهی شما، پورسانت اینجا نمایش داده میشود.</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
|
||
{!isLoading && items.map(r => (
|
||
<tr key={r.uuid}>
|
||
<td style={{ fontWeight: 500 }}>{r.doctor_name ?? '—'}</td>
|
||
<td>{formatRial(r.gross_rials)}</td>
|
||
<td className="muted">{formatRial(r.tax_rials)}</td>
|
||
<td className="muted">{formatRial(r.sms_fee_rials)}</td>
|
||
<td><span className="badge gray">{formatNumber(r.commission_percent)}٪</span></td>
|
||
<td style={{ fontWeight: 700, color: 'var(--primary)' }}>{formatRial(r.representation_share_rials)}</td>
|
||
<td className="muted" style={{ whiteSpace: 'nowrap' }}>{formatDate(r.created_at)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table></div>
|
||
</div>
|
||
</div>
|
||
|
||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||
</div>
|
||
);
|
||
}
|