Files
clinicpro/assets/admin/pages/SecretaryDetailPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

142 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from 'react-router';
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';
import Switch from '../components/ui/Switch';
/** یک ردیف 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 backTo="/admin/secretaries" 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 }}>
<Switch
checked={enabled}
onChange={setEnabled}
ariaLabel="محاسبه درآمد منشی از نوبت‌های آنلاین"
/>
<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>
);
}