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:
@@ -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>} />
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user