Files
clinicpro/assets/admin/pages/RepresentationsPage.tsx
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

281 lines
13 KiB
TypeScript
Raw Permalink 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 React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { EyeIcon, NoSymbolIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { useUrlState, pageOf } from '../hooks/useUrlState';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import MobileInput from '../components/ui/MobileInput';
import { iranMobileSchema } from '../lib/utils';
import type { Representation, City } from '../types';
import { formatDate, formatRial, formatNumber } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import { ActiveBadge } from '../components/ui/StatusBadge';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
import Switch from '../components/ui/Switch';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
mobile_number: iranMobileSchema,
city_ids: z.array(z.number()).optional(),
domain: z.string().optional(),
is_global: z.boolean().optional(),
commission_percent: z.coerce.number().min(0).max(100),
});
type FormData = z.infer<typeof schema>;
export default function RepresentationsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
// وضعیت لیست در URL می‌ماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', city: '' });
const page = pageOf(urlState.page);
const search = urlState.search;
const cityFilter = urlState.city === '' ? null : Number(urlState.city);
const setPage = (p: number) => setUrlState({ page: String(p) });
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
const setCityFilter = (v: number | null) => setUrlState({ city: v === null ? '' : String(v), page: '1' });
const [addOpen, setAddOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
const limit = 15;
const citiesQuery = useQuery({
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cities: City[] = citiesQuery.data?.data ?? [];
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
const { data, isLoading } = useQuery({
queryKey: ['representations', page, search, cityFilter],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set('search', search);
if (cityFilter !== null) params.set('city_id', String(cityFilter));
return api.get<PaginatedResponse<Representation>>(`/api/v1/admin/representations?${params}`);
},
});
const { register, handleSubmit, reset, control, watch, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { commission_percent: 10, city_ids: [], is_global: false },
});
const isGlobal = watch('is_global') ?? false;
const createMutation = useMutation({
mutationFn: (d: FormData) =>
api.post<ApiResponse<Representation>>('/api/v1/representation', {
full_name: d.full_name,
mobile_number: d.mobile_number,
city_ids: d.city_ids ?? [],
is_global: d.is_global ?? false,
...(d.domain?.trim() && { domain: d.domain.trim() }),
commission_percent: d.commission_percent,
}),
onSuccess: () => {
toast.success('نماینده اضافه شد');
setAddOpen(false);
reset();
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const deactivateMutation = useMutation({
mutationFn: (r: Representation) =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${r.uuid}`, { active: false }),
onSuccess: () => {
toast.success('نماینده غیرفعال شد');
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const columns: Column<Representation>[] = [
{ key: 'full_name', header: 'نام', render: (r) => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<b>{r.full_name}</b>
{r.is_global && <span className="badge green" title="نماینده سراسری">سراسری </span>}
</span>
) },
{ key: 'mobile_number', header: 'موبایل', render: (r) => <span dir="ltr">{r.mobile_number ?? '—'}</span> },
{ key: 'domain', header: 'دامنه', render: (r) => r.domain ? <span dir="ltr">{r.domain}</span> : '—' },
{ key: 'city', header: 'شهرها', render: (r) => r.city ?? '—' },
{ key: 'commission_percent', header: 'کمیسیون', render: (r) => `${formatNumber(r.commission_percent)}٪` },
{ key: 'doctor_count', header: 'تعداد پزشکان', render: (r) => formatNumber(r.doctor_count ?? 0) },
{ key: 'appointment_count', header: 'تعداد نوبت', render: (r) => formatNumber(r.appointment_count ?? 0) },
{ key: 'wallet_balance', header: 'موجودی کیف‌پول', render: (r) => formatRial(r.wallet_balance ?? 0) },
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active ?? r.active ?? false} /> },
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
];
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">نمایندگان</h1>
<div className="muted">{total} نماینده ثبت‌شده</div>
</div>
<button onClick={() => setAddOpen(true)} className="btn primary sm">
<PlusIcon style={{ width: 16, height: 16 }} />
افزودن نماینده
</button>
</div>
<div className="card">
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="جستجو بر اساس نام یا موبایل..."
/>
</div>
<div style={{ minWidth: 200 }}>
<SearchableSelect
options={cityOptions}
value={cityFilter}
onChange={(val) => { setCityFilter(val as number | null); setPage(1); }}
placeholder="فیلتر شهر..."
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
</div>
</div>
</div>
<DataTable<Representation>
columns={columns}
data={items}
loading={isLoading}
emptyMessage="هیچ نماینده‌ای یافت نشد"
actions={(rep) => (
<>
<button onClick={() => navigate(`/admin/representations/${rep.uuid}`)}
className="mini-btn" title="مشاهده">
<EyeIcon style={{ width: 15, height: 15 }} />
</button>
{(rep.is_active ?? rep.active) && (
<button onClick={() => setDeleteTarget(rep)}
className="mini-btn danger" title="غیرفعال‌سازی">
<NoSymbolIcon style={{ width: 15, height: 15 }} />
</button>
)}
</>
)}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
<Modal open={addOpen} title="افزودن نماینده" onClose={() => { setAddOpen(false); reset(); }}
footer={
<>
<button onClick={() => { setAddOpen(false); reset(); }} className="btn ghost sm">لغو</button>
<button form="add-rep-form" type="submit" disabled={isSubmitting} className="btn primary sm">
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<form id="add-rep-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
<div className="form-row">
<label>نام کامل</label>
<input {...register('full_name')} placeholder="علی محمدی" className="input" />
{errors.full_name && <p className="err-text">{errors.full_name.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شماره موبایل</label>
<MobileInput className="input" hasError={!!errors.mobile_number} {...register('mobile_number')} />
{errors.mobile_number && <p className="err-text">{errors.mobile_number.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<Switch
inline
checked={watch('is_global') ?? false}
onChange={(v) => setValue('is_global', v, { shouldDirty: true })}
label="نماینده سراسری (دامنه اختصاصی — فقط پزشکان/کلینیک‌های خودش نمایش داده می‌شوند)"
/>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>دامنه</label>
<input {...register('domain')} placeholder="example-nobat.ir" dir="ltr" className="input" />
{errors.domain && <p className="err-text">{errors.domain.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شهرها {isGlobal && <span className="muted">(برای نماینده سراسری اختیاری)</span>}</label>
<Controller
name="city_ids"
control={control}
render={({ field }) => {
const selected: number[] = field.value ?? [];
return (
<div>
<SearchableSelect
options={cityOptions.filter((o) => !selected.includes(o.value))}
value={null}
onChange={(val) => {
if (val !== null && !selected.includes(val as number)) {
field.onChange([...selected, val as number]);
}
}}
placeholder={isGlobal ? 'اختیاری — افزودن شهر...' : 'افزودن شهر...'}
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
{selected.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selected.map((id) => (
<span key={id} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{cityOptions.find((o) => o.value === id)?.label ?? id}
<button type="button" className="mini-btn" style={{ padding: 0, width: 16, height: 16 }}
onClick={() => field.onChange(selected.filter((v) => v !== id))}>
×
</button>
</span>
))}
</div>
)}
</div>
);
}}
/>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>درصد کمیسیون</label>
<input {...numericField(register('commission_percent'), 3)} placeholder="10" dir="ltr"
className="input" />
{errors.commission_percent && <p className="err-text">{errors.commission_percent.message}</p>}
</div>
</form>
</Modal>
<ConfirmDialog
open={!!deleteTarget}
title="غیرفعال‌سازی نماینده"
message={`آیا از غیرفعال‌سازی نماینده "${deleteTarget?.full_name}" اطمینان دارید؟ این نماینده دیگر فعال نخواهد بود.`}
confirmLabel="غیرفعال‌سازی"
danger
loading={deactivateMutation.isPending}
onConfirm={() => deleteTarget && deactivateMutation.mutate(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}