Base insurance is a percentage-only rule: patient share is now total minus the base share, and the contract franchise no longer inflates it (franchise stays meaningful for supplementary contracts only). Coverage percentages are managed centrally by admin per service category (outpatient/inpatient, extensible via the ServiceCategory enum). A tenant contract may override a category, otherwise it follows the admin default live — changing the central value immediately applies to every contract that did not override it. - add ServiceCategory enum + GET /api/v1/service-categories as the single source of the category list for every client - add insurance_coverage_defaults (+ GET/PUT admin coverage-defaults endpoints) and expose coverage_defaults on the insurance list and insurance-pricing - add tenant_insurance_category_coverage; tenant-insurances accepts optional category_coverages (needs insurances.update) and returns the effective percentages with their source - add service_items.service_category; visits always resolve as outpatient - drop the reverse-engineered percent from patient_share_rials in MyPatientsPage and align the client-side BillingCalculator mirror in CreateStep Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
347 lines
16 KiB
TypeScript
347 lines
16 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { ShieldCheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import type { ServiceItem, ClinicStaff } from '../types';
|
|
import type { InventoryPackage, InventoryItem } from '../hooks/useInventory';
|
|
import { rialToToman, tomanToRial } from '../lib/utils';
|
|
import { numericField } from '../lib/forms';
|
|
import { useServiceCategories } from '../hooks/useServiceCategories';
|
|
import Modal from './ui/Modal';
|
|
import PriceInput from './ui/PriceInput';
|
|
import SearchableSelect from './ui/SearchableSelect';
|
|
|
|
const itemSchema = z.object({
|
|
name: z.string().min(1, 'نام سرویس الزامی است'),
|
|
price_rials: z.coerce.number().min(0, 'مبلغ نمیتواند منفی باشد'),
|
|
staff_uuids: z.array(z.string()).optional(),
|
|
duration_minutes: z.coerce.number().min(0).optional(),
|
|
bookable: z.boolean().optional(),
|
|
/** نوع خدمت (سرپایی/بستری) — مبنای انتخاب درصد پوشش بیمه. */
|
|
service_category: z.string().min(1, 'نوع خدمت الزامی است'),
|
|
/** پکیج کالای مصرفی؛ رشتهی خالی یعنی بدون پکیج. */
|
|
inventory_package_uuid: z.string().optional(),
|
|
/** اقلام کالای تکی — مستقل از پکیج. */
|
|
consumables: z.array(z.object({ item_uuid: z.string(), amount: z.coerce.number().min(1) })).optional(),
|
|
});
|
|
type ItemForm = z.infer<typeof itemSchema>;
|
|
|
|
const DEFAULT_SERVICE_CATEGORY = 'outpatient';
|
|
|
|
const EMPTY_FORM: ItemForm = {
|
|
name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined,
|
|
bookable: false, service_category: DEFAULT_SERVICE_CATEGORY,
|
|
inventory_package_uuid: '', consumables: [],
|
|
};
|
|
|
|
interface Props {
|
|
/** `'create'` برای سرویس جدید، شیء سرویس برای ویرایش، `null` یعنی بسته. */
|
|
item: 'create' | ServiceItem | null;
|
|
/** بخش مقصد؛ برای حالت ایجاد الزامی است. */
|
|
sectionUuid: string | null;
|
|
onClose: () => void;
|
|
/** برای باز کردن مودال پوشش بیمه از داخل فرم (فقط در حالت ویرایش). */
|
|
onManageInsurance?: (item: ServiceItem) => void;
|
|
}
|
|
|
|
/**
|
|
* فرم ایجاد/ویرایش سرویس — مشترک بین فهرست سرویسها و صفحهی جزئیات سرویس.
|
|
*
|
|
* تنظیمات بیمه اینجا نیست: پوشش هر بیمهگر تنها در «پوشش بیمه» مدیریت میشود و
|
|
* پرچم `insurance_covered` سمت سرور از همانجا همگام میشود.
|
|
*/
|
|
export default function ServiceItemFormModal({ item, sectionUuid, onClose, onManageInsurance }: Props) {
|
|
const qc = useQueryClient();
|
|
const editing = item !== null && typeof item === 'object' ? item : null;
|
|
|
|
const { data: staffData } = useQuery<ApiResponse<ClinicStaff[]>>({
|
|
queryKey: ['staff'],
|
|
queryFn: () => api.get('/api/v1/staff'),
|
|
enabled: item !== null,
|
|
});
|
|
const allStaff = staffData?.data ?? [];
|
|
|
|
const { data: packagesData } = useQuery<ApiResponse<InventoryPackage[]>>({
|
|
queryKey: ['inventory-packages'],
|
|
queryFn: () => api.get('/api/v1/inventory-packages'),
|
|
enabled: item !== null,
|
|
});
|
|
const packages = packagesData?.data ?? [];
|
|
|
|
// این endpoint پاسخ را در { items, stats } میپیچد.
|
|
const { data: inventoryData } = useQuery<ApiResponse<{ items: InventoryItem[] }>>({
|
|
queryKey: ['inventory-items'],
|
|
queryFn: () => api.get('/api/v1/inventory-items'),
|
|
enabled: item !== null,
|
|
});
|
|
const inventoryItems = inventoryData?.data?.items ?? [];
|
|
|
|
const { categories } = useServiceCategories(item !== null);
|
|
|
|
const form = useForm<ItemForm>({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM });
|
|
|
|
useEffect(() => {
|
|
if (item === null) return;
|
|
form.reset(editing
|
|
? {
|
|
name: editing.name,
|
|
price_rials: rialToToman(editing.price_rials),
|
|
staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid),
|
|
duration_minutes: editing.duration_minutes ?? undefined,
|
|
bookable: editing.bookable ?? false,
|
|
service_category: editing.service_category ?? DEFAULT_SERVICE_CATEGORY,
|
|
inventory_package_uuid: editing.inventory_package_uuid ?? '',
|
|
consumables: (editing.consumables ?? []).map((c) => ({ item_uuid: c.item_uuid, amount: c.amount })),
|
|
}
|
|
: EMPTY_FORM);
|
|
}, [item]);
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: ['service-items'] });
|
|
qc.invalidateQueries({ queryKey: ['service-sections'] });
|
|
if (editing) qc.invalidateQueries({ queryKey: ['service-item', editing.uuid] });
|
|
};
|
|
|
|
// رشتهی خالی در payload یعنی «بدون پکیج»؛ بکاند null میخواهد.
|
|
const toPayload = (body: ItemForm) => ({
|
|
...body,
|
|
price_rials: tomanToRial(body.price_rials),
|
|
inventory_package_uuid: body.inventory_package_uuid || null,
|
|
});
|
|
|
|
const createItem = useMutation({
|
|
mutationFn: (body: ItemForm) => api.post('/api/v1/service-item', {
|
|
...toPayload(body),
|
|
section_uuid: sectionUuid,
|
|
}),
|
|
onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ایجاد شد'); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const editItem = useMutation({
|
|
mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, toPayload(body)),
|
|
onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ویرایش شد'); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const saving = createItem.isPending || editItem.isPending;
|
|
|
|
const selectedStaffUuids = form.watch('staff_uuids') ?? [];
|
|
const editingMembers = editing ? (editing.staff_members ?? (editing.staff ? [editing.staff] : [])) : [];
|
|
const staffOptions = allStaff
|
|
.filter((s) => s.active || editingMembers.some((m) => m.uuid === s.uuid))
|
|
.filter((s) => !selectedStaffUuids.includes(s.uuid))
|
|
.map((s) => ({ value: s.uuid, label: s.active ? s.full_name : `${s.full_name} (غیرفعال)` }));
|
|
const consumables = form.watch('consumables') ?? [];
|
|
const inventoryOptions = inventoryItems
|
|
.filter((i) => !consumables.some((c) => c.item_uuid === i.uuid))
|
|
.map((i) => ({ value: i.uuid, label: `${i.name} (${i.unit})` }));
|
|
const inventoryNameOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.name ?? uuid;
|
|
const inventoryUnitOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.unit ?? '';
|
|
const addConsumable = (uuid: string) =>
|
|
form.setValue('consumables', [...consumables, { item_uuid: uuid, amount: 1 }]);
|
|
|
|
const staffNameOf = (uuid: string) =>
|
|
allStaff.find((s) => s.uuid === uuid)?.full_name
|
|
?? editingMembers.find((m) => m.uuid === uuid)?.full_name
|
|
?? uuid;
|
|
|
|
return (
|
|
<Modal
|
|
open={item !== null}
|
|
onClose={onClose}
|
|
title={editing ? 'ویرایش سرویس' : 'سرویس جدید'}
|
|
size="md"
|
|
footer={
|
|
<>
|
|
<button type="button" className="btn" onClick={onClose}>انصراف</button>
|
|
<button type="submit" form="service-item-form" className="btn primary" disabled={saving}>
|
|
{saving ? 'در حال ذخیره...' : 'ذخیره سرویس'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<form
|
|
id="service-item-form"
|
|
onSubmit={form.handleSubmit((d) => (editing ? editItem : createItem).mutate(d))}
|
|
style={{ display: 'flex', flexDirection: 'column', gap: 20 }}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div>
|
|
<label className="field-label">نام سرویس *</label>
|
|
<div className="field" style={form.formState.errors.name ? { borderColor: 'var(--danger)' } : undefined}>
|
|
<input {...form.register('name')} placeholder="مثلاً: سرم ۵۰۰cc" autoFocus />
|
|
</div>
|
|
{form.formState.errors.name && (
|
|
<span className="field-error">{form.formState.errors.name.message}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div>
|
|
<label className="field-label">قیمت پایه (تومان) *</label>
|
|
<div className="field">
|
|
<PriceInput
|
|
value={form.watch('price_rials') ?? 0}
|
|
onChange={(v) => form.setValue('price_rials', v)}
|
|
placeholder="۸۵,۰۰۰"
|
|
min={0}
|
|
suffix="تومان"
|
|
/>
|
|
</div>
|
|
{form.formState.errors.price_rials && (
|
|
<span className="field-error">{form.formState.errors.price_rials.message}</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="field-label">پرسنل مسئول</label>
|
|
<SearchableSelect
|
|
options={staffOptions}
|
|
value={''}
|
|
onChange={(v) => { if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }}
|
|
placeholder="افزودن پرسنل (اختیاری)"
|
|
noOptionsMessage="پرسنلی باقی نمانده"
|
|
height={42}
|
|
/>
|
|
{selectedStaffUuids.length > 0 && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
|
{selectedStaffUuids.map((uuid) => (
|
|
<span key={uuid} style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
fontSize: 12.5, color: 'var(--text-2)', background: 'var(--surface-2)',
|
|
border: '1px solid var(--border)', borderRadius: 999, padding: '4px 6px 4px 10px',
|
|
}}>
|
|
{staffNameOf(uuid)}
|
|
<button
|
|
type="button" aria-label={`حذف ${staffNameOf(uuid)}`}
|
|
onClick={() => form.setValue('staff_uuids', selectedStaffUuids.filter((u) => u !== uuid))}
|
|
style={{ display: 'grid', placeItems: 'center', width: 16, height: 16, border: 'none', cursor: 'pointer', borderRadius: '50%', background: 'var(--surface-3)', color: 'var(--text-3)' }}
|
|
>
|
|
<XMarkIcon style={{ width: 11 }} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, alignItems: 'end' }}>
|
|
<div>
|
|
<label className="field-label">زمان متوسط (دقیقه)</label>
|
|
<div className="field">
|
|
<input {...numericField(form.register('duration_minutes'))} placeholder="مثلاً: 50" />
|
|
</div>
|
|
</div>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
|
|
<span className="switch">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.watch('bookable') ?? false}
|
|
onChange={(e) => form.setValue('bookable', e.target.checked)}
|
|
/>
|
|
<span className="switch-track"><span className="switch-thumb" /></span>
|
|
</span>
|
|
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="field-label">نوع خدمت *</label>
|
|
<SearchableSelect
|
|
options={categories.map((c) => ({ value: c.key, label: c.label }))}
|
|
value={form.watch('service_category') || null}
|
|
onChange={(v) => { if (v != null) form.setValue('service_category', String(v)); }}
|
|
placeholder="انتخاب نوع خدمت"
|
|
noOptionsMessage="نوعی تعریف نشده است"
|
|
height={42}
|
|
/>
|
|
<span style={{ display: 'block', marginTop: 6, fontSize: 11.5, color: 'var(--text-3)' }}>
|
|
درصد پوشش بیمه بر اساس همین نوع محاسبه میشود.
|
|
</span>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="field-label">پکیج کالای مصرفی</label>
|
|
<SearchableSelect
|
|
options={packages.map((p) => ({ value: p.uuid, label: p.title }))}
|
|
value={form.watch('inventory_package_uuid') || null}
|
|
onChange={(v) => form.setValue('inventory_package_uuid', v == null ? '' : String(v))}
|
|
placeholder="بدون پکیج (اختیاری)"
|
|
noOptionsMessage="پکیجی تعریف نشده است"
|
|
isClearable
|
|
height={42}
|
|
/>
|
|
</div>
|
|
|
|
{/* کالای تکی — مستقل از پکیج و قابل استفاده همزمان با آن. */}
|
|
<div>
|
|
<label className="field-label">کالاهای تکی</label>
|
|
<SearchableSelect
|
|
options={inventoryOptions}
|
|
value={''}
|
|
onChange={(v) => { if (v != null) addConsumable(String(v)); }}
|
|
placeholder="افزودن کالا (اختیاری)"
|
|
noOptionsMessage="کالایی باقی نمانده"
|
|
height={42}
|
|
/>
|
|
{consumables.length > 0 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
|
|
{consumables.map((line, i) => (
|
|
<div key={line.item_uuid} style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px',
|
|
borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)',
|
|
}}>
|
|
<span style={{ flex: 1, minWidth: 0, fontSize: 13 }}>{inventoryNameOf(line.item_uuid)}</span>
|
|
<input
|
|
{...numericField(form.register(`consumables.${i}.amount` as const))}
|
|
aria-label={`تعداد ${inventoryNameOf(line.item_uuid)}`}
|
|
style={{ width: 64, height: 32, textAlign: 'center' }}
|
|
/>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)', minWidth: 34 }}>
|
|
{inventoryUnitOf(line.item_uuid)}
|
|
</span>
|
|
<button
|
|
type="button" aria-label={`حذف ${inventoryNameOf(line.item_uuid)}`}
|
|
onClick={() => form.setValue('consumables', consumables.filter((c) => c.item_uuid !== line.item_uuid))}
|
|
style={{ display: 'grid', placeItems: 'center', width: 20, height: 20, border: 'none', cursor: 'pointer', borderRadius: '50%', background: 'var(--surface-3)', color: 'var(--text-3)' }}
|
|
>
|
|
<XMarkIcon style={{ width: 12 }} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* بیمه — تنظیمات فقط در «پوشش بیمه» مدیریت میشود تا دادهی تکراری ساخته نشود. */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, padding: '11px 13px',
|
|
borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
|
|
}}>
|
|
<ShieldCheckIcon style={{ width: 16, flexShrink: 0, color: 'var(--primary)' }} />
|
|
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7 }}>
|
|
پوشش بیمهی این خدمت — درصد، فرانشیز و سقف هر بیمهگر — در بخش «پوشش بیمه» تنظیم میشود.
|
|
</span>
|
|
{editing && onManageInsurance && (
|
|
<button
|
|
type="button"
|
|
className="btn sm"
|
|
style={{ flexShrink: 0 }}
|
|
onClick={() => { const it = editing; onClose(); onManageInsurance(it); }}
|
|
>
|
|
پوشش بیمه
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|