feat: add clinic status management with active/inactive toggle and update related API endpoints

This commit is contained in:
hamed
2026-06-10 21:29:29 +03:30
parent b0d6ecbd96
commit c41d03b5c5
6 changed files with 555 additions and 134 deletions
+222 -60
View File
@@ -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>
);
}