feat: add clinic status management with active/inactive toggle and update related API endpoints
This commit is contained in:
@@ -1,89 +1,251 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
BuildingOffice2Icon,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Clinic } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="cp-info-row">
|
||||
<span className="cp-info-label text-sm">{label}</span>
|
||||
<span className="cp-info-value">{value ?? '—'}</span>
|
||||
</div>
|
||||
);
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
|
||||
interface ClinicDetail {
|
||||
uuid: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
phone: string | null;
|
||||
logo: string | null;
|
||||
doctors_count?: number;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
export default function ClinicDetailPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editPhone, setEditPhone] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clinic', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Clinic>>(`/api/v1/clinic/${uuid}`),
|
||||
queryKey: ['clinic-detail', uuid],
|
||||
queryFn: () => api.get<ApiResponse<ClinicDetail>>(`/api/v1/clinic/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const clinic = data?.data;
|
||||
const clinic: ClinicDetail | undefined = data?.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="جزئیات کلینیک"
|
||||
breadcrumbs={[
|
||||
{ label: 'داشبورد', to: '/admin/dashboard' },
|
||||
{ label: 'کلینیکها', to: '/admin/clinics' },
|
||||
{ label: 'جزئیات' },
|
||||
]}
|
||||
action={
|
||||
<button onClick={() => navigate('/admin/clinics')}
|
||||
className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
بازگشت
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
useEffect(() => {
|
||||
if (clinic) {
|
||||
setEditName(clinic.name ?? '');
|
||||
setEditPhone(clinic.phone ?? '');
|
||||
}
|
||||
}, [clinic]);
|
||||
|
||||
{isLoading ? (
|
||||
<div className="cp-card p-6 space-y-3">
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: () => api.patch<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/clinic/${uuid}/status`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('وضعیت کلینیک تغییر کرد');
|
||||
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (d: { name: string; phone?: string }) =>
|
||||
api.patch<ApiResponse<ClinicDetail>>(`/api/v1/clinic/${uuid}`, d),
|
||||
onSuccess: () => {
|
||||
toast.success('اطلاعات کلینیک ذخیره شد');
|
||||
setEditOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const hue = HUES_LIST[(uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card card-pad">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-8 rounded-lg skeleton" />
|
||||
<div key={i} className="skeleton" style={{ height: 22, borderRadius: 6, marginBottom: 12 }} />
|
||||
))}
|
||||
</div>
|
||||
) : clinic ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
{clinic.logo ? (
|
||||
<img src={clinic.logo} alt="" className="w-16 h-16 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-xl bg-blue-100 flex items-center justify-center text-blue-700 text-2xl font-bold">
|
||||
{clinic.name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="font-bold text-gray-900 text-lg">{clinic.name}</h2>
|
||||
</div>
|
||||
<InfoRow label="تلفن" value={clinic.phone ? <span dir="ltr">{clinic.phone}</span> : null} />
|
||||
<InfoRow label="وضعیت" value={<ActiveBadge active={clinic.is_active} />} />
|
||||
<InfoRow label="تاریخ ثبت" value={formatDate(clinic.created_at)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{clinic.description && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">توضیحات</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{clinic.description}</p>
|
||||
if (!clinic) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card card-pad">
|
||||
<div className="empty">
|
||||
<BuildingOffice2Icon style={{ width: 36, height: 36 }} />
|
||||
<p>کلینیک یافت نشد</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
{/* Header */}
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button
|
||||
className="btn ghost sm"
|
||||
onClick={() => navigate('/admin/clinics')}
|
||||
style={{ padding: '6px 10px' }}
|
||||
>
|
||||
<ArrowRightIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="section-title">{clinic.name}</h1>
|
||||
<div className="muted">جزئیات کلینیک</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
|
||||
onClick={() => toggleMutation.mutate()}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
<button className="btn ghost sm" onClick={() => setEditOpen(true)}>
|
||||
<PencilIcon style={{ width: 15, height: 15 }} />
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main info card */}
|
||||
<div className="card card-pad">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 20 }}>
|
||||
{clinic.logo ? (
|
||||
<img
|
||||
src={clinic.logo}
|
||||
alt=""
|
||||
className="avatar lg"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="avatar lg"
|
||||
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] ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 18 }}>{clinic.name}</div>
|
||||
<span className={`badge ${clinic.is_active ? 'green' : 'gray'}`} style={{ marginTop: 4 }}>
|
||||
<span className="bdot" />
|
||||
{clinic.is_active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-16 text-center text-gray-400">
|
||||
کلینیکی یافت نشد
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16 }}>
|
||||
<InfoCard label="تلفن" value={clinic.phone ? <span dir="ltr">{clinic.phone}</span> : '—'} />
|
||||
{clinic.doctors_count !== undefined && (
|
||||
<InfoCard
|
||||
label="تعداد پزشکان"
|
||||
value={
|
||||
<span className="badge blue">
|
||||
<span className="bdot" />
|
||||
{clinic.doctors_count} پزشک
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{clinic.created_at && (
|
||||
<InfoCard label="تاریخ ثبت" value={formatDate(String(clinic.created_at))} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit modal */}
|
||||
{editOpen && (
|
||||
<div className="overlay" onClick={() => setEditOpen(false)}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--surface-2, var(--bg))',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
padding: '12px 14px',
|
||||
}}>
|
||||
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontWeight: 600 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,85 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EyeIcon, TrashIcon, PencilIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
BuildingOffice2Icon,
|
||||
} 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 type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Clinic } from '../types';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
|
||||
const addSchema = z.object({
|
||||
name: z.string().min(2, 'نام الزامی است'),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
type AddForm = z.infer<typeof addSchema>;
|
||||
|
||||
export default function ClinicsPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Clinic | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const limit = 15;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clinics', page, search],
|
||||
queryKey: ['admin-clinics', page, search, statusFilter],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<Clinic>>(
|
||||
`/api/v1/clinics?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
|
||||
`/api/v1/admin/clinics?page=${page}&limit=${limit}` +
|
||||
(search ? `&search=${encodeURIComponent(search)}` : '') +
|
||||
(statusFilter !== '' ? `&status=${statusFilter}` : ''),
|
||||
),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (c: Clinic) => api.delete<ApiResponse<null>>(`/api/v1/clinic/${c.uuid}`),
|
||||
mutationFn: (c: Clinic) => api.delete<ApiResponse<null>>(`/api/v1/admin/clinic/${c.uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('کلینیک حذف شد');
|
||||
setDeleteTarget(null);
|
||||
qc.invalidateQueries({ queryKey: ['clinics'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const columns: Column<Clinic>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'نام کلینیک',
|
||||
render: (c) => {
|
||||
const hue = HUES_LIST[(c.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length];
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
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'] });
|
||||
},
|
||||
{ key: 'phone', header: 'تلفن', render: (c) => c.phone ? <span dir="ltr">{c.phone}</span> : '—' },
|
||||
{
|
||||
key: 'doctors_count',
|
||||
header: 'پزشکان',
|
||||
render: (c) => (
|
||||
<span className="badge blue">
|
||||
<span className="bdot" />
|
||||
{formatNumber(c.doctors_count ?? 0)} پزشک
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'is_active', header: 'وضعیت', render: (c) => <ActiveBadge active={c.is_active} /> },
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (c) => formatDate(c.created_at) },
|
||||
];
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const items = data?.data ?? [];
|
||||
const addForm = useForm<AddForm>({ resolver: zodResolver(addSchema) });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (d: AddForm) => api.post<ApiResponse<{ uuid: string }>>('/api/v1/clinic', d),
|
||||
onSuccess: (res) => {
|
||||
toast.success('کلینیک اضافه شد');
|
||||
setAddOpen(false);
|
||||
addForm.reset();
|
||||
qc.invalidateQueries({ queryKey: ['admin-clinics'] });
|
||||
if (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 (
|
||||
@@ -87,8 +88,12 @@ export default function ClinicsPage() {
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">کلینیکها</h1>
|
||||
<div className="muted">{total} کلینیک ثبتشده</div>
|
||||
<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">
|
||||
@@ -99,36 +104,166 @@ export default function ClinicsPage() {
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
placeholder="جستجو بر اساس نام کلینیک..."
|
||||
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>
|
||||
<DataTable<Clinic>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
emptyMessage="هیچ کلینیکی یافت نشد"
|
||||
actions={(clinic) => (
|
||||
<>
|
||||
<button onClick={() => navigate(`/admin/clinics/${clinic.uuid}`)}
|
||||
className="mini-btn" title="مشاهده">
|
||||
<EyeIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/admin/clinics/${clinic.uuid}?edit=1`)}
|
||||
className="mini-btn" title="ویرایش">
|
||||
<PencilIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<button onClick={() => setDeleteTarget(clinic)}
|
||||
className="mini-btn danger" title="حذف">
|
||||
<TrashIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<table className="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<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={6}>
|
||||
<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.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${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>
|
||||
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
{/* Add Modal */}
|
||||
{addOpen && (
|
||||
<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' }}>
|
||||
نام کلینیک
|
||||
</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" dir="ltr" {...addForm.register('phone')} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف کلینیک"
|
||||
|
||||
@@ -41,11 +41,10 @@ export interface Clinic {
|
||||
uuid: string;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
description: string | null;
|
||||
logo: string | null;
|
||||
is_active: boolean;
|
||||
doctors_count: number;
|
||||
created_at: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type AppointmentStatus =
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260610175105 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE clinics ADD is_active TINYINT NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE clinics DROP is_active');
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,86 @@ class AdminApiController extends BaseController
|
||||
return $this->success(['uuid' => $doctor->getUuid()], 201);
|
||||
}
|
||||
|
||||
// ── Clinics ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/admin/clinics', methods: ['GET'])]
|
||||
public function clinicsList(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(5, (int) $request->query->get('limit', 15)));
|
||||
$search = trim((string) $request->query->get('search', ''));
|
||||
$status = $request->query->get('status', '');
|
||||
|
||||
$conn = $this->em->getConnection();
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = '(c.name LIKE :s OR c.telephone LIKE :s)';
|
||||
$params['s'] = '%' . $search . '%';
|
||||
}
|
||||
if ($status !== '') {
|
||||
$where[] = 'c.is_active = :active';
|
||||
$params['active'] = $status === '1' ? 1 : 0;
|
||||
}
|
||||
|
||||
$whereStr = implode(' AND ', $where);
|
||||
|
||||
$total = (int) $conn->fetchOne(
|
||||
"SELECT COUNT(*) FROM clinics c WHERE $whereStr",
|
||||
$params
|
||||
);
|
||||
|
||||
$offset = ($page - 1) * $limit;
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
"SELECT c.uuid, c.name, c.telephone, c.clinic_logo, c.is_active, c.created_at,
|
||||
COUNT(DISTINCT cd.doctor_id) as doctors_count
|
||||
FROM clinics c
|
||||
LEFT JOIN clinic_doctors cd ON cd.clinic_id = c.id
|
||||
WHERE $whereStr
|
||||
GROUP BY c.id
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $limit OFFSET $offset",
|
||||
$params
|
||||
);
|
||||
|
||||
$items = array_map(fn(array $c) => [
|
||||
'uuid' => $c['uuid'],
|
||||
'name' => $c['name'],
|
||||
'phone' => $c['telephone'],
|
||||
'logo' => $c['clinic_logo'],
|
||||
'is_active' => (bool) $c['is_active'],
|
||||
'doctors_count' => (int) $c['doctors_count'],
|
||||
'created_at' => (int) $c['created_at'],
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{uuid}/status', methods: ['PATCH'])]
|
||||
public function toggleClinicStatus(string $uuid): JsonResponse
|
||||
{
|
||||
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404);
|
||||
|
||||
$clinic->setIsActive(!$clinic->isActive());
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['is_active' => $clinic->isActive()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteClinic(string $uuid): JsonResponse
|
||||
{
|
||||
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||||
if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404);
|
||||
|
||||
$this->em->remove($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(null);
|
||||
}
|
||||
|
||||
// ── Appointments ──────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
|
||||
@@ -64,6 +64,9 @@ class Clinic
|
||||
#[ORM\Column(name: 'representation_id', type: 'integer', nullable: true)]
|
||||
private ?int $representationId = null;
|
||||
|
||||
#[ORM\Column(name: 'is_active', type: 'boolean')]
|
||||
private bool $isActive = true;
|
||||
|
||||
#[ORM\Column(name: 'images_clinic', type: 'json', nullable: true)]
|
||||
private ?array $imagesClinic = null;
|
||||
|
||||
@@ -134,6 +137,7 @@ class Clinic
|
||||
public function getCityId(): ?int { return $this->cityId; }
|
||||
public function getProvinceId(): ?int { return $this->provinceId; }
|
||||
public function getRepresentationId(): ?int { return $this->representationId; }
|
||||
public function isActive(): bool { return $this->isActive; }
|
||||
public function getImagesClinic(): ?array { return $this->imagesClinic; }
|
||||
public function getClinicLogo(): ?string { return $this->clinicLogo; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
@@ -154,6 +158,7 @@ class Clinic
|
||||
public function setCityId(?int $v): self { $this->cityId = $v; $this->touch(); return $this; }
|
||||
public function setProvinceId(?int $v): self { $this->provinceId = $v; $this->touch(); return $this; }
|
||||
public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; }
|
||||
public function setIsActive(bool $v): self { $this->isActive = $v; $this->touch(); return $this; }
|
||||
public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; }
|
||||
public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; }
|
||||
|
||||
@@ -164,7 +169,11 @@ class Clinic
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'title' => $this->name,
|
||||
'is_active' => $this->isActive,
|
||||
'phone' => $this->telephone,
|
||||
'logo' => $this->clinicLogo,
|
||||
'images_clinic' => $this->imagesClinic ?? [],
|
||||
'clinic_logo' => $this->clinicLogo,
|
||||
'phone_number' => $this->telephone,
|
||||
@@ -202,14 +211,19 @@ class Clinic
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'title' => $this->name,
|
||||
'images_clinic' => $this->imagesClinic ?? [],
|
||||
'clinic_logo' => $this->clinicLogo ?? [],
|
||||
'phone' => $this->telephone,
|
||||
'phone_number' => $this->telephone,
|
||||
'logo' => $this->clinicLogo,
|
||||
'clinic_logo' => $this->clinicLogo,
|
||||
'images_clinic' => $this->imagesClinic ?? [],
|
||||
'doctors_count' => $this->doctors->count(),
|
||||
'is_active' => $this->isActive,
|
||||
'created_at' => $this->createdAt,
|
||||
'specialties' => array_map(fn(Specialty $s) => [
|
||||
'uuid' => $s->getUuid(), 'id' => (string) $s->getId(), 'name' => $s->getName(),
|
||||
], $this->specialties->toArray()),
|
||||
'doctors' => $this->doctors->count(),
|
||||
'24_7' => $this->is247,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user