The encounter edit form asked for contracts in the panel's own environment, so inside a clinic it got an empty list: no insurance to choose, and the one already recorded on the encounter had no matching option to display. It now scopes the request by the encounter's doctor, the same way the confirm modal does, which is why the session payload's existing doctor_uuid is threaded through. Saving the form had the matching defect on the server. Recalculating the totals looked up the coverage percentage against the record's tenant rather than the contract's, so a single save on a correctly-billed encounter zeroed the insurance share and moved the whole amount back onto the patient. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
572 lines
35 KiB
TypeScript
572 lines
35 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } 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 PriceInput from '../ui/PriceInput';
|
|
import PersianDateInput from '../ui/PersianDateInput';
|
|
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
|
|
import { useAuthStore } from '../../stores/authStore';
|
|
import { digitsOnly, todayIso } 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_percent: 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 = todayIso;
|
|
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: 'var(--text-2)', display: 'block' };
|
|
const primaryBtn: React.CSSProperties = { background: 'var(--primary)', color: 'var(--on-primary)', border: 'none', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' };
|
|
const ghostBtn: React.CSSProperties = { background: 'transparent', color: 'var(--primary)', border: '1px solid var(--primary)', 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 qc = useQueryClient();
|
|
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 insuranceScope = editSession?.doctor_uuid
|
|
? `?doctor_uuid=${encodeURIComponent(editSession.doctor_uuid)}&inherit=1`
|
|
: '';
|
|
|
|
const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({
|
|
queryKey: ['tenant-insurances', editSession?.doctor_uuid ?? null],
|
|
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${insuranceScope}`),
|
|
});
|
|
const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({
|
|
queryKey: ['insurance-pricing', editSession?.doctor_uuid ?? null],
|
|
queryFn: () => api.get(`/api/v1/insurance-pricing${insuranceScope}`),
|
|
});
|
|
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_percent ?? contract.franchise_percent) : 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) => {
|
|
// بدون این، صفحهٔ پرونده و فهرست پرداختها نسخهٔ کششده را نشان میدادند و
|
|
// وضعیت مراجعه تا رفرشِ دستی عوض نمیشد. صورتحساب هم سمت سرور با همین ویرایش
|
|
// همتراز میشود، پس آمار پرداختها هم کهنه میماند.
|
|
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
|
|
qc.invalidateQueries({ queryKey: ['patient-detail', recordUuid] });
|
|
qc.invalidateQueries({ queryKey: ['patient', recordUuid] });
|
|
qc.invalidateQueries({ queryKey: ['patient-invoices', recordUuid] });
|
|
qc.invalidateQueries({ queryKey: ['payments'] });
|
|
qc.invalidateQueries({ queryKey: ['payments-summary'] });
|
|
|
|
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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 74, height: 27, border: '1px solid var(--border-2)', borderRadius: 8, padding: '0 4px', gap: 4 }}>
|
|
<button type="button" aria-label="افزایش" onClick={() => set(qty + 1)} style={{ border: 'none', background: 'transparent', color: 'var(--accent)', cursor: 'pointer', display: 'flex' }}>
|
|
<PlusIcon style={{ width: 16 }} />
|
|
</button>
|
|
<span style={{ fontSize: 13, color: 'var(--text)', minWidth: 20, textAlign: 'center' }}>{qty}</span>
|
|
<button type="button" aria-label="کاهش" onClick={() => set(qty - 1)} style={{ border: 'none', background: 'transparent', color: 'var(--accent)', cursor: 'pointer', display: 'flex' }}>
|
|
{qty > 1 ? <MinusIcon style={{ width: 16 }} /> : <TrashRed size={16} color="var(--accent)" />}
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div style={{ marginTop: 16 }}>
|
|
{/* پذیرش کننده — tauri UserTick + کاربر لاگینشده */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 28 }}>
|
|
<UserTick color="var(--text-2)" size={18} />
|
|
<span style={{ fontSize: 14, color: 'var(--text-2)' }}>پذیرش کننده: {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-[var(--surface)]" style={{ display: 'flex', alignItems: 'center', gap: 8, height: 48, border: '1px solid var(--border-2)', borderRadius: 4, padding: '0 8px' }}>
|
|
<ClockP color="var(--text-2)" 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="var(--text-2)" />
|
|
<span style={{ fontSize: 13, color: 'var(--text)' }}>
|
|
قیمت: {currentItem ? formatRial(currentItem.price_rials) : '—'}
|
|
</span>
|
|
</div>
|
|
<button type="button" aria-label="افزودن سرویس" onClick={addService} disabled={!itemUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: 'var(--primary)', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 20 }}>
|
|
<PlusIcon style={{ width: 18, color: 'var(--on-primary)' }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* خدمات انتخاب شده */}
|
|
{selectedServices.length > 0 && (
|
|
<div style={{ border: '1px dashed var(--primary-soft2)', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', display: 'block', marginBottom: 16 }}>خدمات انتخاب شده</span>
|
|
{selectedServices.map(item => (
|
|
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
|
|
<span style={{ fontSize: 14, color: 'var(--text)', minWidth: 120 }}>{item.name}</span>
|
|
{counter(item.qty, (q) => setServiceQty(item.uuid, q))}
|
|
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text)' }}>قیمت: {formatRial(item.price * item.qty)}</span>
|
|
</div>
|
|
))}
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', 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 style={{ fontSize: 14, color: 'var(--text)', 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="var(--text-2)" />
|
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
|
قیمت: {currentConsumable ? formatRial(currentConsumable.price) : '—'}
|
|
</span>
|
|
</div>
|
|
<button type="button" aria-label="افزودن کالا" onClick={addConsumable} disabled={!consumableUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: 'var(--primary)', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<PlusIcon style={{ width: 18, color: 'var(--on-primary)' }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* کالاهای انتخاب شده — باکس خطچین tauri */}
|
|
<div style={{ border: '1px dashed var(--primary-soft2)', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', display: 'block', marginBottom: 16 }}>کالاهای انتخاب شده</span>
|
|
{selectedConsumables.length === 0 ? (
|
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>کالایی انتخاب نشده است</span>
|
|
) : selectedConsumables.map(item => (
|
|
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
|
|
<span style={{ fontSize: 14, color: 'var(--text)', minWidth: 120 }}>{item.name}</span>
|
|
{counter(item.qty, (q) => setConsumableQty(item.uuid, q))}
|
|
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text)' }}>قیمت: {formatRial(item.price * item.qty)}</span>
|
|
</div>
|
|
))}
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', 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>
|
|
<PriceInput
|
|
className="input"
|
|
ariaLabel="قیمت ویزیت"
|
|
ariaInvalid={!!visitPriceError}
|
|
value={visitPrice === '' ? '' : Number(visitPrice)}
|
|
onChange={(v) => { setVisitPrice(String(v)); 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 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>
|
|
);
|
|
}
|