feat: update ClinicDetailPage and ClinicsPage for address handling and UI improvements
This commit is contained in:
@@ -77,11 +77,6 @@ const editSchema = z.object({
|
||||
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()),
|
||||
@@ -237,16 +232,18 @@ function MapFlyController({ target }: { target: [number, number] | null }) {
|
||||
useEffect(() => { if (target) map.flyTo(target, 12, { duration: 1.2 }); }, [target, map]);
|
||||
return null;
|
||||
}
|
||||
function MapPicker({ lat, lng, onChange, flyTarget }: {
|
||||
function MapPicker({ lat, lng, onChange, flyTarget, initialCenter }: {
|
||||
lat: number | null; lng: number | null;
|
||||
onChange: (lat: number, lng: number) => void;
|
||||
flyTarget?: [number, number] | null;
|
||||
initialCenter?: [number, number] | null;
|
||||
}) {
|
||||
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
|
||||
const center: [number, number] = pos ?? IRAN_CENTER;
|
||||
const center: [number, number] = initialCenter ?? pos ?? IRAN_CENTER;
|
||||
const zoom = initialCenter ?? pos ? 13 : 5;
|
||||
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%' }}>
|
||||
<MapContainer key={`${center[0]},${center[1]}`} center={center} zoom={zoom} style={{ height: '100%', width: '100%' }}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://openstreetmap.org">OpenStreetMap</a>' />
|
||||
<MapClickHandler onPick={onChange} />
|
||||
@@ -271,12 +268,9 @@ async function geocodeCity(name: string): Promise<[number, number] | null> {
|
||||
|
||||
// ── Edit Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
function EditModal({ clinic, onClose, onSaved }: {
|
||||
clinic: ClinicDetail; onClose: () => void; onSaved: () => void;
|
||||
initialTab?: 'basic' | 'location' | 'tags';
|
||||
}) {
|
||||
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
|
||||
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<EditForm>({
|
||||
resolver: zodResolver(editSchema),
|
||||
defaultValues: {
|
||||
@@ -284,36 +278,16 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
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'),
|
||||
@@ -327,8 +301,6 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
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 }));
|
||||
@@ -348,11 +320,6 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
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,
|
||||
@@ -361,7 +328,7 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>(initialTab);
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'tags'>('basic');
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
@@ -375,7 +342,7 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div style={{ borderBottom: '1px solid var(--border)', padding: '0 16px', display: 'flex', gap: 4 }}>
|
||||
{([['basic', 'اطلاعات پایه'], ['location', 'موقعیت'], ['tags', 'تخصص و بیمه']] as const).map(([id, label]) => (
|
||||
{([['basic', 'اطلاعات پایه'], ['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',
|
||||
@@ -413,59 +380,6 @@ function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 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' && (
|
||||
<>
|
||||
@@ -525,10 +439,10 @@ export default function ClinicDetailPage() {
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const authToken = useAuthStore(s => s.token);
|
||||
const isOwner = primaryRole === 'clinic' && dbUuid === uuid;
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors');
|
||||
@@ -562,11 +476,39 @@ export default function ClinicDetailPage() {
|
||||
});
|
||||
const clinicAddresses: ClinicAddress[] = (clinicAddressesQ.data?.data as any)?.data ?? clinicAddressesQ.data?.data ?? [];
|
||||
|
||||
const [addrFormOpen, setAddrFormOpen] = useState(false);
|
||||
const [addrFormOpen, setAddrFormOpen] = useState(false);
|
||||
const [editingClinicAddr, setEditingClinicAddr] = useState<ClinicAddress | null>(null);
|
||||
const [deleteAddrConfirm, setDeleteAddrConfirm] = useState<ClinicAddress | null>(null);
|
||||
|
||||
const [addrForm, setAddrForm] = useState({ name: '', address: '', telephone: '' });
|
||||
const emptyAddrForm = { name: '', address: '', telephone: '', province_id: null as number | null, city_id: null as number | null, latitude: null as number | null, longitude: null as number | null };
|
||||
const [addrForm, setAddrForm] = useState(emptyAddrForm);
|
||||
const [addrMapFlyTarget, setAddrMapFlyTarget] = useState<[number, number] | null>(null);
|
||||
|
||||
const provincesQ = useQuery({
|
||||
queryKey: ['provinces'], staleTime: 600_000,
|
||||
queryFn: () => api.get<ApiResponse<any>>('/api/v1/provinces'),
|
||||
});
|
||||
const citiesQ = useQuery({
|
||||
queryKey: ['cities', addrForm.province_id], staleTime: 300_000,
|
||||
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/cities?province_id=${addrForm.province_id}`),
|
||||
enabled: !!addrForm.province_id,
|
||||
});
|
||||
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 addrCities: Opt[] = useMemo(() => (citiesQ.data?.data?.data ?? citiesQ.data?.data ?? []).map((c: any) => ({ id: Number(c.id ?? c.nid), name: c.name })), [citiesQ.data]);
|
||||
|
||||
const openAddrForm = (addr: ClinicAddress | null) => {
|
||||
setEditingClinicAddr(addr);
|
||||
const lat = addr?.map?.latitude ? parseFloat(addr.map.latitude) : null;
|
||||
const lng = addr?.map?.longitude ? parseFloat(addr.map.longitude) : null;
|
||||
setAddrForm(addr ? {
|
||||
name: addr.name ?? '', address: addr.address ?? '', telephone: addr.telephone ?? '',
|
||||
province_id: addr.province ? Number(addr.province.id) : null,
|
||||
city_id: addr.city ? Number(addr.city.id) : null,
|
||||
latitude: lat, longitude: lng,
|
||||
} : emptyAddrForm);
|
||||
setAddrMapFlyTarget(lat && lng ? [lat, lng] : null);
|
||||
setAddrFormOpen(true);
|
||||
};
|
||||
|
||||
const saveAddrMutation = useMutation({
|
||||
mutationFn: (payload: typeof addrForm) => {
|
||||
@@ -580,7 +522,7 @@ export default function ClinicDetailPage() {
|
||||
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
|
||||
setAddrFormOpen(false);
|
||||
setEditingClinicAddr(null);
|
||||
setAddrForm({ name: '', address: '', telephone: '' });
|
||||
setAddrForm(emptyAddrForm);
|
||||
},
|
||||
onError: () => toast.error('خطا در ذخیره آدرس'),
|
||||
});
|
||||
@@ -607,10 +549,7 @@ export default function ClinicDetailPage() {
|
||||
|
||||
const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? [];
|
||||
|
||||
const openEdit = (tab: 'basic' | 'location' | 'tags' = 'basic') => {
|
||||
setEditInitialTab(tab);
|
||||
setEditOpen(true);
|
||||
};
|
||||
const openEdit = () => setEditOpen(true);
|
||||
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<any>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
|
||||
@@ -655,7 +594,7 @@ export default function ClinicDetailPage() {
|
||||
headers: {
|
||||
'Content-Disposition': `filename="${file.name}"`,
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
Authorization: `Bearer ${JSON.parse(localStorage.getItem('clinicpro-auth') ?? '{}')?.state?.token ?? ''}`,
|
||||
Authorization: `Bearer ${authToken ?? ''}`,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
@@ -670,24 +609,27 @@ export default function ClinicDetailPage() {
|
||||
finally { setLogoUploading(false); }
|
||||
};
|
||||
|
||||
const handleGalleryUpload = async (file: File) => {
|
||||
const handleGalleryUpload = async (files: FileList) => {
|
||||
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 uploaded: { url: string }[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
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 ${authToken ?? ''}`,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json?.data?.url) uploaded.push({ url: json.data.url });
|
||||
}
|
||||
if (uploaded.length > 0 && clinic) {
|
||||
const existing = (clinic.images_clinic ?? []).filter(img => img?.url);
|
||||
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, { url }] });
|
||||
toast.success('تصویر اضافه شد');
|
||||
await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, ...uploaded] });
|
||||
toast.success(`${uploaded.length} تصویر اضافه شد`);
|
||||
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
|
||||
}
|
||||
} catch (e: any) { toast.error(e.message); }
|
||||
@@ -696,10 +638,14 @@ export default function ClinicDetailPage() {
|
||||
|
||||
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;
|
||||
// شهر و استان از آدرس DoctorAddress (منبع واقعی) نه از Clinic entity
|
||||
const primaryAddr = clinicAddresses[0] ?? null;
|
||||
const cityName = primaryAddr?.city?.name ?? clinic?.city?.[0]?.name;
|
||||
const provinceName = primaryAddr?.province?.name ?? clinic?.state?.[0]?.name;
|
||||
const lat = primaryAddr?.map?.latitude ? parseFloat(primaryAddr.map.latitude)
|
||||
: clinic?.map?.latitude ? parseFloat(clinic.map.latitude) : null;
|
||||
const lng = primaryAddr?.map?.longitude ? parseFloat(primaryAddr.map.longitude)
|
||||
: clinic?.map?.longitude ? parseFloat(clinic.map.longitude) : null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -741,7 +687,7 @@ export default function ClinicDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn ghost sm" onClick={() => openEdit('basic')}>
|
||||
<button className="btn ghost sm" onClick={() => openEdit()}>
|
||||
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
|
||||
</button>
|
||||
{primaryRole === 'admin' && (
|
||||
@@ -806,7 +752,7 @@ export default function ClinicDetailPage() {
|
||||
label="شهر"
|
||||
value={[cityName, provinceName].filter(Boolean).join('، ') || '—'} />
|
||||
<InfoTile icon={<BuildingOffice2Icon style={{ width: 15, height: 15 }} />}
|
||||
label="آدرس" value={clinic.location ?? '—'} fullWidth />
|
||||
label="آدرس" value={primaryAddr?.address ?? clinic.location ?? '—'} fullWidth />
|
||||
</div>
|
||||
|
||||
{clinic.caption && (
|
||||
@@ -944,8 +890,8 @@ export default function ClinicDetailPage() {
|
||||
<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])} />
|
||||
<input ref={galleryInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
|
||||
onChange={e => e.target.files?.length && handleGalleryUpload(e.target.files)} />
|
||||
</div>
|
||||
{(clinic.images_clinic ?? []).length === 0 ? (
|
||||
<div className="empty" style={{ padding: '20px 0' }}>
|
||||
@@ -984,7 +930,7 @@ export default function ClinicDetailPage() {
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<b style={{ fontSize: 13 }}>تخصصها، بیمه و خدمات</b>
|
||||
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit('tags')}>
|
||||
<button className="btn ghost sm" style={{ fontSize: 12 }} onClick={() => openEdit()}>
|
||||
<PencilIcon style={{ width: 13, height: 13 }} /> ویرایش
|
||||
</button>
|
||||
</div>
|
||||
@@ -1045,12 +991,8 @@ export default function ClinicDetailPage() {
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
آدرسهای کلینیک ({formatNumber(clinicAddresses.length)})
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button className="btn primary sm" onClick={() => {
|
||||
setEditingClinicAddr(null);
|
||||
setAddrForm({ name: '', address: '', telephone: '' });
|
||||
setAddrFormOpen(true);
|
||||
}}>
|
||||
{(isOwner || primaryRole === 'admin') && clinicAddresses.length === 0 && (
|
||||
<button className="btn primary sm" onClick={() => openAddrForm(null)}>
|
||||
<PlusIcon style={{ width: 14, height: 14 }} />
|
||||
افزودن آدرس
|
||||
</button>
|
||||
@@ -1084,13 +1026,9 @@ export default function ClinicDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && (
|
||||
{(isOwner || primaryRole === 'admin') && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => {
|
||||
setEditingClinicAddr(addr);
|
||||
setAddrForm({ name: addr.name ?? '', address: addr.address ?? '', telephone: addr.telephone ?? '' });
|
||||
setAddrFormOpen(true);
|
||||
}}>
|
||||
<button className="btn ghost sm" style={{ padding: '4px 8px' }} onClick={() => openAddrForm(addr)}>
|
||||
<PencilIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }}
|
||||
@@ -1110,50 +1048,92 @@ export default function ClinicDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Clinic Address Form Modal */}
|
||||
{addrFormOpen && (
|
||||
<div className="modal-backdrop" onClick={() => setAddrFormOpen(false)}>
|
||||
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<span>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</span>
|
||||
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>
|
||||
<XMarkIcon style={{ width: 18, height: 18 }} />
|
||||
{addrFormOpen && createPortal(
|
||||
<div className="overlay" onClick={() => setAddrFormOpen(false)}>
|
||||
<div className="modal" style={{ maxWidth: 460 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<b>{editingClinicAddr ? 'ویرایش آدرس' : 'افزودن آدرس جدید'}</b>
|
||||
<button className="mini-btn" onClick={() => setAddrFormOpen(false)}>
|
||||
<XMarkIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label className="field-label">نام شعبه / عنوان</label>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="مثال: شعبه مرکزی"
|
||||
value={addrForm.name}
|
||||
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام شعبه / عنوان</label>
|
||||
<input className="input" placeholder="مثال: شعبه مرکزی"
|
||||
value={addrForm.name}
|
||||
onChange={e => setAddrForm(f => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
<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={addrForm.province_id}
|
||||
placeholder="انتخاب استان"
|
||||
onChange={val => setAddrForm(f => ({ ...f, province_id: val, city_id: null }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شهر</label>
|
||||
<SearchableSelectField
|
||||
options={addrCities}
|
||||
value={addrForm.city_id}
|
||||
placeholder={addrForm.province_id ? 'انتخاب شهر' : 'ابتدا استان'}
|
||||
disabled={!addrForm.province_id}
|
||||
onChange={(val, label) => {
|
||||
setAddrForm(f => ({ ...f, city_id: val }));
|
||||
if (label) geocodeCity(label).then(c => { if (c) setAddrMapFlyTarget(c); });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">آدرس</label>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="آدرس کامل"
|
||||
value={addrForm.address}
|
||||
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
|
||||
</div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>آدرس کامل</label>
|
||||
<textarea className="input" rows={2} style={{ resize: 'none' }} placeholder="خیابان، کوچه، پلاک..."
|
||||
value={addrForm.address}
|
||||
onChange={e => setAddrForm(f => ({ ...f, address: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">تلفن</label>
|
||||
<div className="field">
|
||||
<input type="text" placeholder="مثال: 02112345678"
|
||||
value={addrForm.telephone}
|
||||
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 600 }}>موقعیت روی نقشه</label>
|
||||
{addrForm.latitude && addrForm.longitude && (
|
||||
<span className="muted" style={{ fontSize: 11, direction: 'ltr' }}>
|
||||
{addrForm.latitude.toFixed(4)}, {addrForm.longitude.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 12, marginBottom: 6 }}>برای تعیین موقعیت دقیق روی نقشه کلیک کنید</p>
|
||||
<MapPicker
|
||||
lat={addrForm.latitude} lng={addrForm.longitude}
|
||||
flyTarget={addrMapFlyTarget}
|
||||
initialCenter={addrForm.latitude && addrForm.longitude ? [addrForm.latitude, addrForm.longitude] : null}
|
||||
onChange={(lt, ln) => setAddrForm(f => ({ ...f, latitude: lt, longitude: ln }))}
|
||||
/>
|
||||
{addrForm.latitude && addrForm.longitude && (
|
||||
<button type="button" className="btn ghost sm" style={{ marginTop: 6, fontSize: 12 }}
|
||||
onClick={() => setAddrForm(f => ({ ...f, latitude: null, longitude: null }))}>
|
||||
<XMarkIcon style={{ width: 13, height: 13 }} /> حذف موقعیت
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تلفن</label>
|
||||
<input className="input" dir="ltr" placeholder="مثال: 02112345678"
|
||||
value={addrForm.telephone}
|
||||
onChange={e => setAddrForm(f => ({ ...f, telephone: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button>
|
||||
<button className="btn primary" disabled={saveAddrMutation.isPending}
|
||||
<div className="modal-foot">
|
||||
<button className="btn ghost sm" onClick={() => setAddrFormOpen(false)}>انصراف</button>
|
||||
<button className="btn primary sm" disabled={saveAddrMutation.isPending}
|
||||
onClick={() => saveAddrMutation.mutate(addrForm)}>
|
||||
{saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Delete Address Confirm */}
|
||||
@@ -1170,7 +1150,6 @@ export default function ClinicDetailPage() {
|
||||
{editOpen && createPortal(
|
||||
<EditModal
|
||||
clinic={clinic}
|
||||
initialTab={editInitialTab}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }}
|
||||
/>,
|
||||
|
||||
@@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
|
||||
const addSchema = z.object({
|
||||
owner_mobile: z.string().min(10, 'شماره موبایل معتبر نیست'),
|
||||
name: z.string().min(2, 'نام الزامی است'),
|
||||
telephone: z.string().optional(),
|
||||
});
|
||||
@@ -69,7 +70,7 @@ export default function ClinicsPage() {
|
||||
const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (d: AddForm) => api.post<ApiResponse<{ uuid: string }>>('/api/v1/clinic', d),
|
||||
mutationFn: (d: AddForm) => api.post<ApiResponse<{ uuid: string }>>('/api/v1/admin/clinic', d),
|
||||
onSuccess: (res) => {
|
||||
toast.success('کلینیک اضافه شد');
|
||||
setAddOpen(false);
|
||||
@@ -237,7 +238,16 @@ export default function ClinicsPage() {
|
||||
<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>
|
||||
<input className="input" placeholder="09xxxxxxxxx" dir="ltr" {...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 && (
|
||||
|
||||
Reference in New Issue
Block a user