Files
clinicpro/assets/admin/pages/RepresentationsPage.tsx
T

271 lines
13 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 React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
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 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';
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();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [cityFilter, setCityFilter] = useState<number | null>(null);
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, 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 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" {...register('is_global')} style={{ width: 16, height: 16 }} />
نماینده سراسری (دامنه اختصاصی فقط پزشکان/کلینیک‌های خودش نمایش داده می‌شوند)
</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 {...register('commission_percent')} type="number" min="0" max="100" 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>
);
}