style(admin): match clinic-services cards exactly to Figma service card
Replicate the Figma patient-service card chrome for catalog items: overflow menu top-start, status square (active/inactive), name + section subtitle, label:value rows, price, full-width primary action. Adapts the frame's fields to catalog data (staff, insurance, price). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon,
|
PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon,
|
||||||
ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon,
|
ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon,
|
||||||
|
EllipsisHorizontalIcon, CheckCircleIcon, ClockIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -48,6 +49,24 @@ function Avatar({ name }: { name: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SrvRow({ label, value, strong, danger }: { label: string; value: string; strong?: boolean; danger?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, padding: '3px 0' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>{label}:</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: strong ? 800 : 500,
|
||||||
|
color: danger ? 'var(--danger)' : strong ? 'var(--primary)' : 'var(--text-2)',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ClinicServicesPageInner() {
|
function ClinicServicesPageInner() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
@@ -60,6 +79,7 @@ function ClinicServicesPageInner() {
|
|||||||
const [insuranceItem, setInsuranceItem] = useState<ServiceItem | null>(null);
|
const [insuranceItem, setInsuranceItem] = useState<ServiceItem | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [showInactive, setShowInactive] = useState(true);
|
const [showInactive, setShowInactive] = useState(true);
|
||||||
|
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: sectionsData, isLoading: sectionsLoading } = useQuery<ApiResponse<ServiceSection[]>>({
|
const { data: sectionsData, isLoading: sectionsLoading } = useQuery<ApiResponse<ServiceSection[]>>({
|
||||||
queryKey: ['service-sections'],
|
queryKey: ['service-sections'],
|
||||||
@@ -313,61 +333,103 @@ function ClinicServicesPageInner() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: 12, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
|
<div style={{ padding: 16, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(268px, 1fr))', gap: 16 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
|
const done = item.active;
|
||||||
|
const menuItemStyle: React.CSSProperties = {
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '8px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', color: 'var(--text-2)', fontSize: 13,
|
||||||
|
fontFamily: 'inherit', textAlign: 'start', whiteSpace: 'nowrap',
|
||||||
|
};
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.uuid}
|
key={item.uuid}
|
||||||
className="cp-card"
|
|
||||||
style={{
|
style={{
|
||||||
padding: 14, display: 'flex', flexDirection: 'column', gap: 10,
|
position: 'relative',
|
||||||
background: item.active ? 'var(--surface)' : 'var(--surface-2)',
|
background: 'var(--surface)',
|
||||||
opacity: item.active ? 1 : 0.75,
|
borderRadius: 8,
|
||||||
|
boxShadow: '0 1px 24.8px rgba(204,204,204,0.18)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
padding: 12,
|
||||||
|
opacity: item.active ? 1 : 0.72,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
{/* ⋮ منوی عملیات — چپبالا (مطابق فیگما) */}
|
||||||
<span style={{ fontWeight: 700, fontSize: 14, lineHeight: 1.5 }}>{item.name}</span>
|
<button
|
||||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
className="btn sm ghost"
|
||||||
{!item.active && <span className="badge gray" style={{ fontSize: 10 }}>غیرفعال</span>}
|
style={{ position: 'absolute', top: 6, left: 6, padding: 4, zIndex: 2 }}
|
||||||
{item.insurance_covered && (
|
onClick={() => setMenuOpen(menuOpen === item.uuid ? null : item.uuid)}
|
||||||
<span className="badge green" style={{ fontSize: 10 }}><span className="bdot" />بیمه</span>
|
title="عملیات"
|
||||||
|
>
|
||||||
|
<EllipsisHorizontalIcon style={{ width: 20 }} />
|
||||||
|
</button>
|
||||||
|
{menuOpen === item.uuid && (
|
||||||
|
<>
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={() => setMenuOpen(null)} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 34, left: 6, zIndex: 41,
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, minWidth: 176,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); openEditItem(item); }}>
|
||||||
|
<PencilIcon style={{ width: 15 }} /> ویرایش
|
||||||
|
</button>
|
||||||
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setTariffItem(item); }}>
|
||||||
|
<BanknotesIcon style={{ width: 15 }} /> تعرفههای سالانه
|
||||||
|
</button>
|
||||||
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setInsuranceItem(item); }}>
|
||||||
|
<ShieldCheckIcon style={{ width: 15 }} /> پوشش بیمه
|
||||||
|
</button>
|
||||||
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setToggleItem(item); }}>
|
||||||
|
{item.active ? <EyeSlashIcon style={{ width: 15 }} /> : <EyeIcon style={{ width: 15 }} />}
|
||||||
|
{item.active ? ' غیرفعالکردن' : ' فعالکردن'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* هدر: آیکن وضعیت (راست) + نام و بخش */}
|
||||||
|
<div style={{ position: 'relative', minHeight: 40, paddingRight: 48, paddingLeft: 28 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, right: 0,
|
||||||
|
width: 36, height: 36, borderRadius: 8, display: 'grid', placeItems: 'center',
|
||||||
|
background: done ? 'var(--success-bg)' : 'var(--warning-bg)',
|
||||||
|
color: done ? 'var(--success)' : 'var(--warning)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? <CheckCircleIcon style={{ width: 20 }} /> : <ClockIcon style={{ width: 20 }} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', lineHeight: 1.5 }}>{item.name}</div>
|
||||||
|
{selectedSection && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 4 }}>{selectedSection.name}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-3)' }}>
|
<div style={{ height: 1, background: 'var(--border)', margin: '12px 0' }} />
|
||||||
{item.staff ? (
|
|
||||||
<>
|
|
||||||
<Avatar name={item.staff.full_name} />
|
|
||||||
<span>{item.staff.full_name}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span>بدون پرسنل مسئول</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ color: 'var(--primary)', fontWeight: 800, fontSize: 16 }}>{formatRial(item.price_rials)}</div>
|
<SrvRow label="پرسنل مسئول" value={item.staff ? item.staff.full_name : 'بدون پرسنل'} />
|
||||||
|
<SrvRow label="پوشش بیمه" value={item.insurance_covered ? 'دارد' : 'ندارد'} />
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginTop: 'auto', paddingTop: 8, borderTop: '1px solid var(--border)' }}>
|
<div style={{ height: 1, background: 'var(--border)', margin: '12px 0' }} />
|
||||||
<button className="btn sm ghost" onClick={() => openEditItem(item)} title="ویرایش">
|
|
||||||
<PencilIcon style={{ width: 15 }} />
|
<SrvRow label="هزینه" value={formatRial(item.price_rials)} strong />
|
||||||
</button>
|
{item.insurance_covered && item.insurance_price_rials != null && (
|
||||||
<button className="btn sm ghost" onClick={() => setTariffItem(item)} title="تعرفههای سالانه">
|
<SrvRow label="سهم بیمار (بیمه)" value={formatRial(item.insurance_price_rials)} />
|
||||||
<BanknotesIcon style={{ width: 15 }} />
|
)}
|
||||||
</button>
|
|
||||||
<button className="btn sm ghost" onClick={() => setInsuranceItem(item)} title="پوشش بیمه">
|
<button className="cp-btn-primary" style={{ width: '100%', marginTop: 12 }} onClick={() => openEditItem(item)}>
|
||||||
<ShieldCheckIcon style={{ width: 15 }} />
|
ویرایش سرویس
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="btn sm ghost"
|
|
||||||
onClick={() => setToggleItem(item)}
|
|
||||||
title={item.active ? 'غیرفعالکردن' : 'فعالکردن'}
|
|
||||||
style={{ marginInlineStart: 'auto', color: item.active ? 'var(--text-3)' : 'var(--primary)' }}
|
|
||||||
>
|
|
||||||
{item.active ? <EyeSlashIcon style={{ width: 15 }} /> : <EyeIcon style={{ width: 15 }} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user