- 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.
142 lines
6.6 KiB
TypeScript
142 lines
6.6 KiB
TypeScript
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>
|
||
);
|
||
}
|