- Refactor multiple admin pages (BlogsPage, ClinicsPage, DoctorsPage, etc.) to utilize the new useUrlState hook for managing pagination, search, and filter states via URL. - Ensure that the state persists in the URL, allowing users to return to the same state when navigating back from detail pages. - Update relevant components to handle state changes appropriately and maintain clean URLs by removing default values. - Add SlotPicker component for selecting appointment slots based on availability. - Create tests for useUrlState to validate its functionality and ensure correct behavior when interacting with the URL. - Update API documentation to reflect changes in appointment creation and slot selection processes.
332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
EyeIcon,
|
|
TrashIcon,
|
|
MagnifyingGlassIcon,
|
|
PlusIcon,
|
|
BuildingOffice2Icon,
|
|
DevicePhoneMobileIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { api } from '../lib/api';
|
|
import { useUrlState, pageOf } from '../hooks/useUrlState';
|
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|
import type { Clinic } from '../types';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import MobileInput from '../components/ui/MobileInput';
|
|
import Portal from '../components/ui/Portal';
|
|
import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
|
import { latinDigitsField } from '../lib/forms';
|
|
|
|
const HUES_LIST = [256, 205, 162, 295, 272];
|
|
|
|
const addSchema = z.object({
|
|
owner_mobile: iranMobileSchema,
|
|
name: z.string().min(2, 'نام الزامی است'),
|
|
telephone: z.string().optional(),
|
|
});
|
|
type AddForm = z.infer<typeof addSchema>;
|
|
|
|
export default function ClinicsPage() {
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
|
const isRepresentation = primaryRole === 'representation';
|
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
|
const page = pageOf(urlState.page);
|
|
const search = urlState.search;
|
|
const statusFilter = urlState.status;
|
|
const setPage = (p: number) => setUrlState({ page: String(p) });
|
|
const setSearch = (v: string) => setUrlState({ search: v, page: '1' });
|
|
const setStatusFilter = (v: string) => setUrlState({ status: v, page: '1' });
|
|
const [deleteTarget, setDeleteTarget] = useState<Clinic | null>(null);
|
|
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const limit = 15;
|
|
|
|
const listBase = isRepresentation ? '/api/v1/representation/clinics' : '/api/v1/admin/clinics';
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['admin-clinics', page, search, statusFilter, isRepresentation],
|
|
queryFn: () =>
|
|
api.get<PaginatedResponse<Clinic>>(
|
|
`${listBase}?page=${page}&limit=${limit}` +
|
|
(search ? `&search=${encodeURIComponent(search)}` : '') +
|
|
(!isRepresentation && statusFilter !== '' ? `&status=${statusFilter}` : ''),
|
|
),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (c: Clinic) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/${c.uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('کلینیک حذف شد');
|
|
setDeleteTarget(null);
|
|
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: (c: Clinic) => api.patch<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/clinic/${c.uuid}/status`, {}),
|
|
onSuccess: () => {
|
|
toast.success('وضعیت کلینیک تغییر کرد');
|
|
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) });
|
|
|
|
const addMutation = useMutation({
|
|
mutationFn: (d: AddForm) =>
|
|
api.post<ApiResponse<{ uuid: string }>>(
|
|
isRepresentation ? '/api/v1/representation/clinic' : '/api/v1/admin/clinic',
|
|
d,
|
|
),
|
|
onSuccess: (res) => {
|
|
toast.success('کلینیک اضافه شد');
|
|
setAddOpen(false);
|
|
addForm.reset();
|
|
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
|
if (!isRepresentation && res?.data?.uuid) navigate(`/admin/clinics/${res.data.uuid}`);
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const items: Clinic[] = 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">{formatNumber(total)} کلینیک ثبتشده</div>
|
|
</div>
|
|
<button className="btn primary sm" onClick={() => setAddOpen(true)}>
|
|
<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 className="seg">
|
|
{[
|
|
{ v: '', label: 'همه' },
|
|
{ v: '1', label: 'فعال' },
|
|
{ v: '0', label: 'غیرفعال' },
|
|
].map(({ v, label }) => (
|
|
<button
|
|
key={v}
|
|
className={statusFilter === v ? 'active' : ''}
|
|
onClick={() => { setStatusFilter(v); setPage(1); }}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="table-wrap"><table className="t">
|
|
<thead>
|
|
<tr>
|
|
<th>کلینیک</th>
|
|
<th>مالک</th>
|
|
<th>تلفن</th>
|
|
<th>پزشکان</th>
|
|
<th>وضعیت</th>
|
|
<th>تاریخ ثبت</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading
|
|
? Array.from({ length: 8 }).map((_, i) => (
|
|
<tr key={i}>
|
|
{Array.from({ length: 6 }).map((_, j) => (
|
|
<td key={j}><div className="skeleton" style={{ height: 18, borderRadius: 6 }} /></td>
|
|
))}
|
|
</tr>
|
|
))
|
|
: items.length === 0
|
|
? (
|
|
<tr>
|
|
<td colSpan={7}>
|
|
<div className="empty">
|
|
<BuildingOffice2Icon style={{ width: 36, height: 36 }} />
|
|
<p>هیچ کلینیکی یافت نشد</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
: items.map((c) => {
|
|
const hue = HUES_LIST[(c.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
|
return (
|
|
<tr key={c.uuid}>
|
|
<td>
|
|
<div className="cell-user">
|
|
{c.logo ? (
|
|
<img src={c.logo} alt="" className="avatar sm" style={{ objectFit: 'cover' }} />
|
|
) : (
|
|
<div className="avatar sm" style={{
|
|
background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`,
|
|
}}>
|
|
{c.name?.[0] ?? '?'}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<b>{c.name}</b>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{c.owner_mobile
|
|
? <span dir="ltr" style={{ fontSize: 13 }}>{c.owner_mobile}</span>
|
|
: <span style={{ color: 'var(--text-3)' }}>—</span>}
|
|
</td>
|
|
<td>{c.phone ? <span dir="ltr">{c.phone}</span> : '—'}</td>
|
|
<td>
|
|
<span className="badge blue">
|
|
<span className="bdot" />
|
|
{formatNumber(c.doctors_count ?? 0)} پزشک
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span className={`badge ${c.is_active ? 'green' : 'gray'}`}>
|
|
<span className="bdot" />
|
|
{c.is_active ? 'فعال' : 'غیرفعال'}
|
|
</span>
|
|
</td>
|
|
<td>{formatDate(String(c.created_at))}</td>
|
|
<td>
|
|
<div className="row-actions">
|
|
<button
|
|
className="mini-btn"
|
|
title="مشاهده"
|
|
onClick={() => navigate(`/admin/clinics/${c.uuid}`)}
|
|
>
|
|
<EyeIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button
|
|
className="mini-btn"
|
|
title="تغییر شماره ورود"
|
|
onClick={() => setMobileTarget({ uuid: c.uuid, name: c.name, mobile_number: c.owner_mobile })}
|
|
>
|
|
<DevicePhoneMobileIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button
|
|
className={`mini-btn${c.is_active ? '' : ' active'}`}
|
|
title={c.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
|
|
onClick={() => toggleMutation.mutate(c)}
|
|
>
|
|
<span style={{ fontSize: 11, fontWeight: 700 }}>
|
|
{c.is_active ? 'OFF' : 'ON'}
|
|
</span>
|
|
</button>
|
|
<button
|
|
className="mini-btn danger"
|
|
title="حذف"
|
|
onClick={() => setDeleteTarget(c)}
|
|
>
|
|
<TrashIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table></div>
|
|
|
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
|
</div>
|
|
|
|
{/* Add Modal */}
|
|
{addOpen && (
|
|
<Portal>
|
|
<div className="overlay" onClick={() => setAddOpen(false)}>
|
|
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-head">
|
|
<b>افزودن کلینیک</b>
|
|
</div>
|
|
<form onSubmit={addForm.handleSubmit((d) => addMutation.mutate(d))}>
|
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div>
|
|
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
|
|
شماره موبایل صاحب کلینیک <span style={{ color: 'var(--red)' }}>*</span>
|
|
</label>
|
|
<MobileInput className="input" hasError={!!addForm.formState.errors.owner_mobile} {...addForm.register('owner_mobile')} />
|
|
{addForm.formState.errors.owner_mobile && (
|
|
<div className="err-text">{addForm.formState.errors.owner_mobile.message}</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
|
|
نام کلینیک <span style={{ color: 'var(--red)' }}>*</span>
|
|
</label>
|
|
<input className="input" placeholder="نام کلینیک را وارد کنید" {...addForm.register('name')} />
|
|
{addForm.formState.errors.name && (
|
|
<div className="err-text">{addForm.formState.errors.name.message}</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
|
|
تلفن (اختیاری)
|
|
</label>
|
|
<input className="input" placeholder="مثال: 021-12345678" {...latinDigitsField(addForm.register('telephone'))} />
|
|
</div>
|
|
</div>
|
|
<div className="modal-foot">
|
|
<button type="button" className="btn ghost sm" onClick={() => setAddOpen(false)}>
|
|
انصراف
|
|
</button>
|
|
<button type="submit" className="btn primary sm" disabled={addMutation.isPending}>
|
|
{addMutation.isPending ? 'در حال ذخیره...' : 'افزودن'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title="حذف کلینیک"
|
|
message={`آیا از حذف کلینیک "${deleteTarget?.name}" اطمینان دارید؟`}
|
|
confirmLabel="حذف"
|
|
danger
|
|
loading={deleteMutation.isPending}
|
|
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
|
|
<ChangeLoginMobileModal
|
|
target={mobileTarget}
|
|
resource="clinic"
|
|
queryKey={['clinics']}
|
|
onClose={() => setMobileTarget(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|