'use client'; import { FunctionComponent, useMemo, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; import { Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, FormSection, PageHeader, RhfTextField, StepperHeader, SurfaceCard, VariantCard, } from '@/components'; import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ApiError } from '@/lib/api/errors'; import { digitsOnly, rialToToman, tomanToRial } from '@/utils'; import { useCategoryOptionGroups, useCreateVariant, useMyVariants, useServiceCategories, useUpdateVariant, } from '@/services/catalog'; import { pickCatalogName } from '@/services/catalog/names'; import { PRICE_UNITS, optionSetSignature, type NurseServiceVariant, type PriceUnit, type VariantOptionSelection, } from '@/services/catalog/types'; interface VariantBuilderProps { /** `null` = create (stepped flow); a variant = edit (category/options locked, price form only). */ initial: NurseServiceVariant | null; onDone: () => void; onCancel: () => void; /** Jump straight into editing an already-existing listing — the create step's 409-duplicate recovery. */ onEditExisting: (variant: NurseServiceVariant) => void; } const DEFAULT_UNIT: PriceUnit = 'per_hour'; const MAX_PRICE_DIGITS = 12; const MAX_DURATION_DIGITS = 4; type StepKey = 'category' | 'options' | 'price'; interface VariantFormValues { categoryId: number | null; /** Option-group id → chosen value id. One field, so a category switch clears the whole answer set. */ options: Record; priceToman: string; priceUnit: PriceUnit; duration: string; /** `null` until the nurse types over the auto-generated name — blank means "let the server name it". */ displayNameOverride: string | null; } /** * The nurse variant builder (`CreateVariant` / `UpdateVariant`). * * **Create** walks category → options → price. Two things make the flow deterministic where it * previously was not: * * 1. **A step that has nothing to ask is not shown.** Categories with no option groups used to get a * middle step whose entire content was "این دسته گزینه‌ای برای تنظیم ندارد" plus a Next button. * The step list is now derived from the loaded groups, so those categories go straight to pricing. * 2. **Advancing is gated *before* the tap, not after it.** The old Next button was always enabled and * surfaced an error only once pressed, from a separate `optionsError` flag. Now the unanswered * required groups are named under the button while it is disabled, so the blocker is visible * without probing for it. * * The final step is a review as well as a form: the chosen category and options are recapped as chips * beside the live `VariantCard`, so the listing can be checked without stepping backwards. * * Money still crosses the field boundary exactly once — the price is entered in **Toman** and * converted to an IRR digit-string via `tomanToRial` (integer-safe, never a float); the estimated * total is shown only from `price` × `sessionCount`, never `price` alone. A duplicate identical * listing (`409`) shows a friendly inline warning that offers to edit the colliding listing instead. * * **Edit** locks the category + option-set (changing them would change identity) and edits only * price/unit/duration/display via `update`. */ const VariantBuilder: FunctionComponent = ({ initial, onDone, onCancel, onEditExisting }) => { const t = useTranslations('services'); const tCatalog = useTranslations('catalog'); const tc = useTranslations('common'); const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); const isEdit = initial !== null; const createVariant = useCreateVariant(); const updateVariant = useUpdateVariant(); const submitting = createVariant.isPending || updateVariant.isPending; const [step, setStep] = useState(isEdit ? 'price' : 'category'); const [duplicate, setDuplicate] = useState(false); const form = useForm({ mode: 'onTouched', defaultValues: { categoryId: initial?.serviceCategoryId ?? null, options: {}, // Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10. priceToman: initial ? String(rialToToman(initial.price)) : '', priceUnit: initial?.priceUnit ?? DEFAULT_UNIT, duration: initial?.sessionCount ? String(initial.sessionCount) : '', displayNameOverride: null, }, }); const { control, handleSubmit, setValue } = form; const categoryId = useWatch({ control, name: 'categoryId' }); const selectedOptions = useWatch({ control, name: 'options' }); const priceToman = useWatch({ control, name: 'priceToman' }); const priceUnit = useWatch({ control, name: 'priceUnit' }); const duration = useWatch({ control, name: 'duration' }); const displayNameOverride = useWatch({ control, name: 'displayNameOverride' }); const categoriesQuery = useServiceCategories(); const categories = categoriesQuery.data?.items ?? []; const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId); // Stable reference so the auto-name useMemo below isn't invalidated on every render by a fresh `[]`. const groups = useMemo(() => optionGroupsQuery.data ?? [], [optionGroupsQuery.data]); const selectedCategory = categories.find((category) => category.id === categoryId) ?? null; const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null); // A category with no option groups has no question to ask, so its step doesn't exist. Until the // groups for the chosen category have actually loaded the answer is unknown, and the assumption is // "there is an options step" — that way the step count only ever collapses for a category proven to // have none, instead of starting at two and growing the moment a category is tapped. const optionsStepUnknown = categoryId == null || optionGroupsQuery.isLoading || optionGroupsQuery.isFetching; const hasOptionsStep = !isEdit && (optionsStepUnknown || groups.length > 0); const effectiveStep: StepKey = step === 'options' && !hasOptionsStep ? 'price' : step; const visibleSteps: StepKey[] = hasOptionsStep ? ['category', 'options', 'price'] : ['category', 'price']; const stepLabels: Record = { category: t('step_category'), options: t('step_options'), price: t('step_price'), }; // Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which // existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly. const myVariantsQuery = useMyVariants(); const existingMatch = useMemo(() => { if (isEdit || categoryId == null) return null; const signature = optionSetSignature(categoryId, Object.values(selectedOptions)); return ( (myVariantsQuery.data?.items ?? []).find( (variant) => variant.serviceCategoryId === categoryId && optionSetSignature(categoryId, variant.options.map((option) => option.optionValueId)) === signature, ) ?? null ); }, [isEdit, categoryId, selectedOptions, myVariantsQuery.data]); // Auto-generated display name preview (create): category + chosen value labels, in the active locale. const autoName = useMemo(() => { if (isEdit) return initial.displayName; if (!selectedCategory) return ''; const valueLabels = groups .map((group) => { const valueId = selectedOptions[group.id]; const value = valueId == null ? null : group.values.find((candidate) => candidate.id === valueId); return value ? pickCatalogName(value, locale) : null; }) .filter((label): label is string => label !== null); return [pickCatalogName(selectedCategory, locale), ...valueLabels].join(' · '); }, [isEdit, initial, selectedCategory, groups, selectedOptions, locale]); const displayValue = displayNameOverride ?? autoName; const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0); const irr = priceValid ? tomanToRial(priceToman) : null; const durationInt = duration ? Number(duration) : 0; const sessionCount = durationInt > 0 ? durationInt : null; const selectCategory = (id: number) => { if (id === categoryId) return; // Switching category invalidates the previous category's option answers + auto-name. setValue('categoryId', id, { shouldDirty: true }); setValue('options', {}); setValue('displayNameOverride', null); }; const changeOption = (groupId: number, valueId: number | null) => { const next = { ...selectedOptions }; if (valueId == null) delete next[groupId]; else next[groupId] = valueId; // A manual displayName override is intentionally left untouched; the auto-name preview tracks // option changes only while the field hasn't been overridden (displayValue = override ?? autoName). setValue('options', next, { shouldDirty: true }); }; const goNext = () => { if (effectiveStep === 'category') { setStep(hasOptionsStep ? 'options' : 'price'); return; } setStep('price'); }; const goBack = () => { if (effectiveStep === 'price' && !isEdit) { setStep(hasOptionsStep ? 'options' : 'category'); return; } setStep('category'); }; const submit = (values: VariantFormValues) => { // `handleSubmit` has already enforced the price rule; this is the type narrowing that lets the // IRR string be passed on, not a second gate. if (irr == null) return; const displayName = values.displayNameOverride?.trim() ? values.displayNameOverride.trim() : undefined; if (isEdit) { updateVariant.mutate( { id: initial.id, input: { price: irr, priceUnit: values.priceUnit, sessionCount, displayName } }, { onSuccess: () => { enqueueSnackbar(t('saved_toast'), { variant: 'success' }); onDone(); }, onError: () => enqueueSnackbar(t('create_error'), { variant: 'error' }), }, ); return; } const options: VariantOptionSelection[] = Object.entries(values.options).map(([groupId, valueId]) => ({ optionGroupId: Number(groupId), optionValueId: valueId, })); createVariant.mutate( { serviceCategoryId: values.categoryId as number, options, price: irr, priceUnit: values.priceUnit, sessionCount, displayName, }, { onSuccess: () => { enqueueSnackbar(t('created_toast'), { variant: 'success' }); onDone(); }, onError: (error) => { // The duplicate-listing conflict is a friendly inline warning, never a generic toast. if (error instanceof ApiError && error.status === 409) setDuplicate(true); else enqueueSnackbar(t('create_error'), { variant: 'error' }); }, }, ); }; // The price step's live preview — the actual VariantCard, so the nurse sees the listing they're // composing, not just an abstract price readout. const previewVariant: NurseServiceVariant = { id: 0, serviceCategoryId: categoryId ?? 0, categoryNameFa: selectedCategory?.nameFa ?? initial?.categoryNameFa ?? '', categoryNameEn: selectedCategory?.nameEn ?? initial?.categoryNameEn ?? '', price: irr ?? '0', priceUnit, sessionCount, displayName: displayValue || t('preview_untitled'), isActive: true, options: [], }; /** Category + chosen options, so the last step doubles as a review of the first two. */ const recapChips = isEdit ? initial.options.map((option) => ({ key: String(option.optionGroupId), label: `${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`, })) : groups.flatMap((group) => { const value = group.values.find((candidate) => candidate.id === selectedOptions[group.id]); return value ? [{ key: String(group.id), label: `${pickCatalogName(group, locale)}: ${pickCatalogName(value, locale)}` }] : []; }); const priceStep = ( {selectedCategory ? pickCatalogName(selectedCategory, locale) : initial ? pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale) : ''} {isEdit ? ( {t('category_locked')} ) : null} {recapChips.length > 0 ? ( recapChips.map((chip) => ( )) ) : ( {t('summary_none')} )} name="priceToman" label={t('price_label')} transform={(raw) => digitsOnly(raw).slice(0, MAX_PRICE_DIGITS)} rules={{ validate: (value) => { const raw = String(value ?? ''); return (raw.length > 0 && BigInt(raw) > BigInt(0)) || t('price_required'); }, }} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} fullWidth /> name="priceUnit" select label={t('unit_label')} fullWidth> {PRICE_UNITS.map((unit) => ( {tCatalog(`unit_${unit}`)} ))} name="duration" label={t('duration_label')} helperText={t('duration_hint')} transform={(raw) => digitsOnly(raw).slice(0, MAX_DURATION_DIGITS)} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} fullWidth /> {!sessionCount && irr ? ( {t('rate_note')} ) : null} {/* Not `RhfTextField`: what is *stored* is the override alone (blank ⇒ the server generates the name), while what is *shown* falls back to the live auto-generated name. One field, two values — the one case in this form where the display value isn't the form value. */} ( field.onChange(event.target.value)} onBlur={field.onBlur} fullWidth /> )} /> {t('preview_heading')} {/* Shown even before a price is entered — the preview is what "comprehensive" means here: the nurse should see the shape of the listing while composing it, not only once it's valid. */} {duplicate ? ( {/* Warning is reserved for the edge + icon; the body reads as normal text, not amber-on-paper. */} {t('duplicate_warning')} {existingMatch ? ( onEditExisting(existingMatch)} sx={{ alignSelf: 'flex-start' }} > {t('duplicate_edit_existing')} ) : null} ) : null} ); return ( {isEdit ? null : ( stepLabels[key])} activeStep={visibleSteps.indexOf(effectiveStep)} /> )} {effectiveStep === 'category' ? ( {categoriesQuery.isLoading ? ( {[0, 1, 2, 3].map((key) => ( ))} ) : categoriesQuery.isError ? ( categoriesQuery.refetch()} /> ) : ( {categories.map((category) => ( selectCategory(category.id)} /> ))} )} ) : null} {effectiveStep === 'options' ? ( {optionGroupsQuery.isLoading ? ( ) : optionGroupsQuery.isError ? ( // A failed fetch must never read as "this category has zero options" — that would let the // nurse skip required options entirely. Block progression until the retry succeeds. optionGroupsQuery.refetch()} /> ) : ( groups.map((group) => ( {pickCatalogName(group, locale)} {group.values.map((value) => { const selected = selectedOptions[group.id] === value.id; return ( changeOption(group.id, selected ? null : value.id)} /> ); })} )) )} ) : null} {effectiveStep === 'price' ? priceStep : null} {/* Names what is still missing while the button is disabled, instead of revealing it on tap. */} {effectiveStep === 'options' && missingRequiredGroups.length > 0 ? ( {t('options_missing_named', { groups: missingRequiredGroups.map((group) => pickCatalogName(group, locale)).join('، '), })} ) : null} {effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')} {effectiveStep === 'price' ? ( {submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')} ) : ( 0 } > {t('next')} )} ); }; export default VariantBuilder;