import { useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ShieldCheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { ServiceItem, ClinicStaff } from '../types'; import type { InventoryPackage, InventoryItem } from '../hooks/useInventory'; import { rialToToman, tomanToRial } from '../lib/utils'; import { numericField } from '../lib/forms'; import Modal from './ui/Modal'; import PriceInput from './ui/PriceInput'; import SearchableSelect from './ui/SearchableSelect'; const itemSchema = z.object({ name: z.string().min(1, 'نام سرویس الزامی است'), price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'), staff_uuids: z.array(z.string()).optional(), duration_minutes: z.coerce.number().min(0).optional(), bookable: z.boolean().optional(), /** پکیج کالای مصرفی؛ رشته‌ی خالی یعنی بدون پکیج. */ inventory_package_uuid: z.string().optional(), /** اقلام کالای تکی — مستقل از پکیج. */ consumables: z.array(z.object({ item_uuid: z.string(), amount: z.coerce.number().min(1) })).optional(), }); type ItemForm = z.infer; const EMPTY_FORM: ItemForm = { name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined, bookable: false, inventory_package_uuid: '', consumables: [], }; interface Props { /** `'create'` برای سرویس جدید، شیء سرویس برای ویرایش، `null` یعنی بسته. */ item: 'create' | ServiceItem | null; /** بخش مقصد؛ برای حالت ایجاد الزامی است. */ sectionUuid: string | null; onClose: () => void; /** برای باز کردن مودال پوشش بیمه از داخل فرم (فقط در حالت ویرایش). */ onManageInsurance?: (item: ServiceItem) => void; } /** * فرم ایجاد/ویرایش سرویس — مشترک بین فهرست سرویس‌ها و صفحه‌ی جزئیات سرویس. * * تنظیمات بیمه اینجا نیست: پوشش هر بیمه‌گر تنها در «پوشش بیمه» مدیریت می‌شود و * پرچم `insurance_covered` سمت سرور از همان‌جا همگام می‌شود. */ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onManageInsurance }: Props) { const qc = useQueryClient(); const editing = item !== null && typeof item === 'object' ? item : null; const { data: staffData } = useQuery>({ queryKey: ['staff'], queryFn: () => api.get('/api/v1/staff'), enabled: item !== null, }); const allStaff = staffData?.data ?? []; const { data: packagesData } = useQuery>({ queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'), enabled: item !== null, }); const packages = packagesData?.data ?? []; // این endpoint پاسخ را در { items, stats } می‌پیچد. const { data: inventoryData } = useQuery>({ queryKey: ['inventory-items'], queryFn: () => api.get('/api/v1/inventory-items'), enabled: item !== null, }); const inventoryItems = inventoryData?.data?.items ?? []; const form = useForm({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM }); useEffect(() => { if (item === null) return; form.reset(editing ? { name: editing.name, price_rials: rialToToman(editing.price_rials), staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid), duration_minutes: editing.duration_minutes ?? undefined, bookable: editing.bookable ?? false, inventory_package_uuid: editing.inventory_package_uuid ?? '', consumables: (editing.consumables ?? []).map((c) => ({ item_uuid: c.item_uuid, amount: c.amount })), } : EMPTY_FORM); }, [item]); const invalidate = () => { qc.invalidateQueries({ queryKey: ['service-items'] }); qc.invalidateQueries({ queryKey: ['service-sections'] }); if (editing) qc.invalidateQueries({ queryKey: ['service-item', editing.uuid] }); }; // رشته‌ی خالی در payload یعنی «بدون پکیج»؛ بک‌اند null می‌خواهد. const toPayload = (body: ItemForm) => ({ ...body, price_rials: tomanToRial(body.price_rials), inventory_package_uuid: body.inventory_package_uuid || null, }); const createItem = useMutation({ mutationFn: (body: ItemForm) => api.post('/api/v1/service-item', { ...toPayload(body), section_uuid: sectionUuid, }), onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ایجاد شد'); }, onError: (e: Error) => toast.error(e.message), }); const editItem = useMutation({ mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, toPayload(body)), onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ویرایش شد'); }, onError: (e: Error) => toast.error(e.message), }); const saving = createItem.isPending || editItem.isPending; const selectedStaffUuids = form.watch('staff_uuids') ?? []; const editingMembers = editing ? (editing.staff_members ?? (editing.staff ? [editing.staff] : [])) : []; const staffOptions = allStaff .filter((s) => s.active || editingMembers.some((m) => m.uuid === s.uuid)) .filter((s) => !selectedStaffUuids.includes(s.uuid)) .map((s) => ({ value: s.uuid, label: s.active ? s.full_name : `${s.full_name} (غیرفعال)` })); const consumables = form.watch('consumables') ?? []; const inventoryOptions = inventoryItems .filter((i) => !consumables.some((c) => c.item_uuid === i.uuid)) .map((i) => ({ value: i.uuid, label: `${i.name} (${i.unit})` })); const inventoryNameOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.name ?? uuid; const inventoryUnitOf = (uuid: string) => inventoryItems.find((i) => i.uuid === uuid)?.unit ?? ''; const addConsumable = (uuid: string) => form.setValue('consumables', [...consumables, { item_uuid: uuid, amount: 1 }]); const staffNameOf = (uuid: string) => allStaff.find((s) => s.uuid === uuid)?.full_name ?? editingMembers.find((m) => m.uuid === uuid)?.full_name ?? uuid; return ( } >
(editing ? editItem : createItem).mutate(d))} style={{ display: 'flex', flexDirection: 'column', gap: 20 }} >
{form.formState.errors.name && ( {form.formState.errors.name.message} )}
form.setValue('price_rials', v)} placeholder="۸۵,۰۰۰" min={0} suffix="تومان" />
{form.formState.errors.price_rials && ( {form.formState.errors.price_rials.message} )}
{ if (v != null) form.setValue('staff_uuids', [...selectedStaffUuids, String(v)]); }} placeholder="افزودن پرسنل (اختیاری)" noOptionsMessage="پرسنلی باقی نمانده" height={42} /> {selectedStaffUuids.length > 0 && (
{selectedStaffUuids.map((uuid) => ( {staffNameOf(uuid)} ))}
)}
({ value: p.uuid, label: p.title }))} value={form.watch('inventory_package_uuid') || null} onChange={(v) => form.setValue('inventory_package_uuid', v == null ? '' : String(v))} placeholder="بدون پکیج (اختیاری)" noOptionsMessage="پکیجی تعریف نشده است" isClearable height={42} />
{/* کالای تکی — مستقل از پکیج و قابل استفاده هم‌زمان با آن. */}
{ if (v != null) addConsumable(String(v)); }} placeholder="افزودن کالا (اختیاری)" noOptionsMessage="کالایی باقی نمانده" height={42} /> {consumables.length > 0 && (
{consumables.map((line, i) => (
{inventoryNameOf(line.item_uuid)} {inventoryUnitOf(line.item_uuid)}
))}
)}
{/* بیمه — تنظیمات فقط در «پوشش بیمه» مدیریت می‌شود تا داده‌ی تکراری ساخته نشود. */}
پوشش بیمه‌ی این خدمت — درصد، فرانشیز و سقف هر بیمه‌گر — در بخش «پوشش بیمه» تنظیم می‌شود. {editing && onManageInsurance && ( )}
); }