Files
baya-monorepo/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx
T
2026-07-27 23:58:16 +03:30

560 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<number, number>;
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<VariantBuilderProps> = ({ 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<StepKey>(isEdit ? 'price' : 'category');
const [duplicate, setDuplicate] = useState(false);
const form = useForm<VariantFormValues>({
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<StepKey, string> = {
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 = (
<Stack sx={{ gap: 2.5 }}>
<FormSection title={t('section_recap_title')} description={t('section_recap_description')} icon="category">
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{selectedCategory
? pickCatalogName(selectedCategory, locale)
: initial
? pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)
: ''}
</Typography>
{isEdit ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
) : null}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{recapChips.length > 0 ? (
recapChips.map((chip) => (
<Chip key={chip.key} size="small" label={chip.label} sx={{ bgcolor: 'var(--bal-primary-soft)' }} />
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</FormSection>
<FormSection title={t('section_price_title')} description={t('price_hint')} icon="earnings">
<RhfTextField<VariantFormValues>
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
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<VariantFormValues> name="priceUnit" select label={t('unit_label')} fullWidth>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</RhfTextField>
<RhfTextField<VariantFormValues>
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
/>
</Stack>
{!sessionCount && irr ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</FormSection>
<FormSection title={t('section_listing_title')} description={t('display_name_hint')} icon="services">
{/* 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. */}
<Controller
control={control}
name="displayNameOverride"
render={({ field }) => (
<TextField
label={t('display_name_label')}
name={field.name}
inputRef={field.ref}
value={field.value ?? autoName}
onChange={(event) => field.onChange(event.target.value)}
onBlur={field.onBlur}
fullWidth
/>
)}
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
{/* 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. */}
<VariantCard variant={previewVariant} interactive={false} />
</Stack>
</FormSection>
{duplicate ? (
<AccentCard tone="warning" padding="sm">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
{/* Warning is reserved for the edge + icon; the body reads as normal text, not amber-on-paper. */}
<Typography variant="body2" sx={{ color: 'text.primary', fontWeight: 500 }}>
{t('duplicate_warning')}
</Typography>
</Stack>
{existingMatch ? (
<AppButton
variant="text"
color="primary"
startIcon="edit"
onClick={() => onEditExisting(existingMatch)}
sx={{ alignSelf: 'flex-start' }}
>
{t('duplicate_edit_existing')}
</AppButton>
) : null}
</Stack>
</AccentCard>
) : null}
</Stack>
);
return (
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<PageHeader title={isEdit ? t('builder_edit_title') : t('builder_add_title')} />
{isEdit ? null : (
<StepperHeader
steps={visibleSteps.map((key) => stepLabels[key])}
activeStep={visibleSteps.indexOf(effectiveStep)}
/>
)}
{effectiveStep === 'category' ? (
<FormSection title={t('category_title')} description={t('category_subtitle')} icon="category">
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => categoriesQuery.refetch()} />
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</FormSection>
) : null}
{effectiveStep === 'options' ? (
<FormSection title={t('options_title')} description={t('options_subtitle')} icon="tune">
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : 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.
<ErrorState message={t('options_error')} retryLabel={tc('retry')} onRetry={() => optionGroupsQuery.refetch()} />
) : (
groups.map((group) => (
<Stack key={group.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pickCatalogName(group, locale)}
</Typography>
<Chip
size="small"
label={group.isRequired ? t('required_badge') : t('optional_badge')}
sx={{
bgcolor: group.isRequired ? 'var(--bal-primary-soft)' : 'var(--bal-divider)',
color: group.isRequired ? 'var(--bal-primary)' : 'text.secondary',
fontWeight: 500,
}}
/>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{group.values.map((value) => {
const selected = selectedOptions[group.id] === value.id;
return (
<Chip
key={value.id}
label={pickCatalogName(value, locale)}
aria-pressed={selected}
clickable
color={selected ? 'primary' : 'default'}
variant={selected ? 'filled' : 'outlined'}
onClick={() => changeOption(group.id, selected ? null : value.id)}
/>
);
})}
</Stack>
</Stack>
))
)}
</FormSection>
) : 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 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('options_missing_named', {
groups: missingRequiredGroups.map((group) => pickCatalogName(group, locale)).join('، '),
})}
</Typography>
) : null}
<SurfaceCard padding="sm" sx={{ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)', zIndex: 1 }}>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
<AppButton
variant="text"
onClick={effectiveStep === 'category' || isEdit ? onCancel : goBack}
disabled={submitting}
>
{effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')}
</AppButton>
{effectiveStep === 'price' ? (
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
{submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')}
</AppButton>
) : (
<AppButton
color="primary"
variant="contained"
onClick={goNext}
disabled={
effectiveStep === 'category'
? categoryId == null
: optionGroupsQuery.isError || missingRequiredGroups.length > 0
}
>
{t('next')}
</AppButton>
)}
</Stack>
</SurfaceCard>
</Box>
</FormProvider>
);
};
export default VariantBuilder;