- Refactor color palette in `ui-design-spec.md` to utilize CSS variables exclusively, eliminating fixed hex values and Tailwind utility classes. - Complete dark mode implementation in `uiStore.ts`, ensuring proper theme application via `applyTheme()` and `applyBrand()`. - Create `admin-theme-dark-light-audit.md` to document the transition process, outlining issues with inline styles and fixed colors. - Introduce `theme-tokens.test.ts` to enforce rules against fixed hex colors and ensure compliance with the design system. - Update various components and styles to replace inline styles and fixed colors with CSS variables, ensuring consistent theming across light and dark modes. - Ensure all changes maintain visual integrity in both light and dark modes, with a focus on accessibility and contrast standards.
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: '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 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 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>
|
|
<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 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>
|
|
);
|
|
}
|