ui phase 8

This commit is contained in:
hamid
2026-07-19 13:57:11 +03:30
parent edc38543fd
commit 1ef4feb911
41 changed files with 2113 additions and 667 deletions
@@ -2,30 +2,21 @@
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, ErrorState, PriceDisplay, StepperHeader } from '@/components';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components';
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,
@@ -36,6 +27,8 @@ interface VariantBuilderProps {
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';
@@ -55,7 +48,7 @@ const MAX_DURATION_DIGITS = 4;
* **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 }) => {
const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDone, onCancel, onEditExisting }) => {
const t = useTranslations('services');
const tCatalog = useTranslations('catalog');
const tc = useTranslations('common');
@@ -92,6 +85,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
// 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;
@@ -188,6 +196,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
);
};
// Step 3'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 ?? '',
categoryNameEn: selectedCategory?.nameEn ?? '',
price: irr ?? '0',
priceUnit,
sessionCount,
displayName: displayValue || t('preview_untitled'),
isActive: true,
options: [],
};
const priceStep = (
<Stack sx={{ gap: 2.5 }}>
<TextField
@@ -230,14 +253,17 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack>
{irr ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}>
<PriceDisplay price={irr} priceUnit={priceUnit} sessionCount={sessionCount} showEstimate />
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</Paper>
</Stack>
) : null}
<TextField
@@ -249,21 +275,28 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
/>
{duplicate ? (
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-warning)',
}}
>
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 500 }}>
{t('duplicate_warning')}
</Typography>
</Paper>
<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>
);
@@ -423,20 +456,25 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
}}
/>
</Stack>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={selectedOptions[group.id] ?? null}
onChange={(_event, valueId: number | null) => changeOption(group.id, valueId)}
sx={{ flexWrap: 'wrap' }}
>
{group.values.map((value) => (
<ToggleButton key={value.id} value={value.id}>
{pickCatalogName(value, locale)}
</ToggleButton>
))}
</ToggleButtonGroup>
<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)}
onClick={() => changeOption(group.id, selected ? null : value.id)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
);
})