An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
550 lines
34 KiB
TypeScript
550 lines
34 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { PlusIcon, MinusIcon } from '@heroicons/react/24/outline';
|
|
import { api } from '../../lib/api';
|
|
import type { ApiResponse } from '../../lib/api';
|
|
import type { PatientProfile, ServiceSection, ServiceItem } from '../../types';
|
|
import { formatRial, rialToToman, tomanToRial } from '../../lib/utils';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import PersianDateInput from '../ui/PersianDateInput';
|
|
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
import { digitsOnly } from '../../lib/utils';
|
|
import {
|
|
DEFAULT_SERVICE_CATEGORY, contractPercentFor, patientShareOf,
|
|
type CoverageRule as Rule, type TenantContract,
|
|
} from '../../lib/insuranceShares';
|
|
|
|
interface Contract extends TenantContract { uuid: string }
|
|
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
|
|
interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string }
|
|
interface PackageRow { uuid: string; title: string; total: number; available: boolean }
|
|
interface StaffRow { uuid: string; full_name: string; active?: boolean }
|
|
|
|
/** ویزیت خدمتِ سرپایی است. */
|
|
const VISIT_SERVICE_CATEGORY = DEFAULT_SERVICE_CATEGORY;
|
|
|
|
// آینهی BillingCalculator در lib/insuranceShares است؛ اینجا فقط re-export میشود تا
|
|
// مصرفکنندگان قبلی (و تستها) نشکنند.
|
|
export { patientShareOf };
|
|
|
|
const todayISO = () => new Date().toISOString().slice(0, 10);
|
|
const nowHHMM = () => {
|
|
const d = new Date();
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
};
|
|
/** تاریخ میلادی + ساعت → unix زمان پذیرش */
|
|
const toSessionAt = (iso: string, time: string) => Math.floor(new Date(`${iso}T${time || '12:00'}:00`).getTime() / 1000);
|
|
|
|
// برچسب فیلد — tauri Step1Details Typography (fontWeight 500, 0.875rem, #6B7280)
|
|
const fieldLabel: React.CSSProperties = { fontWeight: 500, lineHeight: '130%', fontSize: '0.875rem', marginBottom: 8, color: '#6B7280', display: 'block' };
|
|
const primaryBtn: React.CSSProperties = { background: '#5559CE', color: '#fff', border: 'none', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' };
|
|
const ghostBtn: React.CSSProperties = { background: 'transparent', color: '#5559CE', border: '1px solid #5559CE', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' };
|
|
|
|
interface Props {
|
|
recordUuid: string;
|
|
profile?: PatientProfile | null;
|
|
onCreated: (sessionUuid: string) => void;
|
|
onCancel: () => void;
|
|
/** وقتی داده شود فرم در حالت ویرایش است و با PATCH به همان مراجعه ارسال میکند. */
|
|
editSession?: import('../SessionServiceCard').SessionCardData;
|
|
}
|
|
|
|
/**
|
|
* گام «ایجاد سرویس» — پورت tauri Step1Details با دیتای واقعی:
|
|
* تاریخ/ساعت پذیرش، بخش/سرویس/پرسنل، کالای مصرفی با شمارنده، پکیج، و بلوک بیمهی
|
|
* موجودِ NewSessionPage (نمایش شرطی: سرویسِ تحت پوشش بیمه یا بیمه در پروفایل بیمار).
|
|
*/
|
|
export default function CreateStep({ recordUuid, profile, onCreated, onCancel, editSession }: Props) {
|
|
const userName = useAuthStore((s) => s.userName);
|
|
const isEdit = !!editSession;
|
|
|
|
// ── state گام ایجاد ──────────────────────────────────────────────────────
|
|
const [dateISO, setDateISO] = useState(todayISO());
|
|
const [time, setTime] = useState(nowHHMM());
|
|
const [sectionUuid, setSectionUuid] = useState('');
|
|
const [itemUuid, setItemUuid] = useState('');
|
|
const [staffUuid, setStaffUuid] = useState('');
|
|
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean; category: string }[]>([]);
|
|
const [consumableUuid, setConsumableUuid] = useState('');
|
|
const [selectedConsumables, setSelectedConsumables] = useState<{ uuid: string; name: string; price: number; qty: number }[]>([]);
|
|
const [packageUuid, setPackageUuid] = useState('');
|
|
const [visitPrice, setVisitPrice] = useState('0');
|
|
const [visitPriceError, setVisitPriceError] = useState('');
|
|
const [baseId, setBaseId] = useState('');
|
|
const [suppId, setSuppId] = useState('');
|
|
const [basePercent, setBasePercent] = useState('0');
|
|
const [suppPercent, setSuppPercent] = useState('0');
|
|
/** نوع خدمتِ بیمهایِ این مراجعه؛ خالی یعنی «پیشفرضِ tenant». */
|
|
const [serviceCategory, setServiceCategory] = useState('');
|
|
const [notes, setNotes] = useState('');
|
|
|
|
// ── دادهها ──────────────────────────────────────────────────────────────
|
|
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
|
|
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
|
|
});
|
|
// همهی سرویسها (سراسری) تا بتوان بدون انتخاب بخش هم جستجو و انتخاب کرد.
|
|
const { data: allItemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
|
queryKey: ['service-items-all'],
|
|
queryFn: () => api.get('/api/v1/service-items'),
|
|
});
|
|
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
|
queryKey: ['service-items-for-session', sectionUuid],
|
|
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
|
enabled: !!sectionUuid,
|
|
});
|
|
const { data: staffData } = useQuery<ApiResponse<StaffRow[]>>({
|
|
queryKey: ['staff'], queryFn: () => api.get('/api/v1/staff'),
|
|
});
|
|
const { data: inventoryData } = useQuery<ApiResponse<{ items: InventoryItemRow[] }>>({
|
|
queryKey: ['inventory-items'], queryFn: () => api.get('/api/v1/inventory-items'),
|
|
});
|
|
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
|
|
queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'),
|
|
});
|
|
const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({
|
|
queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
|
});
|
|
const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
|
|
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'),
|
|
});
|
|
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
|
|
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
|
|
|
|
// نوع خدماتِ بیمهایِ فعالِ این tenant — همان تنظیم سراسری «مدیریت بیمه».
|
|
const enabledCategories: { key: string; label: string }[] =
|
|
((pricingData as any)?.data?.service_categories ?? []).filter((c: any) => c.enabled);
|
|
const needsCategoryChoice = enabledCategories.length > 1;
|
|
const defaultCategory: string =
|
|
(pricingData as any)?.data?.default_service_category ?? enabledCategories[0]?.key ?? VISIT_SERVICE_CATEGORY;
|
|
|
|
useEffect(() => {
|
|
if (!isEdit && freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit)));
|
|
}, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// پیشپرکردن فرم در حالت ویرایش (یکبار).
|
|
const [prefilled, setPrefilled] = useState(false);
|
|
useEffect(() => {
|
|
if (!editSession || prefilled) return;
|
|
setVisitPrice(String(rialToToman(editSession.visit_price_rials ?? 0)));
|
|
if (editSession.session_at) {
|
|
const d = new Date(editSession.session_at * 1000);
|
|
setDateISO(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
|
|
setTime(`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`);
|
|
}
|
|
setNotes((editSession as any).notes ?? '');
|
|
if (editSession.insurance_base_id) { setBaseId(String(editSession.insurance_base_id)); setBasePercent(String(editSession.base_insurance_discount_percent ?? 0)); }
|
|
if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); }
|
|
setServiceCategory(editSession.insurance_service_category ?? '');
|
|
setSelectedServices((editSession.services ?? []).map((s) => ({
|
|
uuid: s.service_item_uuid ?? '', name: s.service_name || s.name || '', price: s.price_rials ?? 0, qty: s.quantity ?? 1, insured: false,
|
|
category: VISIT_SERVICE_CATEGORY,
|
|
})).filter((s) => s.uuid));
|
|
setSelectedConsumables((editSession.consumables ?? []).map((c) => ({
|
|
uuid: c.inventory_item_uuid ?? '', name: c.item_name ?? '', price: c.price_rials ?? 0, qty: c.quantity ?? 1,
|
|
})).filter((c) => c.uuid));
|
|
setPrefilled(true);
|
|
}, [editSession, prefilled]);
|
|
|
|
const contracts = (contractsData as any)?.data?.data as Contract[] | undefined ?? [];
|
|
const baseOpts = contracts.filter(c => c.insurance_kind === 'basic').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
|
|
const suppOpts = contracts.filter(c => c.insurance_kind === 'supplementary').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
|
|
|
|
const sectionOptions = (sectionsData?.data ?? []).map(s => ({ value: s.uuid, label: s.name }));
|
|
// با انتخاب بخش، فقط سرویسهای همان بخش؛ بدون بخش، همهی سرویسها (قابل جستجو).
|
|
const serviceItems = (sectionUuid ? (itemsData?.data ?? []) : (allItemsData?.data ?? [])).filter(i => i.active);
|
|
const itemOptions = serviceItems.map(i => ({ value: i.uuid, label: i.name }));
|
|
const currentItem = serviceItems.find(i => i.uuid === itemUuid);
|
|
|
|
const staffOptions = ((staffData?.data as StaffRow[] | undefined) ?? []).filter(s => s.active !== false).map(s => ({ value: s.uuid, label: s.full_name }));
|
|
|
|
const inventoryItems = ((inventoryData?.data as any)?.items as InventoryItemRow[] | undefined) ?? [];
|
|
const consumableOptions = inventoryItems.map(i => ({ value: i.uuid, label: i.name }));
|
|
const currentConsumable = inventoryItems.find(i => i.uuid === consumableUuid);
|
|
|
|
const packageOptions = ((packagesData?.data as PackageRow[] | undefined) ?? []).map(p => ({ value: p.uuid, label: `${p.title} — ${formatRial(p.total)}` }));
|
|
|
|
const baseContract = contracts.find(c => String(c.insurance_id) === baseId) ?? null;
|
|
const suppContract = contracts.find(c => String(c.insurance_id) === suppId) ?? null;
|
|
|
|
/**
|
|
* مراجعهٔ جدید: بیمهٔ پروفایل بیمار پیشانتخاب میشود — فقط اگر برای همان بیمه
|
|
* قرارداد فعال وجود داشته باشد. یکبار، و بعدش انتخاب کاربر دستنخورده میماند.
|
|
*/
|
|
const [insurancePrefilled, setInsurancePrefilled] = useState(false);
|
|
useEffect(() => {
|
|
if (isEdit || insurancePrefilled || contracts.length === 0) return;
|
|
|
|
const profileBase = profile?.basic_insurance_id ? String(profile.basic_insurance_id) : '';
|
|
const profileSupp = profile?.supplementary_insurance_id ? String(profile.supplementary_insurance_id) : '';
|
|
if (profileBase && baseOpts.some(o => o.value === profileBase)) applyBase(profileBase);
|
|
if (profileSupp && suppOpts.some(o => o.value === profileSupp)) applySupp(profileSupp);
|
|
setInsurancePrefilled(true);
|
|
}, [contracts.length, profile, isEdit, insurancePrefilled]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const baseCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({
|
|
queryKey: ['service-coverage', baseContract?.uuid],
|
|
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${baseContract!.uuid}/service-coverage`),
|
|
enabled: !!baseContract,
|
|
});
|
|
const suppCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({
|
|
queryKey: ['service-coverage', suppContract?.uuid],
|
|
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${suppContract!.uuid}/service-coverage`),
|
|
enabled: !!suppContract,
|
|
});
|
|
const baseCoverage = (baseCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
|
|
const suppCoverage = (suppCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
|
|
|
|
/**
|
|
* قاعدهی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه درصد
|
|
* همان نوع خدمت (سرپایی/بستری). فرانشیز فقط در قرارداد تکمیلی خوانده میشود.
|
|
*/
|
|
const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string, category: string): Rule | null => {
|
|
if (!contract) return null;
|
|
const ov = coverage.find(r => r.service_item_uuid === serviceUuid);
|
|
if (ov && !ov.covered) return { covered: false, percent: 0, franchise: 0, ceiling: null };
|
|
const isSupplementary = contract.insurance_kind === 'supplementary';
|
|
return {
|
|
covered: true,
|
|
percent: ov?.coverage_percent ?? contractPercentFor(contract, category),
|
|
franchise: isSupplementary ? (ov?.franchise_rials ?? contract.franchise_rials) : 0,
|
|
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
|
|
};
|
|
};
|
|
|
|
/** نوع خدمتِ مؤثرِ ویزیت: انتخاب کاربر، وگرنه تنها نوع فعالِ tenant. */
|
|
const visitCategory = serviceCategory || defaultCategory;
|
|
|
|
const coverageOf = (id: string, category = visitCategory): number => {
|
|
const contract = contracts.find(c => String(c.insurance_id) === id);
|
|
return contract ? contractPercentFor(contract, category) : 0;
|
|
};
|
|
const applyBase = (id: string) => { setBaseId(id); setBasePercent(id ? String(coverageOf(id)) : '0'); };
|
|
const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); };
|
|
|
|
/** تغییر نوع خدمت، درصدهای ویزیت را با همان نوع همگام میکند. */
|
|
const applyServiceCategory = (category: string) => {
|
|
setServiceCategory(category);
|
|
if (baseId) setBasePercent(String(coverageOf(baseId, category || defaultCategory)));
|
|
if (suppId) setSuppPercent(String(coverageOf(suppId, category || defaultCategory)));
|
|
};
|
|
|
|
/**
|
|
* بلوک بیمه هر وقت این پزشک/کلینیک قرارداد بیمهٔ فعال دارد نمایش داده میشود تا
|
|
* بیمه قابل انتخاب و تغییر باشد؛ پیشتر تنها با سرویسِ تحتپوشش یا بیمهٔ پروفایل
|
|
* ظاهر میشد و کاربر راهی برای انتخاب بیمه نداشت. مراجعهای که بیمه دارد هم
|
|
* (حالت ویرایش) همیشه بلوک را نشان میدهد.
|
|
*/
|
|
const showInsurance = contracts.length > 0
|
|
|| !!baseId
|
|
|| !!suppId
|
|
|| selectedServices.some(s => s.insured)
|
|
|| !!profile?.basic_insurance_id
|
|
|| !!profile?.supplementary_insurance_id;
|
|
|
|
// ── خدمات ────────────────────────────────────────────────────────────────
|
|
const addService = () => {
|
|
if (!currentItem || selectedServices.some(s => s.uuid === currentItem.uuid)) return;
|
|
setSelectedServices(p => [...p, {
|
|
uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1,
|
|
insured: !!currentItem.insurance_covered,
|
|
category: currentItem.service_category ?? VISIT_SERVICE_CATEGORY,
|
|
}]);
|
|
setItemUuid('');
|
|
};
|
|
const setServiceQty = (uuid: string, qty: number) => {
|
|
if (qty <= 0) { setSelectedServices(p => p.filter(s => s.uuid !== uuid)); return; }
|
|
setSelectedServices(p => p.map(s => s.uuid === uuid ? { ...s, qty } : s));
|
|
};
|
|
|
|
// ── کالای مصرفی (tauri addConsumable/increase/decrease) ─────────────────
|
|
const addConsumable = () => {
|
|
if (!currentConsumable) return;
|
|
setSelectedConsumables(p => {
|
|
const found = p.find(c => c.uuid === currentConsumable.uuid);
|
|
if (found) return p.map(c => c.uuid === found.uuid ? { ...c, qty: c.qty + 1 } : c);
|
|
return [...p, { uuid: currentConsumable.uuid, name: currentConsumable.name, price: currentConsumable.price, qty: 1 }];
|
|
});
|
|
setConsumableUuid('');
|
|
};
|
|
const setConsumableQty = (uuid: string, qty: number) => {
|
|
if (qty <= 0) { setSelectedConsumables(p => p.filter(c => c.uuid !== uuid)); return; }
|
|
setSelectedConsumables(p => p.map(c => c.uuid === uuid ? { ...c, qty } : c));
|
|
};
|
|
|
|
// ── قیمتها (آینهی سرور) ────────────────────────────────────────────────
|
|
// فیلد «قیمت ویزیت» تومان است؛ محاسبات و API ریالیاند.
|
|
const visit = tomanToRial(Number(visitPrice) || 0);
|
|
const base = Number(basePercent) || 0;
|
|
const supp = Number(suppPercent) || 0;
|
|
const servicesTotal = useMemo(() => selectedServices.reduce((s, x) => s + x.price * x.qty, 0), [selectedServices]);
|
|
const servicesPatient = useMemo(
|
|
() => selectedServices.reduce((sum, x) => {
|
|
const total = x.price * x.qty;
|
|
// نوع خدمت و پرچم پوشش از کاتالوگ خوانده میشوند تا ردیفهای پیشپرشدهٔ ویرایش
|
|
// هم مثل سرور حساب شوند (هنگام prefill این دو را نداریم).
|
|
const catalogItem = serviceItems.find(i => i.uuid === x.uuid);
|
|
const insured = catalogItem?.insurance_covered ?? x.insured;
|
|
if (!insured) return sum + total;
|
|
const category = catalogItem?.service_category ?? x.category;
|
|
return sum + patientShareOf(
|
|
total,
|
|
ruleFor(baseContract, baseCoverage, x.uuid, category),
|
|
ruleFor(suppContract, suppCoverage, x.uuid, category),
|
|
);
|
|
}, 0),
|
|
[selectedServices, baseContract, suppContract, baseCoverage, suppCoverage, serviceItems], // eslint-disable-line react-hooks/exhaustive-deps
|
|
);
|
|
const consumablesTotal = useMemo(() => selectedConsumables.reduce((s, c) => s + c.price * c.qty, 0), [selectedConsumables]);
|
|
const afterBase = Math.round(visit * (1 - base / 100));
|
|
const afterSupp = Math.round(afterBase * (1 - supp / 100));
|
|
const finalPrice = afterSupp + servicesPatient + consumablesTotal;
|
|
|
|
// ── ثبت ──────────────────────────────────────────────────────────────────
|
|
const createMut = useMutation({
|
|
mutationFn: (body: object) => isEdit
|
|
? api.patch(`/api/v1/session/${editSession!.uuid}`, body)
|
|
: api.post(`/api/v1/patient/${recordUuid}/session`, body),
|
|
onSuccess: (res: any) => {
|
|
toast.success(isEdit ? 'مراجعه ویرایش شد' : 'مراجعه ثبت شد');
|
|
onCreated((isEdit ? editSession!.uuid : res?.data?.uuid) as string);
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const submit = () => {
|
|
if (requireVisit && visit <= 0) {
|
|
setVisitPriceError('هزینه ویزیت الزامی است');
|
|
toast.error('هزینه ویزیت الزامی است');
|
|
return;
|
|
}
|
|
createMut.mutate({
|
|
visit_price_rials: visit,
|
|
base_insurance_discount_percent: showInsurance ? base : 0,
|
|
supplementary_discount_percent: showInsurance ? supp : 0,
|
|
...(showInsurance && baseId ? { insurance_base_id: Number(baseId) } : {}),
|
|
...(showInsurance && suppId ? { insurance_supplementary_id: Number(suppId) } : {}),
|
|
// نوع خدمتِ ویزیت؛ سرور درصد پوشش را بر پایهٔ همین resolve میکند.
|
|
insurance_service_category: showInsurance && (baseId || suppId) ? visitCategory : null,
|
|
...(isEdit ? {} : { payment_method: 'pending' }),
|
|
...(notes ? { notes } : {}),
|
|
session_at: toSessionAt(dateISO, time),
|
|
...(packageUuid ? { inventory_package_uuid: packageUuid } : {}),
|
|
services: selectedServices.map(s => ({ service_item_uuid: s.uuid, quantity: s.qty, ...(staffUuid ? { staff_uuid: staffUuid } : {}) })),
|
|
consumables: selectedConsumables.map(c => ({ inventory_item_uuid: c.uuid, quantity: c.qty })),
|
|
});
|
|
};
|
|
|
|
/** ردیف شمارندهی tauri (باکس ۷۴px، +/− نارنجی #FF7A45، سطل روی تعداد ۱) */
|
|
const counter = (qty: number, set: (q: number) => void) => (
|
|
<div className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 74, height: 27, border: '1px solid #d1d1d1', borderRadius: 8, padding: '0 4px', gap: 4 }}>
|
|
<button type="button" aria-label="افزایش" onClick={() => set(qty + 1)} style={{ border: 'none', background: 'transparent', color: '#FF7A45', cursor: 'pointer', display: 'flex' }}>
|
|
<PlusIcon style={{ width: 16 }} />
|
|
</button>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, color: '#525252', minWidth: 20, textAlign: 'center' }}>{qty}</span>
|
|
<button type="button" aria-label="کاهش" onClick={() => set(qty - 1)} style={{ border: 'none', background: 'transparent', color: '#FF7A45', cursor: 'pointer', display: 'flex' }}>
|
|
{qty > 1 ? <MinusIcon style={{ width: 16 }} /> : <TrashRed size={16} color="#f17732" />}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div style={{ marginTop: 16 }}>
|
|
{/* پذیرش کننده — tauri UserTick + کاربر لاگینشده */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 28 }}>
|
|
<UserTick color="#6B7280" size={18} />
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#616161' }}>پذیرش کننده: {userName || '—'}</span>
|
|
</div>
|
|
|
|
{/* تاریخ و ساعت پذیرش */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 24 }}>
|
|
<div>
|
|
<span style={fieldLabel}>تاریخ پذیرش</span>
|
|
<PersianDateInput value={dateISO} onChange={setDateISO} />
|
|
</div>
|
|
<div>
|
|
<span style={fieldLabel}>ساعت پذیرش</span>
|
|
<div className="bg-white dark:bg-[#222433] dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 8, height: 48, border: '1px solid #e1e1e1', borderRadius: 4, padding: '0 8px' }}>
|
|
<ClockP color="#6B7280" size={18} />
|
|
<input
|
|
type="time"
|
|
className="time-input-plain"
|
|
aria-label="ساعت پذیرش"
|
|
value={time}
|
|
onChange={(e) => setTime(e.target.value)}
|
|
style={{ border: 'none', outline: 'none', background: 'transparent', fontSize: '0.875rem', color: 'inherit', flex: 1 }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* انتخاب بخش */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<span style={fieldLabel}>انتخاب بخش</span>
|
|
<SearchableSelect inputId="section-select" options={sectionOptions} value={sectionUuid} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب کنید..." />
|
|
</div>
|
|
|
|
{/* انتخاب سرویس + قیمت + افزودن */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<div style={{ width: '50%' }}>
|
|
<span style={fieldLabel}>انتخاب سرویس</span>
|
|
<SearchableSelect inputId="service-select" options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="جستجو و انتخاب سرویس..." />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 20, flexShrink: 0 }}>
|
|
<FilesServiceAddCard color="#6B7280" />
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, color: '#3b3b3b' }}>
|
|
قیمت: {currentItem ? formatRial(currentItem.price_rials) : '—'}
|
|
</span>
|
|
</div>
|
|
<button type="button" aria-label="افزودن سرویس" onClick={addService} disabled={!itemUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: '#5559CE', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 20 }}>
|
|
<PlusIcon style={{ width: 18, color: '#fff' }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* خدمات انتخاب شده */}
|
|
{selectedServices.length > 0 && (
|
|
<div style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginBottom: 16 }}>خدمات انتخاب شده</span>
|
|
{selectedServices.map(item => (
|
|
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', minWidth: 120 }}>{item.name}</span>
|
|
{counter(item.qty, (q) => setServiceQty(item.uuid, q))}
|
|
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, fontWeight: 500, color: '#3b3b3b' }}>قیمت: {formatRial(item.price * item.qty)}</span>
|
|
</div>
|
|
))}
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginTop: 16 }}>مجموع قیمت خدمات: {formatRial(servicesTotal)}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* پرسنل */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<span style={fieldLabel}>پرسنل</span>
|
|
<SearchableSelect inputId="staff-select" options={staffOptions} value={staffUuid} onChange={v => setStaffUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
|
|
</div>
|
|
|
|
{/* انتخاب کالای مصرفی */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', display: 'block', marginBottom: 8 }}>انتخاب کالای مصرفی</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{ width: 313, maxWidth: '60%' }}>
|
|
<SearchableSelect inputId="consumable-select" options={consumableOptions} value={consumableUuid} onChange={v => setConsumableUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
|
<FilesServiceAddCard color="#6B7280" />
|
|
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>
|
|
قیمت: {currentConsumable ? formatRial(currentConsumable.price) : '—'}
|
|
</span>
|
|
</div>
|
|
<button type="button" aria-label="افزودن کالا" onClick={addConsumable} disabled={!consumableUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: '#5559CE', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<PlusIcon style={{ width: 18, color: '#fff' }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* کالاهای انتخاب شده — باکس خطچین tauri */}
|
|
<div style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginBottom: 16 }}>کالاهای انتخاب شده</span>
|
|
{selectedConsumables.length === 0 ? (
|
|
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>کالایی انتخاب نشده است</span>
|
|
) : selectedConsumables.map(item => (
|
|
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', minWidth: 120 }}>{item.name}</span>
|
|
{counter(item.qty, (q) => setConsumableQty(item.uuid, q))}
|
|
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, fontWeight: 500, color: '#3b3b3b' }}>قیمت: {formatRial(item.price * item.qty)}</span>
|
|
</div>
|
|
))}
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginTop: 16 }}>مجموع قیمت کالاها: {formatRial(consumablesTotal)}</span>
|
|
</div>
|
|
|
|
{/* انتخاب پکیج */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<span style={fieldLabel}>انتخاب پکیج</span>
|
|
<SearchableSelect inputId="package-select" options={packageOptions} value={packageUuid} onChange={v => setPackageUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
|
|
</div>
|
|
|
|
{/* قیمت ویزیت */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<span style={fieldLabel}>
|
|
قیمت ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
|
|
</span>
|
|
<input
|
|
className="input" type="text" inputMode="numeric" dir="ltr" aria-label="قیمت ویزیت"
|
|
aria-invalid={!!visitPriceError}
|
|
value={visitPrice}
|
|
onChange={(e) => { setVisitPrice(digitsOnly(e.target.value)); setVisitPriceError(''); }}
|
|
/>
|
|
{visitPriceError && (
|
|
<span style={{ fontSize: 12, color: 'var(--danger)', display: 'block', marginTop: 4 }}>{visitPriceError}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* بیمه — منطق موجود NewSessionPage؛ فقط وقتی سرویس تحت پوشش یا بیمار بیمه دارد */}
|
|
{showInsurance && (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<span style={fieldLabel}>بیمه</span>
|
|
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود؛ وگرنه همان نوعِ فعال. */}
|
|
{needsCategoryChoice && (
|
|
<div style={{ marginBottom: 12 }}>
|
|
<span style={fieldLabel}>نوع خدمت</span>
|
|
<SearchableSelect
|
|
inputId="service-category-select"
|
|
options={enabledCategories.map(c => ({ value: c.key, label: c.label }))}
|
|
value={serviceCategory || null}
|
|
onChange={v => applyServiceCategory(v ? String(v) : '')}
|
|
placeholder="انتخاب نوع خدمت"
|
|
isClearable
|
|
/>
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
|
<div>
|
|
<span style={fieldLabel}>بیمه پایه</span>
|
|
<SearchableSelect inputId="base-insurance-select" options={baseOpts} value={baseId} onChange={v => applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable />
|
|
</div>
|
|
<div>
|
|
<span style={fieldLabel}>بیمه تکمیلی</span>
|
|
<SearchableSelect inputId="supp-insurance-select" options={suppOpts} value={suppId} onChange={v => applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable />
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div>
|
|
<span style={fieldLabel}>تخفیف بیمه پایه (%)</span>
|
|
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(digitsOnly(e.target.value, 3))} />
|
|
</div>
|
|
<div>
|
|
<span style={fieldLabel}>تخفیف تکمیلی (%)</span>
|
|
<input className="input" type="text" inputMode="numeric" dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(digitsOnly(e.target.value, 3))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* یادداشت */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<span style={fieldLabel}>یادداشت</span>
|
|
<textarea className="input" rows={3} dir="rtl" placeholder="یادداشت پزشک..." value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
</div>
|
|
|
|
{/* خلاصه مبلغ */}
|
|
<div className="dark:border-[#404040]" style={{ borderTop: '1px solid var(--border)', paddingTop: 12, marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>ویزیت</span><span>{formatRial(afterSupp)}</span></div>
|
|
{servicesTotal > 0 && <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>سهم بیمار خدمات</span><span>{formatRial(servicesPatient)}</span></div>}
|
|
{consumablesTotal > 0 && <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>کالاهای مصرفی</span><span>{formatRial(consumablesTotal)}</span></div>}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 15, fontWeight: 700 }}>
|
|
<span>مبلغ نهایی (سهم بیمار)</span>
|
|
<span style={{ color: 'var(--primary)' }}>{formatRial(finalPrice)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ACTIONS — tauri: انصراف / ایجاد سرویس */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 16, width: '100%', maxWidth: 340, margin: '16px auto 0' }}>
|
|
<button type="button" style={{ ...ghostBtn, width: 164 }} onClick={onCancel}>انصراف</button>
|
|
<button type="button" style={{ ...primaryBtn, width: 164 }} onClick={submit} disabled={createMut.isPending}>
|
|
{createMut.isPending ? 'در حال ذخیره...' : (isEdit ? 'ذخیره تغییرات' : 'ایجاد سرویس')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|