feat(clinic-services): two-page section→services flow matching Figma
Restructure clinic-services from two-pane to Figma two-view flow: sections card grid (name, edit, activate/deactivate, 'بخش جدید' modal) → click a section → its services card grid with breadcrumb back. Service card matches Figma: overflow menu, active badge, base price, average time badge, staff chip. Add duration_minutes to ServiceItem (migration + create/update + form field). Update clinic-services.md. (Version20260713050014 syncs pre-existing entity/schema drift.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon,
|
||||
ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon,
|
||||
EllipsisHorizontalIcon, CheckCircleIcon, ClockIcon,
|
||||
EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -29,6 +29,7 @@ const itemSchema = z.object({
|
||||
staff_uuid: z.string().optional(),
|
||||
insurance_covered: z.boolean().optional(),
|
||||
insurance_price_rials: z.coerce.number().min(0).optional(),
|
||||
duration_minutes: z.coerce.number().min(0).optional(),
|
||||
});
|
||||
type SectionForm = z.infer<typeof sectionSchema>;
|
||||
type ItemForm = z.infer<typeof itemSchema>;
|
||||
@@ -143,6 +144,16 @@ function ClinicServicesPageInner() {
|
||||
onError: (e: any) => { toast.error(e.message); setDeleteSection(null); },
|
||||
});
|
||||
|
||||
const toggleSection = useMutation({
|
||||
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
|
||||
api.patch(`/api/v1/service-section/${uuid}`, { active }),
|
||||
onSuccess: (_d, v) => {
|
||||
qc.invalidateQueries({ queryKey: ['service-sections'] });
|
||||
toast.success(v.active ? 'بخش فعال شد' : 'بخش غیرفعال شد');
|
||||
},
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const createItem = useMutation({
|
||||
mutationFn: (body: ItemForm & { section_uuid: string }) => api.post('/api/v1/service-item', {
|
||||
...body,
|
||||
@@ -187,12 +198,13 @@ function ClinicServicesPageInner() {
|
||||
staff_uuid: item.staff?.uuid ?? '',
|
||||
insurance_covered: item.insurance_covered ?? false,
|
||||
insurance_price_rials: rialToToman(item.insurance_price_rials ?? 0),
|
||||
duration_minutes: item.duration_minutes ?? undefined,
|
||||
});
|
||||
setItemModal(item);
|
||||
};
|
||||
|
||||
const openCreateItem = () => {
|
||||
itemForm.reset({ name: '', price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0 });
|
||||
itemForm.reset({ name: '', price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined });
|
||||
setItemModal('create');
|
||||
};
|
||||
|
||||
@@ -200,242 +212,172 @@ function ClinicServicesPageInner() {
|
||||
<>
|
||||
<PageHeader title="سرویسهای کلینیک" description="بخشها، سرویسها، تعرفه و پوشش بیمه را مدیریت کنید" />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '288px 1fr', gap: 18, alignItems: 'start' }}>
|
||||
{/* ───────── ستون بخشها ───────── */}
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<b style={{ fontSize: 14 }}>بخشها</b>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}
|
||||
>
|
||||
<PlusIcon style={{ width: 14 }} /> جدید
|
||||
{!selectedSection ? (
|
||||
/* ═════════ نمای بخشها ═════════ */
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<b style={{ fontSize: 16 }}>بخشها</b>
|
||||
<button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>
|
||||
<PlusIcon style={{ width: 16 }} /> بخش جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{sectionsLoading ? (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '12px 8px' }}>در حال بارگذاری...</div>
|
||||
) : sections.length === 0 ? (
|
||||
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<WrenchScrewdriverIcon style={{ width: 30, margin: '0 auto 10px', display: 'block', opacity: 0.4 }} />
|
||||
<div style={{ fontSize: 13 }}>بخشی ثبت نشده است</div>
|
||||
{sectionsLoading ? (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: 16 }}>در حال بارگذاری...</div>
|
||||
) : sections.length === 0 ? (
|
||||
<div className="card" style={{ padding: '52px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<WrenchScrewdriverIcon style={{ width: 34, margin: '0 auto 12px', display: 'block', opacity: 0.35 }} />
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>بخشی ثبت نشده است</div>
|
||||
<button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>افزودن بخش</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 14 }}>
|
||||
{sections.map((s) => (
|
||||
<div
|
||||
key={s.uuid}
|
||||
onClick={() => setSelectedSection(s)}
|
||||
style={{
|
||||
cursor: 'pointer', background: 'var(--surface)', borderRadius: 8,
|
||||
boxShadow: '0 1px 24.8px rgba(204,204,204,0.18)',
|
||||
border: '1px solid var(--border)', padding: '18px 14px',
|
||||
opacity: s.active ? 1 : 0.62,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', textAlign: 'center', marginBottom: 14 }}>{s.name}</div>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', borderTop: '1px solid var(--border)', paddingTop: 10 }} onClick={(e) => e.stopPropagation()}>
|
||||
<button className="btn sm ghost" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
|
||||
<PencilIcon style={{ width: 14 }} /> ویرایش
|
||||
</button>
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, color: s.active ? 'var(--danger)' : 'var(--success)' }}
|
||||
onClick={() => toggleSection.mutate({ uuid: s.uuid, active: !s.active })}
|
||||
>
|
||||
{s.active
|
||||
? <><XCircleIcon style={{ width: 14 }} /> غیرفعال</>
|
||||
: <><CheckCircleIcon style={{ width: 14 }} /> فعال</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ═════════ نمای سرویسهای یک بخش ═════════ */
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button className="cp-btn-secondary" style={{ height: 36 }} onClick={() => setSelectedSection(null)}>
|
||||
<ChevronRightIcon style={{ width: 16 }} /> بازگشت
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, color: 'var(--text-3)' }}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setSelectedSection(null)}>بخشها</span>
|
||||
<span>‹</span>
|
||||
<b style={{ color: 'var(--text)' }}>{selectedSection.name}</b>
|
||||
</div>
|
||||
) : (
|
||||
sections.map((s) => {
|
||||
const isActive = selectedSection?.uuid === s.uuid;
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<MagnifyingGlassIcon style={{ width: 15, position: 'absolute', insetInlineStart: 9, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
|
||||
<input className="cp-input" placeholder="جستجوی سرویس" value={search} onChange={(e) => setSearch(e.target.value)} style={{ width: 170, paddingInlineStart: 30, height: 36 }} />
|
||||
</div>
|
||||
<button className="btn sm ghost" onClick={() => setShowInactive((v) => !v)} title={showInactive ? 'پنهانکردن غیرفعالها' : 'نمایش غیرفعالها'}>
|
||||
{showInactive ? <EyeIcon style={{ width: 16 }} /> : <EyeSlashIcon style={{ width: 16 }} />}
|
||||
</button>
|
||||
<button className="cp-btn-primary" onClick={openCreateItem}>
|
||||
<PlusIcon style={{ width: 16 }} /> سرویس جدید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{itemsLoading ? (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: 16 }}>در حال بارگذاری...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="card" style={{ padding: '52px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block', opacity: 0.35 }} />
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>
|
||||
{allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'}
|
||||
</div>
|
||||
{allItems.length === 0 && (
|
||||
<button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={openCreateItem}>افزودن سرویس</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
||||
{items.map((item) => {
|
||||
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',
|
||||
};
|
||||
const chipStyle: React.CSSProperties = {
|
||||
fontSize: 12, color: 'var(--text-2)', background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)', borderRadius: 999, padding: '3px 10px', whiteSpace: 'nowrap',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={s.uuid}
|
||||
className="srv-section-row"
|
||||
key={item.uuid}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '9px 10px', cursor: 'pointer', borderRadius: 9,
|
||||
background: isActive ? 'var(--primary-subtle)' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
position: 'relative', background: 'var(--surface)', borderRadius: 8,
|
||||
boxShadow: '0 1px 24.8px rgba(204,204,204,0.18)',
|
||||
border: '1px solid var(--border)', padding: 14,
|
||||
opacity: item.active ? 1 : 0.7,
|
||||
}}
|
||||
onClick={() => setSelectedSection(s)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, minWidth: 0 }}>
|
||||
<WrenchScrewdriverIcon style={{
|
||||
width: 16, flexShrink: 0,
|
||||
color: isActive ? 'var(--primary)' : 'var(--text-3)',
|
||||
}} />
|
||||
<span style={{
|
||||
fontSize: 13.5, fontWeight: isActive ? 600 : 500,
|
||||
color: isActive ? 'var(--primary)' : 'var(--text-1)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{s.name}
|
||||
</span>
|
||||
{/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||
<button className="btn sm ghost" style={{ padding: 4 }} onClick={() => setMenuOpen(menuOpen === item.uuid ? null : item.uuid)} title="عملیات">
|
||||
<EllipsisHorizontalIcon style={{ width: 20 }} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span className={`badge ${item.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
||||
<span className="bdot" />{item.active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="srv-section-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
|
||||
<button className="btn sm ghost" onClick={() => openEditSection(s)} title="ویرایش">
|
||||
<PencilIcon style={{ width: 13 }} />
|
||||
</button>
|
||||
<button className="btn sm ghost" onClick={() => setDeleteSection(s)} title="حذف">
|
||||
<TrashIcon style={{ width: 13 }} />
|
||||
</button>
|
||||
{menuOpen === item.uuid && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={() => setMenuOpen(null)} />
|
||||
<div style={{ position: 'absolute', top: 40, left: 8, 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SrvRow label="قیمت پایه" value={formatRial(item.price_rials)} strong />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '3px 0' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>زمان متوسط:</span>
|
||||
{item.duration_minutes
|
||||
? <span className="badge blue" style={{ fontSize: 11 }}>{Number(item.duration_minutes).toLocaleString('fa-IR')} دقیقه</span>
|
||||
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>}
|
||||
</div>
|
||||
|
||||
{item.insurance_covered && item.insurance_price_rials != null && (
|
||||
<SrvRow label="سهم بیمار (بیمه)" value={formatRial(item.insurance_price_rials)} />
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '3px 0' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>پرسنل:</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{item.staff
|
||||
? <span style={chipStyle}>{item.staff.full_name}</span>
|
||||
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ───────── ستون سرویسها ───────── */}
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
{!selectedSection ? (
|
||||
<div style={{
|
||||
border: '2px dashed var(--border)', borderRadius: 12,
|
||||
margin: 18, padding: '64px 0', textAlign: 'center', color: 'var(--text-3)',
|
||||
}}>
|
||||
<WrenchScrewdriverIcon style={{ width: 42, margin: '0 auto 14px', display: 'block', opacity: 0.35 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-2)' }}>یک بخش انتخاب کنید</div>
|
||||
<div style={{ fontSize: 13, marginTop: 4 }}>تا سرویسهای آن را مشاهده و مدیریت کنید</div>
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--border)', flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<b style={{ fontSize: 14 }}>{selectedSection.name}</b>
|
||||
{!itemsLoading && (
|
||||
<span className="badge gray" style={{ fontSize: 11 }}>
|
||||
{activeCount} فعال{allItems.length > activeCount ? ` / ${allItems.length}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<MagnifyingGlassIcon style={{ width: 15, position: 'absolute', insetInlineStart: 9, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder="جستجوی سرویس"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 180, paddingInlineStart: 30, height: 34 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
onClick={() => setShowInactive((v) => !v)}
|
||||
title={showInactive ? 'پنهانکردن غیرفعالها' : 'نمایش غیرفعالها'}
|
||||
>
|
||||
{showInactive ? <EyeIcon style={{ width: 15 }} /> : <EyeSlashIcon style={{ width: 15 }} />}
|
||||
</button>
|
||||
<button className="btn primary sm" style={{ display: 'flex', alignItems: 'center', gap: 4 }} onClick={openCreateItem}>
|
||||
<PlusIcon style={{ width: 14 }} /> سرویس جدید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{itemsLoading ? (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: 16 }}>در حال بارگذاری...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: '52px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block', opacity: 0.35 }} />
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>
|
||||
{allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'}
|
||||
</div>
|
||||
{allItems.length === 0 && (
|
||||
<button className="btn primary sm" style={{ marginTop: 12 }} onClick={openCreateItem}>
|
||||
افزودن سرویس
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 16, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(268px, 1fr))', gap: 16 }}>
|
||||
{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
|
||||
key={item.uuid}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--surface)',
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{/* ⋮ منوی عملیات — چپبالا (مطابق فیگما) */}
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
style={{ position: 'absolute', top: 6, left: 6, padding: 4, zIndex: 2 }}
|
||||
onClick={() => setMenuOpen(menuOpen === item.uuid ? null : item.uuid)}
|
||||
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 style={{ height: 1, background: 'var(--border)', margin: '12px 0' }} />
|
||||
|
||||
<SrvRow label="پرسنل مسئول" value={item.staff ? item.staff.full_name : 'بدون پرسنل'} />
|
||||
<SrvRow label="پوشش بیمه" value={item.insurance_covered ? 'دارد' : 'ندارد'} />
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)', margin: '12px 0' }} />
|
||||
|
||||
<SrvRow label="هزینه" value={formatRial(item.price_rials)} strong />
|
||||
{item.insurance_covered && item.insurance_price_rials != null && (
|
||||
<SrvRow label="سهم بیمار (بیمه)" value={formatRial(item.insurance_price_rials)} />
|
||||
)}
|
||||
|
||||
<button className="cp-btn-primary" style={{ width: '100%', marginTop: 12 }} onClick={() => openEditItem(item)}>
|
||||
ویرایش سرویس
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───────── Modal بخش ───────── */}
|
||||
<Modal
|
||||
@@ -527,6 +469,15 @@ function ClinicServicesPageInner() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="field-label">زمان متوسط (دقیقه)</label>
|
||||
<div className="field">
|
||||
<input type="number" min={0} {...itemForm.register('duration_minutes')} placeholder="مثلاً: ۵۰" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* بیمه */}
|
||||
|
||||
@@ -431,6 +431,7 @@ export interface ServiceItem {
|
||||
active: boolean;
|
||||
insurance_covered?: boolean;
|
||||
insurance_price_rials?: number | null;
|
||||
duration_minutes?: number | null;
|
||||
}
|
||||
|
||||
export interface SmsWalletBalance {
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"name": "رادیوگرافی مستقیم",
|
||||
"price_rials": 500000,
|
||||
"active": true,
|
||||
"insurance_covered": false,
|
||||
"insurance_price_rials": null,
|
||||
"duration_minutes": 50,
|
||||
"created_at": 1718000000,
|
||||
"updated_at": 1718000000
|
||||
}
|
||||
@@ -113,7 +116,8 @@
|
||||
"price_rials": 500000,
|
||||
"staff_uuid": "...",
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": 200000
|
||||
"insurance_price_rials": 200000,
|
||||
"duration_minutes": 50
|
||||
}
|
||||
```
|
||||
|
||||
@@ -121,10 +125,11 @@
|
||||
|------|-----|--------|
|
||||
| section_uuid | UUID | ✅ |
|
||||
| name | string | ✅ |
|
||||
| price_rials | integer | ❌ (پیشفرض 0) |
|
||||
| staff_uuid | UUID | ❌ |
|
||||
| price_rials | integer | ❌ (پیشفرض 0) — «قیمت پایه» |
|
||||
| staff_uuid | UUID | ❌ — پرسنل مسئول |
|
||||
| insurance_covered | boolean | ❌ (پیشفرض false) — آیا خدمت شامل بیمه میشود |
|
||||
| insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه |
|
||||
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
|
||||
|
||||
**Response 201:** ServiceItem object (شامل `insurance_covered` و `insurance_price_rials`)
|
||||
|
||||
@@ -143,10 +148,13 @@
|
||||
"staff_uuid": null,
|
||||
"active": false,
|
||||
"insurance_covered": true,
|
||||
"insurance_price_rials": 250000
|
||||
"insurance_price_rials": 250000,
|
||||
"duration_minutes": 30
|
||||
}
|
||||
```
|
||||
|
||||
> فیلد `duration_minutes` (زمان متوسط، دقیقه) در پاسخِ `toArray` و در ساخت/ویرایش پشتیبانی میشود؛ `""`/`null` آن را پاک میکند.
|
||||
|
||||
> `staff_uuid` باید به پرسنل متعلق به همان tenant (`entity_type`/`entity_id` کاربر) اشاره کند؛ ربطدادن پرسنل tenant دیگر → `422 ERR_VALIDATION_001` (`field: staff_uuid`). همین قید روی `POST /service-item` نیز اعمال میشود.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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 Version20260713050014 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 doctor_claim_requests RENAME INDEX uniq_claim_uuid TO UNIQ_352BEA22D17F50A6');
|
||||
$this->addSql('ALTER TABLE doctor_claim_requests RENAME INDEX idx_claim_user TO IDX_352BEA22A76ED395');
|
||||
$this->addSql('ALTER TABLE representations CHANGE is_global is_global 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 doctor_claim_requests RENAME INDEX idx_352bea22a76ed395 TO IDX_claim_user');
|
||||
$this->addSql('ALTER TABLE doctor_claim_requests RENAME INDEX uniq_352bea22d17f50a6 TO UNIQ_claim_uuid');
|
||||
$this->addSql('ALTER TABLE representations CHANGE is_global is_global TINYINT DEFAULT 0 NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -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 Version20260713050114 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 service_items ADD duration_minutes INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE service_items DROP duration_minutes');
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,10 @@ class ClinicServiceController extends BaseController
|
||||
if (array_key_exists('insurance_price_rials', $data)) {
|
||||
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
|
||||
}
|
||||
if (array_key_exists('duration_minutes', $data)) {
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
@@ -213,6 +217,10 @@ class ClinicServiceController extends BaseController
|
||||
if (array_key_exists('insurance_price_rials', $data)) {
|
||||
$item->setInsurancePriceRials($data['insurance_price_rials'] !== null ? (int) $data['insurance_price_rials'] : null);
|
||||
}
|
||||
if (array_key_exists('duration_minutes', $data)) {
|
||||
$dm = $data['duration_minutes'];
|
||||
$item->setDurationMinutes(($dm === null || $dm === '') ? null : (int) $dm);
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ class ServiceItem
|
||||
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
|
||||
private ?int $insurancePriceRials = null;
|
||||
|
||||
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
|
||||
private ?int $durationMinutes = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -67,6 +70,7 @@ class ServiceItem
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
|
||||
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
|
||||
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
@@ -76,6 +80,7 @@ class ServiceItem
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
@@ -92,6 +97,7 @@ class ServiceItem
|
||||
'active' => $this->active,
|
||||
'insurance_covered' => $this->insuranceCovered,
|
||||
'insurance_price_rials' => $this->insurancePriceRials,
|
||||
'duration_minutes' => $this->durationMinutes,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user