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:
hamed
2026-06-12 13:39:37 +03:30
parent 63073c6a42
commit 0333b24071
13 changed files with 1446 additions and 89 deletions
+178
View File
@@ -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