refactor: rename phone field to telephone in ClinicsPage and update related types

- Updated the form schema in ClinicsPage to change the field name from 'phone' to 'telephone'.
- Adjusted the input registration to reflect the new field name.
- Added a new interface ClinicDetail to define detailed clinic information including phone and other attributes.
- Modified the ClinicController to use null-safe access for province ID retrieval.
This commit is contained in:
hamed
2026-06-10 21:49:16 +03:30
parent c41d03b5c5
commit 9ef94043c8
4 changed files with 799 additions and 160 deletions
+758 -143
View File
@@ -1,84 +1,594 @@
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { import {
ArrowRightIcon, ArrowRightIcon, PencilIcon, TrashIcon,
BuildingOffice2Icon, BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon,
PencilIcon, PlusIcon, CameraIcon, ChevronDownIcon,
CheckIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import { formatDate } from '../lib/utils'; import type { ClinicDetail } from '../types';
import { formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
// Fix leaflet icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
const HUES_LIST = [256, 205, 162, 295, 272]; const HUES_LIST = [256, 205, 162, 295, 272];
const IRAN_CENTER: [number, number] = [32.4279, 53.6880];
interface ClinicDetail { // ── Types ──────────────────────────────────────────────────────────────────
uuid: string;
name: string; interface ClinicDoctorItem {
is_active: boolean; id: string; uuid: string; name: string;
phone: string | null; gender: string | null; degree: string | null;
logo: string | null; img: { url: string }[];
doctors_count?: number; specialties: { id: string; name: string }[];
created_at?: number; active: boolean;
} }
interface Opt { id: number; name: string; }
interface OptUuid { id: number; uuid: string; name: string; }
// ── Edit form schema ───────────────────────────────────────────────────────
const editSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
telephone: z.string().optional(),
info: z.string().optional(),
is_247: z.boolean(),
address: z.string().optional(),
province_id: z.number().nullable(),
city_id: z.number().nullable(),
latitude: z.string().optional(),
longitude: z.string().optional(),
specialties: z.array(z.number()),
insurance: z.array(z.number()),
doctor_services: z.array(z.number()),
});
type EditForm = z.infer<typeof editSchema>;
// ── Searchable Select (template CSS) ──────────────────────────────────────
function SearchableSelectField({ options, value, onChange, placeholder, disabled = false }: {
options: Opt[];
value: number | null;
onChange: (v: number | null, label?: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const [rect, setRect] = useState<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(
() => q ? options.filter(o => o.name.includes(q)) : options,
[options, q],
);
const selected = useMemo(() => options.find(o => o.id === value) ?? null, [options, value]);
const openDD = () => {
if (disabled || !btnRef.current) return;
setRect(btnRef.current.getBoundingClientRect());
setOpen(v => !v);
setQ('');
};
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node) &&
btnRef.current && !btnRef.current.contains(e.target as Node))
setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const dropStyle: React.CSSProperties = rect
? { position: 'fixed', top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999 }
: {};
return (
<div>
<button ref={btnRef} type="button" disabled={disabled} onClick={openDD}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '8px 12px', borderRadius: 8,
border: '1px solid var(--border)', background: disabled ? 'var(--surface-2, var(--bg))' : 'var(--surface)',
color: selected ? 'var(--text)' : 'var(--text-3)', cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 14, opacity: disabled ? 0.6 : 1,
}}>
<span>{selected?.name ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon style={{ width: 14, height: 14, flexShrink: 0, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
</button>
{open && createPortal(
<div ref={dropRef} style={{
...dropStyle,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, boxShadow: '0 8px 32px rgba(0,0,0,.12)', overflow: 'hidden',
}}>
<div style={{ padding: '8px 8px 0' }}>
<input autoFocus value={q} onChange={e => setQ(e.target.value)} placeholder="جستجو..."
className="input" style={{ fontSize: 13 }} />
</div>
<div style={{ maxHeight: 200, overflowY: 'auto', padding: '4px 0' }}>
<button type="button"
onClick={() => { onChange(null); setOpen(false); }}
style={{ width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13, color: 'var(--text-3)', background: 'none', border: 'none', cursor: 'pointer' }}>
{placeholder ?? 'انتخاب کنید'}
</button>
{filtered.map(o => (
<button key={o.id} type="button"
onClick={() => { onChange(o.id, o.name); setOpen(false); setQ(''); }}
style={{
width: '100%', textAlign: 'right', padding: '8px 12px', fontSize: 13,
background: value === o.id ? 'var(--primary-light, oklch(0.95 0.04 256))' : 'none',
color: value === o.id ? 'var(--primary)' : 'var(--text)', border: 'none', cursor: 'pointer',
}}>
{o.name}
</button>
))}
{filtered.length === 0 && (
<p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجهای یافت نشد</p>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
// ── Multi-select checkbox list ─────────────────────────────────────────────
function MultiCheckList({ options, selected, onChange, placeholder }: {
options: Opt[]; selected: number[]; onChange: (v: number[]) => void; placeholder?: string;
}) {
const [q, setQ] = useState('');
const filtered = useMemo(() => q ? options.filter(o => o.name.includes(q)) : options, [options, q]);
const toggle = (id: number) =>
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '6px 8px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2, var(--bg))' }}>
<input value={q} onChange={e => setQ(e.target.value)} placeholder={placeholder ?? 'جستجو...'}
className="input" style={{ fontSize: 13 }} />
</div>
{selected.length > 0 && (
<div style={{ padding: '6px 10px', borderBottom: '1px solid var(--border)', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{selected.map(id => {
const opt = options.find(o => o.id === id);
if (!opt) return null;
return (
<span key={id} className="badge violet" style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggle(id)}>
<span className="bdot" />{opt.name} ×
</span>
);
})}
</div>
)}
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
{filtered.length === 0
? <p style={{ textAlign: 'center', padding: '10px 0', fontSize: 12, color: 'var(--text-3)' }}>نتیجهای یافت نشد</p>
: filtered.map(o => (
<label key={o.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 12px', cursor: 'pointer' }}>
<input type="checkbox" checked={selected.includes(o.id)} onChange={() => toggle(o.id)} />
<span style={{ fontSize: 13, color: 'var(--text)' }}>{o.name}</span>
</label>
))}
</div>
</div>
);
}
// ── Leaflet helpers ────────────────────────────────────────────────────────
function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) });
return null;
}
function MapFlyController({ target }: { target: [number, number] | null }) {
const map = useMap();
useEffect(() => { if (target) map.flyTo(target, 12, { duration: 1.2 }); }, [target, map]);
return null;
}
function MapPicker({ lat, lng, onChange, flyTarget }: {
lat: number | null; lng: number | null;
onChange: (lat: number, lng: number) => void;
flyTarget?: [number, number] | null;
}) {
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
const center: [number, number] = pos ?? IRAN_CENTER;
return (
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border)', height: 260 }}>
<MapContainer center={center} zoom={pos ? 13 : 5} style={{ height: '100%', width: '100%' }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>' />
<MapClickHandler onPick={onChange} />
<MapFlyController target={flyTarget ?? null} />
{pos && <Marker position={pos} />}
</MapContainer>
</div>
);
}
// ── Geocode helper ─────────────────────────────────────────────────────────
async function geocodeCity(name: string): Promise<[number, number] | null> {
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(name + ',ایران')}&format=json&countrycodes=ir&limit=1`;
const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } });
const d = await res.json();
if (d?.[0]) return [parseFloat(d[0].lat), parseFloat(d[0].lon)];
return null;
} catch { return null; }
}
// ── Edit Modal ─────────────────────────────────────────────────────────────
function EditModal({ clinic, onClose, onSaved }: {
clinic: ClinicDetail; onClose: () => void; onSaved: () => void;
}) {
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<EditForm>({
resolver: zodResolver(editSchema),
defaultValues: {
name: clinic.name ?? '',
telephone: clinic.phone ?? clinic.phone_number ?? '',
info: clinic.caption ?? '',
is_247: clinic['24_7'] ?? false,
address: clinic.location ?? '',
province_id: clinic.state?.[0] ? Number(clinic.state[0].id) : null,
city_id: clinic.city?.[0] ? Number(clinic.city[0].id) : null,
latitude: clinic.map?.latitude ?? '',
longitude: clinic.map?.longitude ?? '',
specialties: (clinic.specialties ?? []).map(s => Number(s.id)),
insurance: (clinic.list_bime ?? []).map(s => Number(s.id)),
doctor_services: (clinic.services ?? []).map(s => Number(s.id)),
},
});
const provinceId = watch('province_id');
const cityId = watch('city_id');
const lat = watch('latitude');
const lng = watch('longitude');
const latN = lat ? parseFloat(lat) : null;
const lngN = lng ? parseFloat(lng) : null;
const watchedSpec = (watch('specialties') ?? []) as number[];
const watchedIns = (watch('insurance') ?? []) as number[];
const watchedSrv = (watch('doctor_services') ?? []) as number[];
const provincesQ = useQuery({
queryKey: ['provinces'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/provinces'),
});
const citiesQ = useQuery({
queryKey: ['cities', provinceId], staleTime: 300_000,
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/cities?province_id=${provinceId}`),
enabled: !!provinceId,
});
const specialtiesQ = useQuery({
queryKey: ['specialties-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/specialties'),
});
const insurancesQ = useQuery({
queryKey: ['insurances-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/insurances'),
});
const servicesQ = useQuery({
queryKey: ['doctor-services-all'], staleTime: 600_000,
queryFn: () => api.get<ApiResponse<any>>('/api/v1/doctor-services'),
});
const provinces: Opt[] = useMemo(() => (provincesQ.data?.data?.data ?? provincesQ.data?.data ?? []).map((p: any) => ({ id: Number(p.id ?? p.nid), name: p.name })), [provincesQ.data]);
const cities: Opt[] = useMemo(() => (citiesQ.data?.data?.data ?? citiesQ.data?.data ?? []).map((c: any) => ({ id: Number(c.id ?? c.nid), name: c.name })), [citiesQ.data]);
const specialties: Opt[] = useMemo(() => {
const raw = specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [];
return raw.map((s: any) => ({ id: Number(s.id), name: s.name }));
}, [specialtiesQ.data]);
const insurances: Opt[] = useMemo(() => {
const raw = insurancesQ.data?.data?.data ?? insurancesQ.data?.data ?? [];
return raw.map((i: any) => ({ id: Number(i.id), name: i.name }));
}, [insurancesQ.data]);
const services: Opt[] = useMemo(() => {
const raw = servicesQ.data?.data?.data ?? servicesQ.data?.data ?? [];
return raw.map((s: any) => ({ id: Number(s.id), name: s.name }));
}, [servicesQ.data]);
const saveMut = useMutation({
mutationFn: (values: EditForm) => api.patch<ApiResponse<any>>(`/api/v1/clinic/${clinic.uuid}`, {
name: values.name,
telephone: values.telephone || undefined,
info: values.info || undefined,
'24_7': values.is_247,
address: values.address || undefined,
state: values.province_id ? [values.province_id] : undefined,
city: values.city_id ? [values.city_id] : undefined,
latitude: values.latitude ? parseFloat(values.latitude) : undefined,
longitude: values.longitude ? parseFloat(values.longitude) : undefined,
specialties: values.specialties,
insurance: values.insurance,
doctor_services: values.doctor_services,
}),
onSuccess: () => { toast.success('اطلاعات کلینیک ذخیره شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>('basic');
return (
<div className="overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<b>ویرایش کلینیک</b>
<button className="mini-btn" onClick={onClose}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
{/* Tab switcher */}
<div style={{ borderBottom: '1px solid var(--border)', padding: '0 16px', display: 'flex', gap: 4 }}>
{([['basic', 'اطلاعات پایه'], ['location', 'موقعیت'], ['tags', 'تخصص و بیمه']] as const).map(([id, label]) => (
<button key={id} type="button" onClick={() => setActiveTab(id)}
style={{
padding: '10px 14px', fontSize: 13, fontWeight: 600, border: 'none', background: 'none',
cursor: 'pointer', borderBottom: activeTab === id ? '2px solid var(--primary)' : '2px solid transparent',
color: activeTab === id ? 'var(--primary)' : 'var(--text-3)', marginBottom: -1,
}}>
{label}
</button>
))}
</div>
<form onSubmit={handleSubmit(v => saveMut.mutate(v))}>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* ── Basic tab ── */}
{activeTab === 'basic' && (
<>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام کلینیک</label>
<input className="input" {...register('name')} />
{errors.name && <div className="err-text">{errors.name.message}</div>}
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
<input className="input" dir="ltr" placeholder="021-12345678" {...register('telephone')} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>توضیحات</label>
<textarea className="input" rows={3} style={{ resize: 'vertical' }} {...register('info')} />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" {...register('is_247')} />
<span style={{ fontSize: 13, fontWeight: 600 }}>کلینیک ۲۴ ساعته (۷ روز هفته)</span>
</label>
</>
)}
{/* ── Location tab ── */}
{activeTab === 'location' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>استان</label>
<SearchableSelectField
options={provinces}
value={provinceId ?? null}
placeholder="انتخاب استان"
onChange={val => { setValue('province_id', val); setValue('city_id', null); }}
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شهر</label>
<SearchableSelectField
options={cities}
value={cityId ?? null}
placeholder={provinceId ? 'انتخاب شهر' : 'ابتدا استان انتخاب کنید'}
disabled={!provinceId}
onChange={(val, label) => {
setValue('city_id', val);
if (label) geocodeCity(label).then(c => { if (c) setMapFlyTarget(c); });
}}
/>
</div>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>آدرس</label>
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>موقعیت روی نقشه</label>
{latN && lngN && (
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>{latN.toFixed(4)}, {lngN.toFixed(4)}</span>
)}
</div>
<p className="muted" style={{ fontSize: 12, marginBottom: 6 }}>برای تعیین موقعیت روی نقشه کلیک کنید</p>
<MapPicker
lat={latN} lng={lngN} flyTarget={mapFlyTarget}
onChange={(lt, ln) => { setValue('latitude', String(lt)); setValue('longitude', String(ln)); }}
/>
{latN && lngN && (
<button type="button" className="btn ghost sm" style={{ marginTop: 6, fontSize: 12 }}
onClick={() => { setValue('latitude', ''); setValue('longitude', ''); }}>
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
</button>
)}
</div>
</>
)}
{/* ── Tags tab ── */}
{activeTab === 'tags' && (
<>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
تخصصها ({watchedSpec.length} انتخابشده)
</label>
<MultiCheckList options={specialties} selected={watchedSpec}
onChange={v => setValue('specialties', v)} placeholder="جستجوی تخصص..." />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
بیمهها ({watchedIns.length} انتخابشده)
</label>
<MultiCheckList options={insurances} selected={watchedIns}
onChange={v => setValue('insurance', v)} placeholder="جستجوی بیمه..." />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>
خدمات ({watchedSrv.length} انتخابشده)
</label>
<MultiCheckList options={services} selected={watchedSrv}
onChange={v => setValue('doctor_services', v)} placeholder="جستجوی خدمت..." />
</div>
</>
)}
</div>
<div className="modal-foot">
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
<button type="submit" className="btn primary sm" disabled={saveMut.isPending}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Main Page ──────────────────────────────────────────────────────────────
export default function ClinicDetailPage() { export default function ClinicDetailPage() {
const { uuid } = useParams<{ uuid: string }>(); const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editName, setEditName] = useState(''); const [deleteOpen, setDeleteOpen] = useState(false);
const [editPhone, setEditPhone] = useState(''); const logoInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
const [logoUploading, setLogoUploading] = useState(false);
const [galleryUploading, setGalleryUploading] = useState(false);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['clinic-detail', uuid], queryKey: ['clinic-detail', uuid],
queryFn: () => api.get<ApiResponse<ClinicDetail>>(`/api/v1/clinic/${uuid}`), queryFn: () => api.get<ApiResponse<{ data: ClinicDetail }>>(`/api/v1/clinic/${uuid}`),
enabled: !!uuid, enabled: !!uuid,
}); });
const clinic: ClinicDetail | undefined = data?.data; const doctorsQ = useQuery({
queryKey: ['clinic-doctors', uuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${uuid}`),
enabled: !!uuid,
});
useEffect(() => { const clinic: ClinicDetail | undefined = useMemo(() => {
if (clinic) { const raw = data?.data;
setEditName(clinic.name ?? ''); return (raw as any)?.data ?? raw;
setEditPhone(clinic.phone ?? ''); }, [data]);
}
}, [clinic]);
const toggleMutation = useMutation({ const doctorList: ClinicDoctorItem[] = useMemo(() => {
mutationFn: () => api.patch<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/clinic/${uuid}/status`, {}), const raw = doctorsQ.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
const toggleMut = useMutation({
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
onSuccess: () => { onSuccess: () => {
toast.success('وضعیت کلینیک تغییر کرد'); toast.success('وضعیت کلینیک تغییر کرد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-clinics'] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] });
}, },
onError: (err: Error) => toast.error(err.message), onError: (e: Error) => toast.error(e.message),
}); });
const updateMutation = useMutation({ const deleteMut = useMutation({
mutationFn: (d: { name: string; phone?: string }) => mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/${uuid}`),
api.patch<ApiResponse<ClinicDetail>>(`/api/v1/clinic/${uuid}`, d), onSuccess: () => { toast.success('کلینیک حذف شد'); navigate('/admin/clinics'); },
onSuccess: () => { onError: (e: Error) => toast.error(e.message),
toast.success('اطلاعات کلینیک ذخیره شد');
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
},
onError: (err: Error) => toast.error(err.message),
}); });
const handleLogoUpload = async (file: File) => {
setLogoUploading(true);
try {
const res = await fetch('/file/upload/clinic_pro/clinic/field_clinic_logo', {
method: 'POST',
headers: {
'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`,
},
body: file,
});
const json = await res.json();
const url = json?.data?.url;
if (url) {
await api.patch(`/api/v1/clinic/${uuid}`, { clinic_logo: url });
toast.success('لوگو آپلود شد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
}
} catch (e: any) { toast.error(e.message); }
finally { setLogoUploading(false); }
};
const handleGalleryUpload = async (file: File) => {
setGalleryUploading(true);
try {
const res = await fetch('/file/upload/clinic_pro/clinic/field_image_clinic', {
method: 'POST',
headers: {
'Content-Disposition': `filename="${file.name}"`,
'Content-Type': file.type || 'application/octet-stream',
Authorization: `Bearer ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`,
},
body: file,
});
const json = await res.json();
const url = json?.data?.url;
if (url && clinic) {
const existing = (clinic.images_clinic ?? []).map(img => img.url);
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, url] });
toast.success('تصویر اضافه شد');
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
}
} catch (e: any) { toast.error(e.message); }
finally { setGalleryUploading(false); }
};
const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const logo = clinic?.logo ?? clinic?.clinic_logo;
const lat = clinic?.map?.latitude ? parseFloat(clinic.map.latitude) : null;
const lng = clinic?.map?.longitude ? parseFloat(clinic.map.longitude) : null;
const cityName = clinic?.city?.[0]?.name;
const provinceName = clinic?.state?.[0]?.name;
if (isLoading) { if (isLoading) {
return ( return (
<div className="fade-in"> <div className="fade-in">
<div className="card card-pad"> <div className="card card-pad">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton" style={{ height: 22, borderRadius: 6, marginBottom: 12 }} /> <div key={i} className="skeleton" style={{ height: 20, borderRadius: 6, marginBottom: 12 }} />
))} ))}
</div> </div>
</div> </div>
@@ -100,14 +610,11 @@ export default function ClinicDetailPage() {
return ( return (
<div className="fade-in"> <div className="fade-in">
{/* Header */}
{/* ── Header ── */}
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}> <div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button <button className="btn ghost sm" onClick={() => navigate('/admin/clinics')} style={{ padding: '6px 10px' }}>
className="btn ghost sm"
onClick={() => navigate('/admin/clinics')}
style={{ padding: '6px 10px' }}
>
<ArrowRightIcon style={{ width: 16, height: 16 }} /> <ArrowRightIcon style={{ width: 16, height: 16 }} />
</button> </button>
<div> <div>
@@ -116,136 +623,244 @@ export default function ClinicDetailPage() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button className="btn ghost sm" onClick={() => setEditOpen(true)}>
className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`} <PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
onClick={() => toggleMutation.mutate()} </button>
disabled={toggleMutation.isPending} <button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
> onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'} {clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
</button> </button>
<button className="btn ghost sm" onClick={() => setEditOpen(true)}> <button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
<PencilIcon style={{ width: 15, height: 15 }} /> <TrashIcon style={{ width: 15, height: 15 }} /> حذف
ویرایش
</button> </button>
</div> </div>
</div> </div>
{/* Main info card */} {/* ── Main grid ── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 'var(--gap)', alignItems: 'start' }}>
{/* Left column */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{/* Identity card */}
<div className="card card-pad"> <div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
{clinic.logo ? ( <div style={{ position: 'relative', flexShrink: 0 }}>
<img {logo ? (
src={clinic.logo} <img src={logo} alt="" className="avatar lg" style={{ objectFit: 'cover' }} />
alt=""
className="avatar lg"
style={{ objectFit: 'cover' }}
/>
) : ( ) : (
<div <div className="avatar lg"
className="avatar lg" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`, fontSize: 28, fontWeight: 700 }}>
style={{
background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))`,
fontSize: 28,
fontWeight: 700,
flexShrink: 0,
}}
>
{clinic.name?.[0] ?? '?'} {clinic.name?.[0] ?? '?'}
</div> </div>
)} )}
<div> <button onClick={() => logoInputRef.current?.click()} disabled={logoUploading}
<div style={{ fontWeight: 700, fontSize: 18 }}>{clinic.name}</div> style={{
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`} style={{ marginTop: 4 }}> position: 'absolute', insetInlineEnd: -6, bottom: -6, width: 24, height: 24,
<span className="bdot" /> borderRadius: '50%', background: 'var(--primary)', border: '2px solid var(--surface)',
{clinic.is_active ? 'فعال' : 'غیرفعال'} display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
}}>
<CameraIcon style={{ width: 12, height: 12, color: '#fff' }} />
</button>
<input ref={logoInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleLogoUpload(e.target.files[0])} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 20, fontWeight: 700 }}>{clinic.name}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />{clinic.is_active ? 'فعال' : 'غیرفعال'}
</span> </span>
{clinic['24_7'] && <span className="badge amber"><span className="bdot" />۲۴ ساعته</span>}
</div>
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16 }}> {/* Info rows */}
<InfoCard label="تلفن" value={clinic.phone ? <span dir="ltr">{clinic.phone}</span> : '—'} /> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{clinic.doctors_count !== undefined && ( <InfoTile icon={<PhoneIcon style={{ width: 15, height: 15 }} />}
<InfoCard label="تلفن" value={clinic.phone ? <span dir="ltr">{clinic.phone}</span> : '—'} />
label="تعداد پزشکان" <InfoTile icon={<MapPinIcon style={{ width: 15, height: 15 }} />}
value={ label="شهر"
<span className="badge blue"> value={[cityName, provinceName].filter(Boolean).join('، ') || '—'} />
<span className="bdot" /> <InfoTile icon={<BuildingOffice2Icon style={{ width: 15, height: 15 }} />}
{clinic.doctors_count} پزشک label="آدرس" value={clinic.location ?? '—'} fullWidth />
</span> </div>
{clinic.caption && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--surface-2, var(--bg))', borderRadius: 8 }}>
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>توضیحات</div>
<p style={{ fontSize: 13, lineHeight: 1.7 }}>{clinic.caption}</p>
</div>
)}
</div>
{/* Doctors */}
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<b style={{ fontSize: 14 }}>پزشکان ({formatNumber(doctorList.length)})</b>
</div>
{doctorList.length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{doctorList.map(doc => {
const dHue = HUES_LIST[(doc.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
const img = doc.img?.[0]?.url;
return (
<div key={doc.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--surface-2, var(--bg))' }}>
{img
? <img src={img} alt="" className="avatar sm" style={{ objectFit: 'cover', flexShrink: 0 }} />
: <div className="avatar sm" style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${dHue}), oklch(0.48 0.16 ${dHue}))`, flexShrink: 0 }}>{doc.name?.[0] ?? '?'}</div>
} }
/> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{doc.name}</div>
{doc.specialties?.length > 0 && (
<div className="muted" style={{ fontSize: 11 }}>{doc.specialties.map(s => s.name).join('، ')}</div>
)} )}
{clinic.created_at && ( </div>
<InfoCard label="تاریخ ثبت" value={formatDate(String(clinic.created_at))} /> <span className={`badge ${doc.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{doc.active ? 'فعال' : 'غیرفعال'}
</span>
</div>
);
})}
</div>
)} )}
</div> </div>
{/* Gallery */}
<div className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<b style={{ fontSize: 14 }}>گالری تصاویر</b>
<button className="btn ghost sm" onClick={() => galleryInputRef.current?.click()} disabled={galleryUploading}>
<PlusIcon style={{ width: 14, height: 14 }} />
{galleryUploading ? 'در حال آپلود...' : 'افزودن تصویر'}
</button>
<input ref={galleryInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => e.target.files?.[0] && handleGalleryUpload(e.target.files[0])} />
</div>
{(clinic.images_clinic ?? []).length === 0 ? (
<div className="empty" style={{ padding: '20px 0' }}>
<p className="muted">هیچ تصویری در گالری وجود ندارد</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8 }}>
{clinic.images_clinic.map((img, i) => (
<div key={i} style={{ borderRadius: 8, overflow: 'hidden', aspectRatio: '1', background: 'var(--surface-2, var(--bg))' }}>
<img src={img.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
))}
</div>
)}
</div>
{/* Map (read-only) */}
{lat !== null && lng !== null && (
<div className="card card-pad">
<b style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>موقعیت روی نقشه</b>
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border)', height: 260 }}>
<MapContainer center={[lat, lng]} zoom={13} style={{ height: '100%', width: '100%' }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>' />
<Marker position={[lat, lng]} />
</MapContainer>
</div>
</div>
)}
</div>
{/* Right sidebar */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{/* Specialties */}
<div className="card card-pad">
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
تخصصها ({formatNumber((clinic.specialties ?? []).length)})
</b>
{(clinic.specialties ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>تخصصی ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.specialties.map(s => (
<span key={s.id} className="badge violet"><span className="bdot" />{s.name}</span>
))}
</div>
}
</div>
{/* Insurances */}
<div className="card card-pad">
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
بیمهها ({formatNumber((clinic.list_bime ?? []).length)})
</b>
{(clinic.list_bime ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>بیمهای ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.list_bime.map(ins => (
<span key={ins.id} className="badge blue"><span className="bdot" />{ins.name}</span>
))}
</div>
}
</div>
{/* Services */}
<div className="card card-pad">
<b style={{ fontSize: 13, display: 'block', marginBottom: 10 }}>
خدمات ({formatNumber((clinic.services ?? []).length)})
</b>
{(clinic.services ?? []).length === 0
? <p className="muted" style={{ fontSize: 12 }}>خدمتی ثبت نشده</p>
: <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{clinic.services.map(s => (
<span key={s.id} className="badge green"><span className="bdot" />{s.name}</span>
))}
</div>
}
</div>
</div>
</div> </div>
{/* Edit modal */} {/* Edit modal */}
{editOpen && ( {editOpen && (
<div className="overlay" onClick={() => setEditOpen(false)}> <EditModal clinic={clinic} onClose={() => setEditOpen(false)}
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}> onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} />
<div className="modal-head">
<b>ویرایش کلینیک</b>
<button className="mini-btn" onClick={() => setEditOpen(false)}>
<XMarkIcon style={{ width: 16, height: 16 }} />
</button>
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
نام کلینیک
</label>
<input
className="input"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="نام کلینیک"
/>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
تلفن
</label>
<input
className="input"
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
placeholder="مثال: 021-12345678"
dir="ltr"
/>
</div>
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={() => setEditOpen(false)}>
انصراف
</button>
<button
className="btn primary sm"
disabled={updateMutation.isPending || !editName.trim()}
onClick={() => updateMutation.mutate({ name: editName.trim(), phone: editPhone.trim() || undefined })}
>
<CheckIcon style={{ width: 15, height: 15 }} />
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</div>
)} )}
{/* Delete confirm */}
<ConfirmDialog
open={deleteOpen}
title="حذف کلینیک"
message={`آیا از حذف کلینیک "${clinic.name}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
confirmLabel="حذف"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteMut.mutate()}
onCancel={() => setDeleteOpen(false)}
/>
</div> </div>
); );
} }
function InfoCard({ label, value }: { label: string; value: React.ReactNode }) { // ── Info tile ──────────────────────────────────────────────────────────────
function InfoTile({ icon, label, value, fullWidth }: {
icon?: React.ReactNode; label: string; value: React.ReactNode; fullWidth?: boolean;
}) {
return ( return (
<div style={{ <div style={{
gridColumn: fullWidth ? '1 / -1' : undefined,
padding: '10px 12px', borderRadius: 8,
background: 'var(--surface-2, var(--bg))', background: 'var(--surface-2, var(--bg))',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: 10,
padding: '12px 14px',
}}> }}>
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>{label}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 3, color: 'var(--text-3)' }}>
<div style={{ fontWeight: 600 }}>{value}</div> {icon}
<span style={{ fontSize: 11 }}>{label}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{value}</div>
</div> </div>
); );
} }
+2 -2
View File
@@ -23,7 +23,7 @@ const HUES_LIST = [256, 205, 162, 295, 272];
const addSchema = z.object({ const addSchema = z.object({
name: z.string().min(2, 'نام الزامی است'), name: z.string().min(2, 'نام الزامی است'),
phone: z.string().optional(), telephone: z.string().optional(),
}); });
type AddForm = z.infer<typeof addSchema>; type AddForm = z.infer<typeof addSchema>;
@@ -248,7 +248,7 @@ export default function ClinicsPage() {
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}> <label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
تلفن (اختیاری) تلفن (اختیاری)
</label> </label>
<input className="input" placeholder="مثال: 021-12345678" dir="ltr" {...addForm.register('phone')} /> <input className="input" placeholder="مثال: 021-12345678" dir="ltr" {...addForm.register('telephone')} />
</div> </div>
</div> </div>
<div className="modal-foot"> <div className="modal-foot">
+24
View File
@@ -47,6 +47,30 @@ export interface Clinic {
created_at: number; created_at: number;
} }
export interface ClinicDetail {
id: string;
uuid: string;
name: string | null;
title: string | null;
is_active: boolean;
phone: string | null;
phone_number: string | null;
logo: string | null;
clinic_logo: string | null;
caption: string | null;
images_clinic: { url: string; fid?: number }[];
specialties: { id: string; uuid: string; name: string }[];
services: { id: string; uuid: string; name: string }[];
list_bime: { id: string; uuid: string; name: string }[];
doctors: number;
city: { id: string; name: string }[];
state: { id: string; name: string }[];
location: string | null;
map: { latitude: string | null; longitude: string | null };
'24_7': boolean;
field_working_days: string | null;
}
export type AppointmentStatus = export type AppointmentStatus =
| 'waiting_for_payment' | 'waiting_for_payment'
| 'reserved' | 'reserved'
+1 -1
View File
@@ -491,7 +491,7 @@ class ClinicController extends BaseController
'uuid' => $city->getUuid(), 'uuid' => $city->getUuid(),
'id' => (string) $city->getId(), 'id' => (string) $city->getId(),
'name' => $city->getName(), 'name' => $city->getName(),
'parent' => $city->getProvinceId() !== null ? (string) $city->getProvinceId() : null, 'parent' => $city->getProvince()?->getId() !== null ? (string) $city->getProvince()->getId() : null,
]; ];
} }
} }