560 lines
23 KiB
TypeScript
560 lines
23 KiB
TypeScript
'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;
|