diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index d2690363..9d727910 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -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 ( -
- {label} - {value ?? '—'} -
- ); +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>(`/api/v1/clinic/${uuid}`), + queryKey: ['clinic-detail', uuid], + queryFn: () => api.get>(`/api/v1/clinic/${uuid}`), enabled: !!uuid, }); - const clinic = data?.data; + const clinic: ClinicDetail | undefined = data?.data; - return ( -
- 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"> - - بازگشت - - } - /> + useEffect(() => { + if (clinic) { + setEditName(clinic.name ?? ''); + setEditPhone(clinic.phone ?? ''); + } + }, [clinic]); - {isLoading ? ( -
+ const toggleMutation = useMutation({ + mutationFn: () => api.patch>(`/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>(`/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 ( +
+
{Array.from({ length: 5 }).map((_, i) => ( -
+
))}
- ) : clinic ? ( -
-
-
- {clinic.logo ? ( - - ) : ( -
- {clinic.name?.[0]} -
- )} -

{clinic.name}

-
- {clinic.phone} : null} /> - } /> - -
+
+ ); + } - {clinic.description && ( -
-

توضیحات

-

{clinic.description}

+ if (!clinic) { + return ( +
+
+
+ +

کلینیک یافت نشد

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

{clinic.name}

+
جزئیات کلینیک
+
+
+
+ + +
+
+ + {/* Main info card */} +
+
+ {clinic.logo ? ( + + ) : ( +
+ {clinic.name?.[0] ?? '?'}
)} +
+
{clinic.name}
+ + + {clinic.is_active ? 'فعال' : 'غیرفعال'} + +
- ) : ( -
- کلینیکی یافت نشد + +
+ {clinic.phone} : '—'} /> + {clinic.doctors_count !== undefined && ( + + + {clinic.doctors_count} پزشک + + } + /> + )} + {clinic.created_at && ( + + )} +
+
+ + {/* Edit modal */} + {editOpen && ( +
setEditOpen(false)}> +
e.stopPropagation()}> +
+ ویرایش کلینیک + +
+
+
+ + setEditName(e.target.value)} + placeholder="نام کلینیک" + /> +
+
+ + setEditPhone(e.target.value)} + placeholder="مثال: 021-12345678" + dir="ltr" + /> +
+
+
+ + +
+
)}
); } + +function InfoCard({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index 28e19752..acbc4308 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -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; + 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(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>( - `/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>(`/api/v1/clinic/${c.uuid}`), + mutationFn: (c: Clinic) => api.delete>(`/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[] = [ - { - key: 'name', - header: 'نام کلینیک', - render: (c) => { - const hue = HUES_LIST[(c.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; - return ( -
- {c.logo ? ( - - ) : ( -
- {c.name?.[0] ?? '?'} -
- )} -
- {c.name} -
-
- ); - }, + const toggleMutation = useMutation({ + mutationFn: (c: Clinic) => api.patch>(`/api/v1/admin/clinic/${c.uuid}/status`, {}), + onSuccess: () => { + toast.success('وضعیت کلینیک تغییر کرد'); + qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }, - { key: 'phone', header: 'تلفن', render: (c) => c.phone ? {c.phone} : '—' }, - { - key: 'doctors_count', - header: 'پزشکان', - render: (c) => ( - - - {formatNumber(c.doctors_count ?? 0)} پزشک - - ), - }, - { key: 'is_active', header: 'وضعیت', render: (c) => }, - { key: 'created_at', header: 'تاریخ ثبت', render: (c) => formatDate(c.created_at) }, - ]; + onError: (err: Error) => toast.error(err.message), + }); - const items = data?.data ?? []; + const addForm = useForm({ resolver: zodResolver(addSchema) }); + + const addMutation = useMutation({ + mutationFn: (d: AddForm) => api.post>('/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() {

کلینیک‌ها

-
{total} کلینیک ثبت‌شده
+
{formatNumber(total)} کلینیک ثبت‌شده
+
@@ -99,36 +104,166 @@ export default function ClinicsPage() { { setSearch(e.target.value); setPage(1); }} - placeholder="جستجو بر اساس نام کلینیک..." + placeholder="جستجو بر اساس نام..." />
+
+ {[ + { v: '', label: 'همه' }, + { v: '1', label: 'فعال' }, + { v: '0', label: 'غیرفعال' }, + ].map(({ v, label }) => ( + + ))} +
- - columns={columns} - data={items} - loading={isLoading} - emptyMessage="هیچ کلینیکی یافت نشد" - actions={(clinic) => ( - <> - - - - - )} - /> + + + + + + + + + + + + + {isLoading + ? Array.from({ length: 8 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + : items.length === 0 + ? ( + + + + ) + : items.map((c) => { + const hue = HUES_LIST[(c.uuid?.charCodeAt(0) ?? 0) % HUES_LIST.length]; + return ( + + + + + + + + + ); + })} + +
کلینیکتلفنپزشکانوضعیتتاریخ ثبت +
+
+ +

هیچ کلینیکی یافت نشد

+
+
+
+ {c.logo ? ( + + ) : ( +
+ {c.name?.[0] ?? '?'} +
+ )} +
+ {c.name} +
+
+
{c.phone ? {c.phone} : '—'} + + + {formatNumber(c.doctors_count ?? 0)} پزشک + + + + + {c.is_active ? 'فعال' : 'غیرفعال'} + + {formatDate(String(c.created_at))} +
+ + + +
+
+
+ {/* Add Modal */} + {addOpen && ( +
setAddOpen(false)}> +
e.stopPropagation()}> +
+ افزودن کلینیک +
+
addMutation.mutate(d))}> +
+
+ + + {addForm.formState.errors.name && ( +
{addForm.formState.errors.name.message}
+ )} +
+
+ + +
+
+
+ + +
+
+
+
+ )} + 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'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index e8bde66c..9c60e22f 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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( diff --git a/src/Clinic/Entity/Clinic.php b/src/Clinic/Entity/Clinic.php index 9750880b..c4858632 100644 --- a/src/Clinic/Entity/Clinic.php +++ b/src/Clinic/Entity/Clinic.php @@ -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, ]; }