feat: Add online share functionality for secretaries

- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments.
- Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements.
- Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`.
- Implemented `SecretaryEarning` entity and repository for managing secretary earnings.
- Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments.
- Added `UserIbanResolver` service to handle user IBAN retrieval and management.
- Created `HasIbansTrait` for entities to manage IBANs in a JSON format.
- Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
This commit is contained in:
hamed
2026-07-25 18:34:18 +03:30
parent 73a608c3dd
commit 8d2b0d908a
33 changed files with 2564 additions and 76 deletions
+6
View File
@@ -33,6 +33,9 @@ import BlogsPage from './pages/BlogsPage';
import BlogFormPage from './pages/BlogFormPage';
import BlogReviewPage from './pages/BlogReviewPage';
import SecretariesPage from './pages/SecretariesPage';
import SecretaryDetailPage from './pages/SecretaryDetailPage';
import SecretaryEarningsPage from './pages/SecretaryEarningsPage';
import SecretarySettlementPage from './pages/SecretarySettlementPage';
import ClinicDoctorsPage from './pages/ClinicDoctorsPage';
import SettingsPage from './pages/SettingsPage';
import FinancialReportPage from './pages/FinancialReportPage';
@@ -209,6 +212,9 @@ export default function App() {
<Route path="blogs/:uuid/edit" element={<RoleRoute roles={['admin']}><BlogFormPage /></RoleRoute>} />
<Route path="blog-review" element={<RoleRoute roles={['admin']}><BlogReviewPage /></RoleRoute>} />
<Route path="secretaries" element={<RoleRoute roles={['admin']}><SecretariesPage /></RoleRoute>} />
<Route path="secretaries/:uuid" element={<RoleRoute roles={['admin']}><SecretaryDetailPage /></RoleRoute>} />
<Route path="secretary-earnings" element={<RoleRoute roles={['secretary']}><SecretaryEarningsPage /></RoleRoute>} />
<Route path="secretary-settlement" element={<RoleRoute roles={['secretary']}><SecretarySettlementPage /></RoleRoute>} />
<Route path="clinics" element={<RoleRoute roles={['admin', 'representation']}><ClinicsPage /></RoleRoute>} />
<Route path="settings" element={<RoleRoute roles={['admin']}><SettingsPage /></RoleRoute>} />
+27 -1
View File
@@ -11,6 +11,7 @@ import {
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CreditCardIcon,
CurrencyDollarIcon,
DevicePhoneMobileIcon,
DocumentTextIcon,
FolderOpenIcon,
@@ -30,6 +31,7 @@ import { useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { usePermissions } from "../../hooks/usePermissions";
import { useSecretaryEarnings } from "../../hooks/useSecretaryEarnings";
import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore";
@@ -65,6 +67,8 @@ function buildSections(
dbUuid: string | null,
scope: string | null,
can: (resource: string, action: string) => boolean,
/** سهم درآمد نوبت آنلاین برای این منشی فعال است؟ آیتم‌های مالی به آن گِیت می‌شوند. */
secretaryShareEnabled = false,
): Section[] {
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
// برایش تعیین کرده ساخته می‌شود، نه به‌صورت hardcode.
@@ -446,6 +450,21 @@ function buildSections(
label: "انبارداری",
});
}
// درآمد و تسویه فقط وقتی ادمین سهم نوبت آنلاین را برای این منشی فعال کرده باشد.
if (secretaryShareEnabled) {
items.push(
{
to: "/admin/secretary-earnings",
icon: CurrencyDollarIcon,
label: "درآمد من",
},
{
to: "/admin/secretary-settlement",
icon: BanknotesIcon,
label: "تسویه حساب",
},
);
}
// منابعِ زیرمجموعهٔ «تنظیمات» (staff/discounts/sms/tags/appointment_settings/
// clinic_doctors) در سایدبار اصلی نمی‌آیند — دقیقاً مثل پزشک/کلینیک، فقط داخل
@@ -705,7 +724,14 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const navigate = useNavigate();
const { can } = usePermissions();
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null, can);
const { summary: secretaryEarnings } = useSecretaryEarnings(primaryRole === "secretary");
const sections = buildSections(
primaryRole,
dbUuid,
context?.scope ?? null,
can,
secretaryEarnings?.enabled ?? false,
);
const initials = (userName ?? "U").charAt(0).toUpperCase();
return (
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
export interface SecretaryEarningsSummary {
enabled: boolean;
share_percent: number;
today_rials: number;
this_month_rials: number;
total_rials: number;
appointments_count: number;
wallet_balance_rials: number;
}
/**
* خلاصهٔ درآمد منشی از نوبت‌های آنلاین. `enabled` تنها منبع تصمیم برای نمایش
* آیتم‌های مالی منشی است؛ سرور همان قاعده را اعمال می‌کند.
*/
export function useSecretaryEarnings(enabled = true) {
const { data, isLoading } = useQuery<ApiResponse<{ data: SecretaryEarningsSummary }>>({
queryKey: ['secretary-earnings-summary'],
queryFn: () => api.get('/api/v1/secretary/earnings/summary'),
staleTime: 5 * 60 * 1000,
enabled,
});
const summary = (data?.data as any)?.data ?? data?.data;
return { summary: summary as SecretaryEarningsSummary | undefined, isLoading };
}
+11 -2
View File
@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrashIcon, PencilIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { TrashIcon, PencilIcon, MagnifyingGlassIcon, CurrencyDollarIcon } from '@heroicons/react/24/outline';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Secretary, SecretaryPermissions } from '../types';
import { formatDate, maskMobile } from '../lib/utils';
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import { ActiveBadge } from '../components/ui/StatusBadge';
import Pagination from '../components/ui/Pagination';
@@ -274,6 +275,11 @@ export default function SecretariesPage() {
{ key: 'mobile_number', header: 'موبایل', render: (s) => <span dir="ltr">{maskMobile(s.mobile_number)}</span> },
{ key: 'doctor_name', header: 'پزشک', render: (s) => s.doctor_name },
{ key: 'is_active', header: 'وضعیت', render: (s) => <ActiveBadge active={s.is_active} /> },
{ key: 'online_share_percent', header: 'سهم آنلاین', render: (s) => (
s.online_share_enabled
? <span style={{ fontSize: 12.5, color: 'var(--primary)', fontWeight: 700 }}>{formatNumber(s.online_share_percent ?? 0)}٪</span>
: <span className="muted"></span>
) },
{ key: 'created_at', header: 'تاریخ ثبت', render: (s) => formatDate(s.created_at) },
];
@@ -309,6 +315,9 @@ export default function SecretariesPage() {
emptyMessage="هیچ منشی‌ای یافت نشد"
actions={(sec) => (
<>
<Link to={`/admin/secretaries/${sec.uuid}`} className="mini-btn" title="جزئیات و سهم درآمد">
<CurrencyDollarIcon style={{ width: 15, height: 15 }} />
</Link>
<button onClick={() => openEdit(sec)} className="mini-btn" title="ویرایش دسترسی‌ها">
<PencilIcon style={{ width: 15, height: 15 }} />
</button>
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
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 {},
}));
import { api } from '../lib/api';
import SecretaryDetailPage from './SecretaryDetailPage';
const get = api.get as ReturnType<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
function mockSecretary(over: Record<string, unknown> = {}) {
get.mockResolvedValue({
success: true,
data: { data: {
uuid: 'rel-1', user_name: 'زهرا رضایی', mobile_number: '09120000000',
doctor_name: 'دکتر تست', doctor_uuid: 'doc-1', is_active: true,
permissions: {}, created_at: 1_700_000_000,
online_share_enabled: false, online_share_percent: 0,
earnings: { total_rials: 1_500_000, this_month_rials: 500_000, appointments_count: 3 },
...over,
} },
});
}
beforeEach(() => {
get.mockReset();
put.mockReset();
put.mockResolvedValue({ success: true, data: { data: {} } });
});
function renderDetail() {
return renderWithProviders(
<Routes><Route path="/admin/secretaries/:uuid" element={<SecretaryDetailPage />} /></Routes>,
{ route: '/admin/secretaries/rel-1' },
);
}
describe('SecretaryDetailPage', () => {
it('مشخصات منشی و خلاصهٔ درآمد را نشان می‌دهد', async () => {
mockSecretary();
renderDetail();
expect(await screen.findByText('اطلاعات منشی')).toBeInTheDocument();
expect(screen.getByText('دکتر تست')).toBeInTheDocument();
expect(screen.getByText('سهم درآمد نوبت‌های آنلاین')).toBeInTheDocument();
expect(screen.getByText('خلاصه درآمد')).toBeInTheDocument();
});
it('سوییچ و درصد را از سرور پیش‌پر می‌کند و ذخیره می‌فرستد', async () => {
mockSecretary({ online_share_enabled: true, online_share_percent: 5 });
renderDetail();
await waitFor(() => expect(screen.getByLabelText('درصد سهم منشی')).toHaveValue('5'));
expect(screen.getByLabelText('محاسبه درآمد منشی از نوبت‌های آنلاین')).toBeChecked();
fireEvent.change(screen.getByLabelText('درصد سهم منشی'), { target: { value: '7' } });
fireEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
await waitFor(() => expect(put).toHaveBeenCalledWith('/api/v1/admin/secretary/rel-1/online-share', {
enabled: true,
percent: 7,
}));
});
it('درصد بیشتر از ۱۰۰ خطا می‌دهد و ذخیره غیرفعال می‌شود', async () => {
mockSecretary({ online_share_enabled: true, online_share_percent: 5 });
renderDetail();
await waitFor(() => expect(screen.getByLabelText('درصد سهم منشی')).toHaveValue('5'));
fireEvent.change(screen.getByLabelText('درصد سهم منشی'), { target: { value: '101' } });
expect(screen.getByText('درصد نمی‌تواند بیشتر از ۱۰۰ باشد')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
expect(put).not.toHaveBeenCalled();
});
it('فعال‌سازی با درصد صفر مجاز نیست', async () => {
mockSecretary();
renderDetail();
await screen.findByText('اطلاعات منشی');
fireEvent.click(screen.getByLabelText('محاسبه درآمد منشی از نوبت‌های آنلاین'));
expect(screen.getByText('برای فعال‌سازی، درصد باید بیشتر از صفر باشد')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
});
});
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { ChevronRightIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Secretary } from '../types';
import { digitsOnly, formatDate, formatNumber, formatRial } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
/** یک ردیف label:value با همان تم کارت‌های موجود. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, padding: '9px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>{label}</span>
<span style={{ fontSize: 13.5, color: 'var(--text)', fontWeight: 500 }}>{value}</span>
</div>
);
}
/**
* جزئیات یک رابطهٔ منشی–پزشک/کلینیک برای ادمین: مشخصات، سهم درآمد نوبت‌های آنلاین
* (فعال‌سازی + درصد) و خلاصهٔ درآمد.
*/
export default function SecretaryDetailPage() {
const { uuid = '' } = useParams();
const qc = useQueryClient();
const [enabled, setEnabled] = useState(false);
const [percent, setPercent] = useState('0');
const { data, isLoading } = useQuery<ApiResponse<{ data: Secretary }>>({
queryKey: ['admin-secretary', uuid],
queryFn: () => api.get(`/api/v1/admin/secretary/${uuid}`),
enabled: !!uuid,
});
const secretary = data?.data?.data;
useEffect(() => {
if (!secretary) return;
setEnabled(!!secretary.online_share_enabled);
setPercent(String(secretary.online_share_percent ?? 0));
}, [data]);
const save = useMutation({
mutationFn: () => api.put(`/api/v1/admin/secretary/${uuid}/online-share`, {
enabled,
percent: Number(percent) || 0,
}),
onSuccess: () => {
toast.success('سهم درآمد منشی ذخیره شد');
qc.invalidateQueries({ queryKey: ['admin-secretary', uuid] });
qc.invalidateQueries({ queryKey: ['secretaries'] });
},
onError: (e: Error) => toast.error(e.message),
});
const invalidPercent = Number(percent) > 100 || (enabled && Number(percent) <= 0);
if (isLoading || !secretary) {
return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
}
return (
<div className="fade-in">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
<Link to="/admin/secretaries" className="btn sm ghost" style={{ color: 'var(--text-2)' }}>
<ChevronRightIcon style={{ width: 16 }} /> بازگشت
</Link>
</div>
<PageHeader title={secretary.user_name} description="جزئیات منشی و سهم درآمد نوبت‌های آنلاین" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--gap)' }}>
<div className="card" style={{ padding: 20 }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 10px' }}>اطلاعات منشی</h2>
<Row label="نام" value={secretary.user_name} />
<Row label="موبایل" value={<span dir="ltr">{secretary.mobile_number}</span>} />
<Row label="پزشک" value={secretary.doctor_name} />
{secretary.clinic_name && <Row label="کلینیک" value={secretary.clinic_name} />}
<Row label="وضعیت" value={
<span style={{ color: secretary.is_active ? 'var(--success)' : 'var(--text-3)', fontWeight: 700 }}>
{secretary.is_active ? 'فعال' : 'غیرفعال'}
</span>
} />
<Row label="تاریخ ثبت" value={formatDate(secretary.created_at)} />
</div>
<div className="card" style={{ padding: 20 }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 6px' }}>سهم درآمد نوبتهای آنلاین</h2>
<p style={{ margin: '0 0 14px', fontSize: 12, lineHeight: 1.9, color: 'var(--text-2)' }}>
درصد سهم از <b>مبلغ خالص</b> نوبت محاسبه می‌شود: ابتدا هزینهٔ پیامک و مالیات و
کسورات از مبلغ نوبت کم میشود، سپس این درصد اعمال میگردد. فقط نوبتهایی که
آنلاین ثبت و پرداخت میشوند سهم میسازند.
</p>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', marginBottom: 14 }}>
<span className="switch">
<input
type="checkbox"
aria-label="محاسبه درآمد منشی از نوبت‌های آنلاین"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<span className="switch-track"><span className="switch-thumb" /></span>
</span>
<span style={{ fontSize: 13 }}>محاسبه درآمد از نوبتهای آنلاین فعال باشد</span>
</label>
<div className="form-row">
<label>درصد سهم منشی</label>
<input
className="input"
inputMode="numeric"
dir="ltr"
aria-label="درصد سهم منشی"
value={percent}
onChange={(e) => setPercent(digitsOnly(e.target.value, 3))}
placeholder="مثلاً: ۵"
/>
{Number(percent) > 100 && <p className="err-text">درصد نمیتواند بیشتر از ۱۰۰ باشد</p>}
{enabled && Number(percent) <= 0 && <p className="err-text">برای فعالسازی، درصد باید بیشتر از صفر باشد</p>}
</div>
<button
className="btn primary sm"
style={{ marginTop: 14 }}
disabled={save.isPending || invalidPercent}
onClick={() => save.mutate()}
>
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
<div className="card" style={{ padding: 20 }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 10px' }}>خلاصه درآمد</h2>
<Row label="کل درآمد" value={formatRial(secretary.earnings?.total_rials ?? 0)} />
<Row label="۳۰ روز گذشته" value={formatRial(secretary.earnings?.this_month_rials ?? 0)} />
<Row label="تعداد نوبت" value={formatNumber(secretary.earnings?.appointments_count ?? 0)} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, 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 {},
}));
import { api } from '../lib/api';
import SecretaryEarningsPage from './SecretaryEarningsPage';
const get = api.get as ReturnType<typeof vi.fn>;
const SUMMARY = {
enabled: true, share_percent: 5, relations: [],
today_rials: 500_000, this_month_rials: 3_000_000, total_rials: 9_000_000,
appointments_count: 4, wallet_balance_rials: 9_000_000,
};
const ROW = {
uuid: 'e1', appointment_uuid: 'ap1', doctor_name: 'دکتر تست',
gross_rials: 10_000_000, sms_fee_rials: 0, tax_rials: 0,
net_after_tax_rials: 10_000_000, share_percent: 5, share_rials: 500_000,
created_at: 1_700_000_000,
};
function mockEndpoints(summary: Record<string, unknown>, rows: object[] = []) {
get.mockImplementation((url?: string) => {
if (url === undefined) return Promise.resolve({ success: true, data: [] });
if (url.startsWith('/api/v1/secretary/earnings/summary')) {
return Promise.resolve({ success: true, data: { data: summary } });
}
if (url.startsWith('/api/v1/secretary/earnings/report')) {
return Promise.resolve({ success: true, data: rows, meta: { totalRecords: rows.length, totalPages: 1, currentPage: 1 } });
}
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => get.mockReset());
describe('SecretaryEarningsPage', () => {
it('کارت‌های درآمد و ردیف گزارش را نشان می‌دهد', async () => {
mockEndpoints(SUMMARY, [ROW]);
renderWithProviders(<SecretaryEarningsPage />);
expect(await screen.findByText('امروز')).toBeInTheDocument();
expect(screen.getByText('۳۰ روز گذشته')).toBeInTheDocument();
expect(screen.getByText('موجودی کیف پول')).toBeInTheDocument();
expect(await screen.findByText('دکتر تست')).toBeInTheDocument();
expect(screen.getByText('سهم شما')).toBeInTheDocument();
expect(screen.getByText('۵٪')).toBeInTheDocument();
});
it('غیرفعال بودن قابلیت → پیام و بدون جدول', async () => {
mockEndpoints({ ...SUMMARY, enabled: false });
renderWithProviders(<SecretaryEarningsPage />);
await waitFor(() => expect(screen.getByText(/برای شما فعال نیست/)).toBeInTheDocument());
expect(screen.queryByText('امروز')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, formatRial } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import StatCard from '../components/ui/StatCard';
import DataTable, { Column } from '../components/ui/DataTable';
import Pagination from '../components/ui/Pagination';
import PersianDateInput from '../components/ui/PersianDateInput';
interface EarningsSummary {
enabled: boolean;
share_percent: number;
relations: { relation_uuid: string; doctor_name: string; clinic_name: string | null; share_percent: number }[];
today_rials: number;
this_month_rials: number;
total_rials: number;
appointments_count: number;
wallet_balance_rials: number;
}
interface EarningRow {
uuid: string;
appointment_uuid: string | null;
doctor_name: string | null;
gross_rials: number;
sms_fee_rials: number;
tax_rials: number;
net_after_tax_rials: number;
share_percent: number;
share_rials: number;
created_at: number;
}
/** تاریخ ISO (Y-m-d) → Unix؛ رشتهٔ خالی یعنی بدون فیلتر. */
const toUnix = (iso: string): string => (iso ? String(Math.floor(new Date(iso).getTime() / 1000)) : '');
/** درآمد منشی از نوبت‌های آنلاین: خلاصهٔ روزانه/ماهانه + گزارش سطر-به-سطر. */
export default function SecretaryEarningsPage() {
const [page, setPage] = useState(1);
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const limit = 15;
const { data: summaryData } = useQuery<ApiResponse<{ data: EarningsSummary }>>({
queryKey: ['secretary-earnings-summary'],
queryFn: () => api.get('/api/v1/secretary/earnings/summary'),
});
const summary = summaryData?.data?.data;
const { data, isLoading } = useQuery({
queryKey: ['secretary-earnings-report', page, from, to],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (from) params.set('from', toUnix(from));
if (to) params.set('to', toUnix(to));
return api.get<PaginatedResponse<EarningRow>>(`/api/v1/secretary/earnings/report?${params}`);
},
});
const rows = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
const columns: Column<EarningRow>[] = [
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
{ key: 'doctor_name', header: 'پزشک', render: (r) => r.doctor_name ?? '—' },
{ key: 'gross_rials', header: 'مبلغ نوبت', render: (r) => formatRial(r.gross_rials) },
{ key: 'sms_fee_rials', header: 'هزینه پیامک', render: (r) => (r.sms_fee_rials > 0 ? formatRial(r.sms_fee_rials) : '—') },
{ key: 'tax_rials', header: 'مالیات', render: (r) => (r.tax_rials > 0 ? formatRial(r.tax_rials) : '—') },
{ key: 'net_after_tax_rials', header: 'مبلغ خالص', render: (r) => formatRial(r.net_after_tax_rials) },
{ key: 'share_percent', header: 'درصد', render: (r) => `${formatNumber(r.share_percent)}٪` },
{ key: 'share_rials', header: 'سهم شما', render: (r) => (
<b style={{ color: 'var(--success)' }}>{formatRial(r.share_rials)}</b>
) },
];
return (
<div className="fade-in">
<PageHeader title="درآمد نوبت‌های آنلاین" description="سهم شما از نوبت‌هایی که آنلاین ثبت و پرداخت شده‌اند" />
{summary && !summary.enabled ? (
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text-2)', lineHeight: 1.9 }}>
محاسبه درآمد از نوبتهای آنلاین برای شما فعال نیست. برای فعالسازی با مدیر سیستم تماس بگیرید.
</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard label="امروز" value={formatRial(summary?.today_rials ?? 0)} tone="green" />
<StatCard label="۳۰ روز گذشته" value={formatRial(summary?.this_month_rials ?? 0)} tone="violet" />
<StatCard label="کل درآمد" value={formatRial(summary?.total_rials ?? 0)} tone="amber" />
<StatCard label="موجودی کیف پول" value={formatRial(summary?.wallet_balance_rials ?? 0)} tone="pink" />
</div>
<div className="card">
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar" style={{ gap: 12, flexWrap: 'wrap' }}>
<div style={{ minWidth: 170 }}>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>از تاریخ</label>
<PersianDateInput value={from} onChange={(v) => { setFrom(v); setPage(1); }} placeholder="انتخاب" />
</div>
<div style={{ minWidth: 170 }}>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>تا تاریخ</label>
<PersianDateInput value={to} onChange={(v) => { setTo(v); setPage(1); }} placeholder="انتخاب" />
</div>
<div style={{ marginInlineStart: 'auto', fontSize: 12.5, color: 'var(--text-2)' }}>
{formatNumber(summary?.appointments_count ?? 0)} نوبت
</div>
</div>
</div>
<DataTable<EarningRow>
columns={columns}
data={rows}
loading={isLoading}
emptyMessage="هنوز درآمدی از نوبت آنلاین ثبت نشده است"
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrashIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDate, formatRial, tomanToRial } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import StatCard from '../components/ui/StatCard';
import SearchableSelect from '../components/ui/SearchableSelect';
import PriceInput from '../components/ui/PriceInput';
import DataTable, { Column } from '../components/ui/DataTable';
interface IbanItem { id: string; iban: string; bank_name: string | null; owner_name: string | null; verified: boolean }
interface SecretaryMe { bank_account: IbanItem[] | null }
interface SettlementRow {
uuid: string;
amount_rials: number;
status: string;
created_at: number;
bank_account?: { iban?: string } | null;
}
const STATUS_LABEL: Record<string, string> = {
pending: 'در انتظار بررسی',
approved: 'تأییدشده',
rejected: 'رد شده',
paid: 'پرداخت‌شده',
};
/** unwrap پاسخ‌های احتمالاً تودرتوی `success(['data' => …])`. */
const unwrap = <T,>(res: ApiResponse<T> | undefined): T | undefined =>
((res?.data as any)?.data ?? res?.data) as T | undefined;
/** تسویه حساب منشی: موجودی، مدیریت شبا و درخواست برداشت — الگوی پنل نماینده. */
export default function SecretarySettlementPage() {
const qc = useQueryClient();
const [amountToman, setAmountToman] = useState(0);
const [ibanId, setIbanId] = useState('');
const [newIban, setNewIban] = useState('');
const [bankName, setBankName] = useState('');
const balanceQ = useQuery<ApiResponse<{ balance_rials: number }>>({
queryKey: ['wallet-balance'],
queryFn: () => api.get('/api/v1/wallet/balance'),
});
const balance = unwrap(balanceQ.data)?.balance_rials ?? 0;
const meQ = useQuery<ApiResponse<SecretaryMe>>({
queryKey: ['secretary-me'],
queryFn: () => api.get('/api/v1/secretary/me'),
});
const ibans = unwrap(meQ.data)?.bank_account ?? [];
const verifiedIbans = ibans.filter((b) => b.verified);
const listQ = useQuery<ApiResponse<SettlementRow[]>>({
queryKey: ['secretary-settlements'],
queryFn: () => api.get('/api/v1/settlement?page=1&limit=15'),
});
const settlements = unwrap(listQ.data) ?? [];
const invalidateWallet = () => {
qc.invalidateQueries({ queryKey: ['wallet-balance'] });
qc.invalidateQueries({ queryKey: ['secretary-settlements'] });
};
const addIbanMut = useMutation({
mutationFn: () => api.post('/api/v1/secretary/iban', {
iban: newIban.trim().toUpperCase(),
...(bankName.trim() ? { bank_name: bankName.trim() } : {}),
}),
onSuccess: () => {
toast.success('شماره شبا ثبت شد؛ پس از تأیید ادمین قابل استفاده است');
setNewIban('');
setBankName('');
qc.invalidateQueries({ queryKey: ['secretary-me'] });
},
onError: (e: Error) => toast.error(e.message),
});
const removeIbanMut = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/secretary/iban/${id}`),
onSuccess: () => {
toast.success('شماره شبا حذف شد');
qc.invalidateQueries({ queryKey: ['secretary-me'] });
},
onError: (e: Error) => toast.error(e.message),
});
const requestMut = useMutation({
mutationFn: () => api.post('/api/v1/settlement', {
amount_rials: tomanToRial(amountToman),
iban_id: ibanId,
}),
onSuccess: () => {
toast.success('درخواست تسویه ثبت شد');
setAmountToman(0);
invalidateWallet();
},
onError: (e: Error) => toast.error(e.message),
});
const columns: Column<SettlementRow>[] = [
{ key: 'created_at', header: 'تاریخ', render: (r) => formatDate(r.created_at) },
{ key: 'amount_rials', header: 'مبلغ', render: (r) => formatRial(r.amount_rials) },
{ key: 'bank_account', header: 'شبا', render: (r) => <span dir="ltr">{r.bank_account?.iban ?? '—'}</span> },
{ key: 'status', header: 'وضعیت', render: (r) => STATUS_LABEL[r.status] ?? r.status },
];
return (
<div className="fade-in">
<PageHeader title="تسویه حساب" description="برداشت سهم نوبت‌های آنلاین به شماره شبای شما" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--gap)', marginBottom: 'var(--gap)' }}>
<StatCard label="موجودی قابل برداشت" value={formatRial(balance)} tone="green" />
</div>
<div className="card" style={{ padding: 20, marginBottom: 'var(--gap)' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px' }}>شماره شبا</h2>
{ibans.length === 0 ? (
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '0 0 12px' }}>هنوز شبایی ثبت نکردهاید.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{ibans.map((b) => (
<div key={b.id} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
}}>
<span dir="ltr" style={{ flex: 1, fontSize: 13 }}>{b.iban}</span>
{b.bank_name && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>{b.bank_name}</span>}
<span style={{ fontSize: 11.5, fontWeight: 700, color: b.verified ? 'var(--success)' : 'var(--warning)' }}>
{b.verified ? 'تأییدشده' : 'در انتظار تأیید'}
</span>
<button
className="mini-btn danger"
title="حذف"
disabled={removeIbanMut.isPending}
onClick={() => removeIbanMut.mutate(b.id)}
>
<TrashIcon style={{ width: 14, height: 14 }} />
</button>
</div>
))}
</div>
)}
{ibans.length < 2 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, alignItems: 'end' }}>
<div className="form-row">
<label>شماره شبا</label>
<input
className="input"
dir="ltr"
aria-label="شماره شبا"
placeholder="IR..."
value={newIban}
onChange={(e) => setNewIban(e.target.value)}
/>
</div>
<div className="form-row">
<label>نام بانک (اختیاری)</label>
<input className="input" aria-label="نام بانک" value={bankName} onChange={(e) => setBankName(e.target.value)} />
</div>
<button
className="btn primary sm"
disabled={addIbanMut.isPending || newIban.trim() === ''}
onClick={() => addIbanMut.mutate()}
>
{addIbanMut.isPending ? 'در حال ثبت...' : 'افزودن شبا'}
</button>
</div>
)}
</div>
<div className="card" style={{ padding: 20, marginBottom: 'var(--gap)' }}>
<h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px' }}>درخواست تسویه</h2>
{verifiedIbans.length === 0 ? (
<p style={{ fontSize: 12.5, color: 'var(--text-2)', margin: 0, lineHeight: 1.9 }}>
برای ثبت درخواست تسویه، باید حداقل یک شماره شبای <b>تأییدشده</b> داشته باشید.
</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, alignItems: 'end' }}>
<div className="form-row">
<label>مبلغ (تومان)</label>
<PriceInput value={amountToman} onChange={setAmountToman} suffix="تومان" />
</div>
<div className="form-row">
<label>شماره شبا</label>
<SearchableSelect
options={verifiedIbans.map((b) => ({ value: b.id, label: `${b.iban}${b.bank_name ? `${b.bank_name}` : ''}` }))}
value={ibanId || null}
onChange={(v) => setIbanId(v ? String(v) : '')}
placeholder="انتخاب شبا"
/>
</div>
<button
className="btn primary sm"
disabled={requestMut.isPending || amountToman <= 0 || ibanId === ''}
onClick={() => requestMut.mutate()}
>
{requestMut.isPending ? 'در حال ثبت...' : 'ثبت درخواست'}
</button>
</div>
)}
</div>
<div className="card">
<DataTable<SettlementRow>
columns={columns}
data={settlements}
loading={listQ.isLoading}
emptyMessage="درخواست تسویه‌ای ثبت نشده است"
/>
</div>
</div>
);
}
+10
View File
@@ -485,6 +485,16 @@ export interface Secretary {
address?: string | null;
permissions: SecretaryPermissions;
created_at: string;
clinic_uuid?: string | null;
clinic_name?: string | null;
/** سهم منشی از نوبت‌های آنلاین — per-relation، توسط ادمین تنظیم می‌شود. */
online_share_enabled?: boolean;
online_share_percent?: number;
earnings?: {
total_rials: number;
this_month_rials: number;
appointments_count: number;
};
}
export interface SecretaryPermissions {