feat: enhance DoctorAddress entity to support clinic addresses and types
- Added `clinic_id` and `type` fields to `DoctorAddress` entity to differentiate between personal and clinic addresses. - Updated constructor to support creation of addresses for both doctors and clinics. - Modified repository methods to handle new address types and added methods for counting and finding addresses by clinic. - Implemented migration to update the database schema accordingly. - Removed deprecated endpoint for creating addresses from clinics and updated related controller methods. - Added new endpoints for managing clinic addresses, including CRUD operations. - Updated frontend components to handle new address types and display accordingly.
This commit is contained in:
@@ -57,6 +57,16 @@ interface ClinicDoctorItem {
|
||||
specialties: { id: string; name: string }[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface ClinicAddress {
|
||||
id: string; uuid: string;
|
||||
name: string | null; address: string | null;
|
||||
telephone: string | null;
|
||||
map: { latitude: string | null; longitude: string | null };
|
||||
city: { id: string; name: string } | null;
|
||||
province: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface Opt { id: number; name: string; }
|
||||
interface OptUuid { id: number; uuid: string; name: string; }
|
||||
|
||||
@@ -514,6 +524,9 @@ export default function ClinicDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const dbUuid = useAuthStore(s => s.dbUuid);
|
||||
const isOwner = primaryRole === 'clinic' && dbUuid === uuid;
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
@@ -542,6 +555,46 @@ export default function ClinicDetailPage() {
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const clinicAddressesQ = useQuery({
|
||||
queryKey: ['clinic-addresses', uuid],
|
||||
queryFn: () => api.get<ApiResponse<ClinicAddress[]>>(`/api/v1/clinic/${uuid}/addresses`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
const clinicAddresses: ClinicAddress[] = clinicAddressesQ.data?.data ?? [];
|
||||
|
||||
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 saveAddrMutation = useMutation({
|
||||
mutationFn: (payload: typeof addrForm) => {
|
||||
if (editingClinicAddr) {
|
||||
return api.patch<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address/${editingClinicAddr.uuid}`, payload);
|
||||
}
|
||||
return api.post<ApiResponse<ClinicAddress>>(`/api/v1/clinic/${uuid}/address`, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(editingClinicAddr ? 'آدرس ویرایش شد' : 'آدرس اضافه شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
|
||||
setAddrFormOpen(false);
|
||||
setEditingClinicAddr(null);
|
||||
setAddrForm({ name: '', address: '', telephone: '' });
|
||||
},
|
||||
onError: () => toast.error('خطا در ذخیره آدرس'),
|
||||
});
|
||||
|
||||
const deleteAddrMutation = useMutation({
|
||||
mutationFn: (addrUuid: string) => api.delete<ApiResponse<null>>(`/api/v1/clinic/${uuid}/address/${addrUuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('آدرس حذف شد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-addresses', uuid] });
|
||||
setDeleteAddrConfirm(null);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.message ?? 'خطا در حذف آدرس'),
|
||||
});
|
||||
|
||||
const clinic: ClinicDetail | undefined = useMemo(() => {
|
||||
const raw = data?.data;
|
||||
return (raw as any)?.data ?? raw;
|
||||
@@ -985,9 +1038,134 @@ export default function ClinicDetailPage() {
|
||||
<NotificationMobileCard target="clinic" />
|
||||
)}
|
||||
|
||||
{/* Clinic Addresses Section */}
|
||||
{(isOwner || primaryRole === 'admin') && (
|
||||
<div className="card">
|
||||
<div className="toolbar" style={{ padding: '12px 16px' }}>
|
||||
<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);
|
||||
}}>
|
||||
<PlusIcon style={{ width: 14, height: 14 }} />
|
||||
افزودن آدرس
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clinicAddresses.length === 0 ? (
|
||||
<div className="empty" style={{ padding: '24px 16px' }}>
|
||||
<MapPinIcon style={{ width: 28, height: 28 }} />
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||||
هنوز آدرسی ثبت نشده — دکتران نمیتوانند این کلینیک را به عنوان لوکیشن انتخاب کنند
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{clinicAddresses.map((addr, idx) => (
|
||||
<div key={addr.uuid} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
padding: '12px 16px',
|
||||
borderTop: idx === 0 ? '1px solid var(--border)' : undefined,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<MapPinIcon style={{ width: 18, height: 18, color: 'var(--primary)', flexShrink: 0, marginTop: 2 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{addr.name && <div style={{ fontWeight: 600, fontSize: 13 }}>{addr.name}</div>}
|
||||
{addr.address && <div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{addr.address}</div>}
|
||||
{addr.telephone && (
|
||||
<div style={{ fontSize: 12, marginTop: 2, color: 'var(--text-2)' }}>
|
||||
<PhoneIcon style={{ width: 12, height: 12, display: 'inline', marginLeft: 4 }} />
|
||||
{addr.telephone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && (
|
||||
<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);
|
||||
}}>
|
||||
<PencilIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<button className="btn ghost sm" style={{ padding: '4px 8px', color: 'var(--error)' }}
|
||||
onClick={() => setDeleteAddrConfirm(addr)}>
|
||||
<TrashIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</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 }} />
|
||||
</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 }))} />
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn ghost" onClick={() => setAddrFormOpen(false)}>انصراف</button>
|
||||
<button className="btn primary" disabled={saveAddrMutation.isPending}
|
||||
onClick={() => saveAddrMutation.mutate(addrForm)}>
|
||||
{saveAddrMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Address Confirm */}
|
||||
<ConfirmDialog
|
||||
open={deleteAddrConfirm !== null}
|
||||
title="حذف آدرس"
|
||||
message={`آیا از حذف آدرس "${deleteAddrConfirm?.name ?? deleteAddrConfirm?.address ?? ''}" اطمینان دارید؟`}
|
||||
confirmLabel="حذف"
|
||||
onConfirm={() => deleteAddrConfirm && deleteAddrMutation.mutate(deleteAddrConfirm.uuid)}
|
||||
onCancel={() => setDeleteAddrConfirm(null)}
|
||||
/>
|
||||
|
||||
{/* Edit modal — portal to escape Leaflet transform context */}
|
||||
{editOpen && createPortal(
|
||||
<EditModal
|
||||
|
||||
Reference in New Issue
Block a user