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:
hamed
2026-07-13 08:41:29 +03:30
co-authored by Claude Opus 4.8
parent 5852890819
commit 9e491ae6b9
7 changed files with 267 additions and 227 deletions
+174 -223
View File
@@ -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>
{/* بیمه */}
+1
View File
@@ -431,6 +431,7 @@ export interface ServiceItem {
active: boolean;
insurance_covered?: boolean;
insurance_price_rials?: number | null;
duration_minutes?: number | null;
}
export interface SmsWalletBalance {
+12 -4
View File
@@ -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` نیز اعمال می‌شود.
---
+35
View File
@@ -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');
}
}
+31
View File
@@ -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);
+6
View File
@@ -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,
];