blocker phase 6, catalog admin
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"payouts": "Payouts",
|
||||
"reviews": "Reviews",
|
||||
"config": "Configuration",
|
||||
"catalog": "Catalog",
|
||||
"holidays": "Holidays",
|
||||
"alerts": "Alerts",
|
||||
"audit": "Audit log",
|
||||
@@ -1687,6 +1688,45 @@
|
||||
"hol_bank_hint": "When on, payouts falling on this day shift to the next business day.",
|
||||
"hol_saved": "Holiday saved.",
|
||||
"hol_year": "Year",
|
||||
"status_active": "Active",
|
||||
"status_inactive": "Inactive",
|
||||
"cat_title": "Catalog",
|
||||
"cat_subtitle": "Categories and the pricing dimensions nurses build their offerings from.",
|
||||
"cat_empty": "No categories yet.",
|
||||
"cat_col_name": "Category",
|
||||
"cat_col_order": "Order",
|
||||
"cat_col_status": "Status",
|
||||
"cat_add": "Add category",
|
||||
"cat_edit": "Edit",
|
||||
"cat_activate": "Activate",
|
||||
"cat_deactivate": "Deactivate",
|
||||
"cat_activated": "Category activated.",
|
||||
"cat_deactivated": "Category deactivated.",
|
||||
"cat_saved": "Category saved.",
|
||||
"cat_not_found": "This category could not be found.",
|
||||
"cat_name_fa": "Name (Persian)",
|
||||
"cat_name_en": "Name (English)",
|
||||
"cat_description_fa": "Description (Persian)",
|
||||
"cat_description_en": "Description (English)",
|
||||
"cat_icon_key": "Icon key (optional)",
|
||||
"cat_icon_hint": "Matches a name in the icon registry; an unknown or missing key falls back gracefully.",
|
||||
"cat_sort_order": "Sort order",
|
||||
"og_section_title": "Pricing options",
|
||||
"og_empty": "No pricing dimensions yet.",
|
||||
"og_add": "Add dimension",
|
||||
"og_edit": "Edit",
|
||||
"og_cross_category_badge": "All categories",
|
||||
"og_required_badge": "Required",
|
||||
"og_optional_badge": "Optional",
|
||||
"og_scope_all": "Applies to all categories",
|
||||
"og_scope_all_hint": "A cross-category dimension is offered for every category, not just this one.",
|
||||
"og_scope_this_hint": "Offered only for this category.",
|
||||
"og_is_required": "Must be answered before booking",
|
||||
"og_saved": "Pricing dimension saved.",
|
||||
"og_value_add": "Add value",
|
||||
"og_value_edit": "Edit value",
|
||||
"og_value_saved": "Value saved.",
|
||||
"og_value_empty": "No values yet.",
|
||||
"alert_title": "Support alerts",
|
||||
"alert_subtitle": "Internal triage — never shown to customers or nurses.",
|
||||
"alert_empty": "No open alerts.",
|
||||
@@ -2104,6 +2144,7 @@
|
||||
"admin_tickets_sub": "The global ticket queue",
|
||||
"admin_alerts_sub": "The internal alert worklist",
|
||||
"admin_config_sub": "Config keys and their change history",
|
||||
"admin_catalog_sub": "Categories and their pricing dimensions",
|
||||
"admin_holidays_sub": "The bank-holiday calendar",
|
||||
"admin_audit_sub": "Read-only record of every change",
|
||||
"admin_partners_sub": "Partner centers and sponsored nurses",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"payouts": "تسویهها",
|
||||
"reviews": "نظرات",
|
||||
"config": "پیکربندی",
|
||||
"catalog": "کاتالوگ",
|
||||
"holidays": "تعطیلات",
|
||||
"alerts": "هشدارها",
|
||||
"audit": "گزارش ممیزی",
|
||||
@@ -1687,6 +1688,45 @@
|
||||
"hol_bank_hint": "در صورت فعال بودن، تسویههای این روز به روز کاری بعد منتقل میشوند.",
|
||||
"hol_saved": "تعطیلی ذخیره شد.",
|
||||
"hol_year": "سال",
|
||||
"status_active": "فعال",
|
||||
"status_inactive": "غیرفعال",
|
||||
"cat_title": "کاتالوگ خدمات",
|
||||
"cat_subtitle": "دستهبندیهای خدمات و ابعاد قیمتگذاری که پرستاران خدمات خود را بر اساس آنها میسازند.",
|
||||
"cat_empty": "هنوز دستهبندیای ثبت نشده است.",
|
||||
"cat_col_name": "دستهبندی",
|
||||
"cat_col_order": "ترتیب",
|
||||
"cat_col_status": "وضعیت",
|
||||
"cat_add": "افزودن دستهبندی",
|
||||
"cat_edit": "ویرایش",
|
||||
"cat_activate": "فعالسازی",
|
||||
"cat_deactivate": "غیرفعالسازی",
|
||||
"cat_activated": "دستهبندی فعال شد.",
|
||||
"cat_deactivated": "دستهبندی غیرفعال شد.",
|
||||
"cat_saved": "دستهبندی ذخیره شد.",
|
||||
"cat_not_found": "این دستهبندی پیدا نشد.",
|
||||
"cat_name_fa": "نام (فارسی)",
|
||||
"cat_name_en": "نام (انگلیسی)",
|
||||
"cat_description_fa": "توضیحات (فارسی)",
|
||||
"cat_description_en": "توضیحات (انگلیسی)",
|
||||
"cat_icon_key": "کلید آیکون (اختیاری)",
|
||||
"cat_icon_hint": "باید با یکی از نامهای ثبتشدهٔ آیکون مطابقت داشته باشد؛ کلید ناشناخته یا خالی بهصورت خودکار جایگزین میشود.",
|
||||
"cat_sort_order": "ترتیب نمایش",
|
||||
"og_section_title": "ابعاد قیمتگذاری",
|
||||
"og_empty": "هنوز بُعد قیمتگذاریای ثبت نشده است.",
|
||||
"og_add": "افزودن بُعد",
|
||||
"og_edit": "ویرایش",
|
||||
"og_cross_category_badge": "همهٔ دستهبندیها",
|
||||
"og_required_badge": "اجباری",
|
||||
"og_optional_badge": "اختیاری",
|
||||
"og_scope_all": "برای همهٔ دستهبندیها اعمال شود",
|
||||
"og_scope_all_hint": "یک بُعد بیندستهای برای همهٔ دستهبندیها نمایش داده میشود، نه فقط همین یکی.",
|
||||
"og_scope_this_hint": "فقط برای همین دستهبندی نمایش داده میشود.",
|
||||
"og_is_required": "پیش از رزرو باید پاسخ داده شود",
|
||||
"og_saved": "بُعد قیمتگذاری ذخیره شد.",
|
||||
"og_value_add": "افزودن مقدار",
|
||||
"og_value_edit": "ویرایش مقدار",
|
||||
"og_value_saved": "مقدار ذخیره شد.",
|
||||
"og_value_empty": "هنوز مقداری ثبت نشده است.",
|
||||
"alert_title": "هشدارهای پشتیبانی",
|
||||
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمیشود.",
|
||||
"alert_empty": "هشداری برای رسیدگی نیست.",
|
||||
@@ -2104,6 +2144,7 @@
|
||||
"admin_tickets_sub": "صف سراسری تیکتها",
|
||||
"admin_alerts_sub": "کارتابل داخلی هشدارها",
|
||||
"admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها",
|
||||
"admin_catalog_sub": "دستهبندیهای خدمات و ابعاد قیمتگذاری آنها",
|
||||
"admin_holidays_sub": "تقویم تعطیلات بانکی",
|
||||
"admin_audit_sub": "گزارش تغییرها، فقطخواندنی",
|
||||
"admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش",
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function AdminOverviewScreen() {
|
||||
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||
{ section: 'system', title: tNav('catalog'), subtitle: th('admin_catalog_sub'), path: ROUTES.ADMIN_CATALOG, icon: 'category', enabled: caps.canManageCatalog },
|
||||
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, PageHeader, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
|
||||
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
|
||||
import type {
|
||||
CreateOptionValueInput,
|
||||
OptionGroupInput,
|
||||
ServiceOptionGroup,
|
||||
ServiceOptionValue,
|
||||
UpdateOptionValueInput,
|
||||
} from '@/services/catalog/types';
|
||||
import {
|
||||
useAdminCategories,
|
||||
useAdminOptionGroups,
|
||||
useCreateOptionGroup,
|
||||
useCreateOptionValue,
|
||||
useSetCategoryActive,
|
||||
useUpdateOptionGroup,
|
||||
useUpdateOptionValue,
|
||||
} from '@/services/catalog';
|
||||
import { CategoryFormDialog } from '../page';
|
||||
|
||||
/**
|
||||
* A category's pricing-options console (f6 blocker) — the option groups (dimensions) and their values
|
||||
* the nurse builder offers for this category, plus every cross-category group. Option groups have no
|
||||
* active/inactive toggle in the contract (create/edit only); values do, exposed as a switch in the
|
||||
* value edit dialog rather than a hard delete, matching the "reference data, never hard-deleted" rule
|
||||
* that already governs categories and variants.
|
||||
*/
|
||||
export default function AdminCatalogCategoryPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_CATALOG}`);
|
||||
|
||||
const params = useParams<{ categoryId: string }>();
|
||||
const parsed = Number(params?.categoryId);
|
||||
const categoryId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
|
||||
// No single-category admin read exists — categories are few, so the admin list (uncached) doubles
|
||||
// as the detail source, exactly like the public reference-data reads do for the browse side.
|
||||
const categories = useAdminCategories({ page: 1, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
|
||||
const category = categories.data?.items.find((c) => c.id === categoryId) ?? null;
|
||||
|
||||
const groups = useAdminOptionGroups(categoryId || null);
|
||||
const setActive = useSetCategoryActive();
|
||||
|
||||
const [editingCategory, setEditingCategory] = useState(false);
|
||||
const [groupDialog, setGroupDialog] = useState<ServiceOptionGroup | 'new' | null>(null);
|
||||
const [valueDialog, setValueDialog] = useState<{ groupId: number; value: ServiceOptionValue | 'new' } | null>(null);
|
||||
|
||||
const back = <PageHeader title={t('og_section_title')} onBack={goBack} backLabel={t('back')} />;
|
||||
|
||||
if (categories.isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{back}
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={220} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (categories.isError) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{back}
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{back}
|
||||
<AdminEmptyState icon="category" title={t('cat_not_found')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const onToggleActive = () => {
|
||||
setActive.mutate(
|
||||
{ id: category.id, isActive: !category.isActive },
|
||||
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<PageHeader
|
||||
title={category.nameFa}
|
||||
subtitle={category.nameEn}
|
||||
onBack={goBack}
|
||||
backLabel={t('back')}
|
||||
meta={<StatusChip status={category.isActive ? 'active' : 'neutral'} label={t(category.isActive ? 'status_active' : 'status_inactive')} />}
|
||||
/>
|
||||
|
||||
{category.descriptionFa || category.descriptionEn ? (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{category.descriptionFa || category.descriptionEn}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{caps.canManageCatalog ? (
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditingCategory(true)}>
|
||||
{t('cat_edit')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color={category.isActive ? 'error' : 'primary'}
|
||||
onClick={onToggleActive}
|
||||
disabled={setActive.isPending}
|
||||
>
|
||||
{t(category.isActive ? 'cat_deactivate' : 'cat_activate')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('og_section_title')}
|
||||
</Typography>
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton variant="text" color="primary" startIcon="add" onClick={() => setGroupDialog('new')}>
|
||||
{t('og_add')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{groups.isLoading ? (
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
) : groups.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => groups.refetch()} />
|
||||
) : (groups.data ?? []).length === 0 ? (
|
||||
<AdminEmptyState icon="tune" title={t('og_empty')} />
|
||||
) : (
|
||||
(groups.data ?? []).map((group) => (
|
||||
<Paper
|
||||
key={group.id}
|
||||
elevation={0}
|
||||
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{group.nameFa} · {group.nameEn}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{group.serviceCategoryId == null ? <Chip size="small" label={t('og_cross_category_badge')} /> : null}
|
||||
<Chip size="small" variant="outlined" label={t(group.isRequired ? 'og_required_badge' : 'og_optional_badge')} />
|
||||
</Stack>
|
||||
</Box>
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setGroupDialog(group)}>
|
||||
{t('cat_edit')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{group.values.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('og_value_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
group.values.map((value) => (
|
||||
<Stack
|
||||
key={value.id}
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2">
|
||||
{value.nameFa} · {value.nameEn}
|
||||
</Typography>
|
||||
<StatusChip status={value.isActive ? 'active' : 'neutral'} label={t(value.isActive ? 'status_active' : 'status_inactive')} />
|
||||
</Stack>
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton variant="text" color="primary" onClick={() => setValueDialog({ groupId: group.id, value })}>
|
||||
{t('cat_edit')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="add"
|
||||
onClick={() => setValueDialog({ groupId: group.id, value: 'new' })}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('og_value_add')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{editingCategory ? <CategoryFormDialog category={category} onClose={() => setEditingCategory(false)} /> : null}
|
||||
{groupDialog ? (
|
||||
<OptionGroupFormDialog
|
||||
categoryId={category.id}
|
||||
group={groupDialog === 'new' ? null : groupDialog}
|
||||
onClose={() => setGroupDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
{valueDialog ? (
|
||||
<OptionValueFormDialog
|
||||
groupId={valueDialog.groupId}
|
||||
value={valueDialog.value === 'new' ? null : valueDialog.value}
|
||||
onClose={() => setValueDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** `'this'` maps to `serviceCategoryId: categoryId`; `'all'` maps to the cross-category `null`. */
|
||||
interface GroupFormState {
|
||||
scope: 'this' | 'all';
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
isRequired: boolean;
|
||||
sortOrder: string;
|
||||
}
|
||||
|
||||
function initialGroupForm(group: ServiceOptionGroup | null): GroupFormState {
|
||||
return {
|
||||
scope: group != null && group.serviceCategoryId == null ? 'all' : 'this',
|
||||
nameFa: group?.nameFa ?? '',
|
||||
nameEn: group?.nameEn ?? '',
|
||||
isRequired: group?.isRequired ?? false,
|
||||
sortOrder: group != null ? String(group.sortOrder) : '0',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit dialog for a pricing dimension (option group). Re-scoping an existing cross-category
|
||||
* group to "this category only" removes it from every other category that currently sees it — the hint
|
||||
* text under the switch says so, matching what `UpdateServiceOptionGroupCommand` actually does server-side.
|
||||
*/
|
||||
function OptionGroupFormDialog({
|
||||
categoryId,
|
||||
group,
|
||||
onClose,
|
||||
}: {
|
||||
categoryId: number;
|
||||
group: ServiceOptionGroup | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const isEdit = group != null;
|
||||
const create = useCreateOptionGroup();
|
||||
const update = useUpdateOptionGroup();
|
||||
const isPending = create.isPending || update.isPending;
|
||||
|
||||
const form = useForm<GroupFormState>({ mode: 'onTouched', defaultValues: initialGroupForm(group) });
|
||||
const { control, handleSubmit, formState } = form;
|
||||
const scope = useWatch({ control, name: 'scope' });
|
||||
|
||||
const onSave = (values: GroupFormState) => {
|
||||
const input: OptionGroupInput = {
|
||||
serviceCategoryId: values.scope === 'all' ? null : categoryId,
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
isRequired: values.isRequired,
|
||||
sortOrder: Number(values.sortOrder),
|
||||
};
|
||||
const onSuccess = () => {
|
||||
enqueueSnackbar(t('og_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
};
|
||||
if (isEdit) update.mutate({ id: group.id, input }, { onSuccess });
|
||||
else create.mutate(input, { onSuccess });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_edit') : t('og_add')}</DialogTitle>
|
||||
<FormProvider {...form}>
|
||||
<DialogContent>
|
||||
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
|
||||
calls the same handler directly rather than relying on cross-element form association. */}
|
||||
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
|
||||
<RhfTextField<GroupFormState>
|
||||
name="nameFa"
|
||||
label={t('cat_name_fa')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<GroupFormState>
|
||||
name="nameEn"
|
||||
label={t('cat_name_en')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<Box>
|
||||
<RhfControlGroup<GroupFormState> name="scope">
|
||||
{({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value === 'all'}
|
||||
onChange={(event) => field.onChange(event.target.checked ? 'all' : 'this')}
|
||||
/>
|
||||
}
|
||||
label={t('og_scope_all')}
|
||||
/>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{scope === 'all' ? t('og_scope_all_hint') : t('og_scope_this_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<RhfControlGroup<GroupFormState> name="isRequired">
|
||||
{({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
|
||||
label={t('og_is_required')}
|
||||
/>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
<RhfTextField<GroupFormState>
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
label={t('cat_sort_order')}
|
||||
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!formState.isValid || isPending}
|
||||
>
|
||||
{isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</FormProvider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueFormState {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function initialValueForm(value: ServiceOptionValue | null): ValueFormState {
|
||||
return {
|
||||
nameFa: value?.nameFa ?? '',
|
||||
nameEn: value?.nameEn ?? '',
|
||||
sortOrder: value != null ? String(value.sortOrder) : '0',
|
||||
isActive: value?.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit dialog for a concrete option value. Create has no active flag (the server always
|
||||
* creates active); edit exposes it as a switch — the only way to deactivate a value, since re-parenting
|
||||
* to a different group is deliberately not supported (it would silently change variants that already
|
||||
* answered with this value).
|
||||
*/
|
||||
function OptionValueFormDialog({
|
||||
groupId,
|
||||
value,
|
||||
onClose,
|
||||
}: {
|
||||
groupId: number;
|
||||
value: ServiceOptionValue | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const isEdit = value != null;
|
||||
const create = useCreateOptionValue();
|
||||
const update = useUpdateOptionValue();
|
||||
const isPending = create.isPending || update.isPending;
|
||||
|
||||
const form = useForm<ValueFormState>({ mode: 'onTouched', defaultValues: initialValueForm(value) });
|
||||
const { handleSubmit, formState } = form;
|
||||
|
||||
const onSave = (values: ValueFormState) => {
|
||||
const onSuccess = () => {
|
||||
enqueueSnackbar(t('og_value_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
};
|
||||
if (isEdit) {
|
||||
const input: UpdateOptionValueInput = {
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
sortOrder: Number(values.sortOrder),
|
||||
isActive: values.isActive,
|
||||
};
|
||||
update.mutate({ id: value.id, input }, { onSuccess });
|
||||
} else {
|
||||
const input: CreateOptionValueInput = {
|
||||
optionGroupId: groupId,
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
sortOrder: Number(values.sortOrder),
|
||||
};
|
||||
create.mutate(input, { onSuccess });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_value_edit') : t('og_value_add')}</DialogTitle>
|
||||
<FormProvider {...form}>
|
||||
<DialogContent>
|
||||
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
|
||||
<RhfTextField<ValueFormState>
|
||||
name="nameFa"
|
||||
label={t('cat_name_fa')}
|
||||
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<ValueFormState>
|
||||
name="nameEn"
|
||||
label={t('cat_name_en')}
|
||||
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<ValueFormState>
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
label={t('cat_sort_order')}
|
||||
rules={{ validate: (val) => Number.isFinite(Number(val)) }}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
{isEdit ? (
|
||||
<RhfControlGroup<ValueFormState> name="isActive">
|
||||
{({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
|
||||
label={t(field.value ? 'status_active' : 'status_inactive')}
|
||||
/>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
) : null}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!formState.isValid || isPending}
|
||||
>
|
||||
{isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</FormProvider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack } from '@mui/material';
|
||||
import { AppButton, AppLoading, RhfTextField, StatusChip } from '@/components';
|
||||
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { adminCatalogCategoryPath } from '@/constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
|
||||
import type { CategoryInput, ServiceCategory } from '@/services/catalog/types';
|
||||
import { useAdminCategories, useCreateCategory, useSetCategoryActive, useUpdateCategory } from '@/services/catalog';
|
||||
|
||||
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
|
||||
type CategoryListFilters = Record<string, never>;
|
||||
const EMPTY_FILTERS: CategoryListFilters = {};
|
||||
|
||||
/**
|
||||
* Catalog admin — the top-level category list (f6 blocker). Categories are seeded by migration, but
|
||||
* pricing options are deliberately admin-authored data (never a migration), so outside a developer's
|
||||
* own dev-seeded box there is nothing here until an admin creates it — this console is the fix.
|
||||
* A row opens the category's option-groups/values console; the create dialog here handles categories
|
||||
* only. Categories are reference data — deactivating (never a hard delete) hides a category from
|
||||
* public browse and new bookings while leaving existing variants/bookings intact.
|
||||
*/
|
||||
export default function AdminCatalogPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminCatalogPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminCatalogPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const setActive = useSetCategoryActive();
|
||||
|
||||
const listState = useAdminListState<CategoryListFilters>({
|
||||
parse: () => EMPTY_FILTERS,
|
||||
serialize: () => ({}),
|
||||
empty: EMPTY_FILTERS,
|
||||
});
|
||||
const page = listState.page;
|
||||
|
||||
const categories = useAdminCategories({ page, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
|
||||
const items = categories.data?.items ?? [];
|
||||
const total = categories.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / ADMIN_CATEGORIES_PAGE_SIZE));
|
||||
const from = items.length === 0 ? 0 : (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + 1;
|
||||
const to = (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + items.length;
|
||||
|
||||
const onToggleActive = (category: ServiceCategory) => {
|
||||
setActive.mutate(
|
||||
{ id: category.id, isActive: !category.isActive },
|
||||
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
|
||||
);
|
||||
};
|
||||
|
||||
const columns: AdminTableColumn<ServiceCategory>[] = [
|
||||
{ key: 'name', header: t('cat_col_name'), render: (c) => `${c.nameFa} · ${c.nameEn}` },
|
||||
{ key: 'order', header: t('cat_col_order'), render: (c) => c.sortOrder },
|
||||
{
|
||||
key: 'status',
|
||||
header: t('cat_col_status'),
|
||||
render: (c) => <StatusChip status={c.isActive ? 'active' : 'neutral'} label={t(c.isActive ? 'status_active' : 'status_inactive')} />,
|
||||
},
|
||||
...(caps.canManageCatalog
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (c: ServiceCategory) => (
|
||||
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color={c.isActive ? 'error' : 'primary'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleActive(c);
|
||||
}}
|
||||
disabled={setActive.isPending}
|
||||
>
|
||||
{t(c.isActive ? 'cat_deactivate' : 'cat_activate')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
),
|
||||
} as AdminTableColumn<ServiceCategory>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('cat_title')}
|
||||
subtitle={t('cat_subtitle')}
|
||||
actions={
|
||||
caps.canManageCatalog ? (
|
||||
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)}>
|
||||
{t('cat_add')}
|
||||
</AppButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{categories.isLoading ? (
|
||||
<Skeleton variant="rounded" height={220} />
|
||||
) : categories.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmptyState icon="category" title={t('cat_empty')} />
|
||||
) : (
|
||||
<AdminDataTable
|
||||
columns={columns}
|
||||
rows={items}
|
||||
getRowKey={(c) => c.id}
|
||||
ariaLabel={t('cat_title')}
|
||||
onRowClick={(c) => router.push(`/${locale}${adminCatalogCategoryPath(c.id)}`)}
|
||||
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
|
||||
{creating ? <CategoryFormDialog category={null} onClose={() => setCreating(false)} /> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editable slice of `CategoryInput`, kept as strings for controlled text/number inputs. */
|
||||
interface CategoryFormState {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descriptionFa: string;
|
||||
descriptionEn: string;
|
||||
iconKey: string;
|
||||
sortOrder: string;
|
||||
}
|
||||
|
||||
function initialForm(category: ServiceCategory | null): CategoryFormState {
|
||||
return {
|
||||
nameFa: category?.nameFa ?? '',
|
||||
nameEn: category?.nameEn ?? '',
|
||||
descriptionFa: category?.descriptionFa ?? '',
|
||||
descriptionEn: category?.descriptionEn ?? '',
|
||||
iconKey: category?.iconKey ?? '',
|
||||
sortOrder: category != null ? String(category.sortOrder) : '0',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit dialog for a category — shared by the list (create, `category=null`) and the detail
|
||||
* page (edit, `category` prefilled). Both labels are required; description/icon are optional.
|
||||
*/
|
||||
export function CategoryFormDialog({ category, onClose }: { category: ServiceCategory | null; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const isEdit = category != null;
|
||||
const create = useCreateCategory();
|
||||
const update = useUpdateCategory();
|
||||
|
||||
const form = useForm<CategoryFormState>({ mode: 'onTouched', defaultValues: initialForm(category) });
|
||||
const { handleSubmit, formState } = form;
|
||||
const isPending = create.isPending || update.isPending;
|
||||
|
||||
const onSave = (values: CategoryFormState) => {
|
||||
const input: CategoryInput = {
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
descriptionFa: values.descriptionFa.trim() || null,
|
||||
descriptionEn: values.descriptionEn.trim() || null,
|
||||
iconKey: values.iconKey.trim() || null,
|
||||
sortOrder: Number(values.sortOrder),
|
||||
};
|
||||
const onSuccess = () => {
|
||||
enqueueSnackbar(t('cat_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
};
|
||||
if (isEdit) update.mutate({ id: category.id, input }, { onSuccess });
|
||||
else create.mutate(input, { onSuccess });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('cat_edit') : t('cat_add')}</DialogTitle>
|
||||
<FormProvider {...form}>
|
||||
<DialogContent>
|
||||
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
|
||||
calls the same handler directly rather than relying on cross-element form association. */}
|
||||
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
|
||||
<RhfTextField<CategoryFormState>
|
||||
name="nameFa"
|
||||
label={t('cat_name_fa')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<CategoryFormState>
|
||||
name="nameEn"
|
||||
label={t('cat_name_en')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<CategoryFormState> name="descriptionFa" label={t('cat_description_fa')} multiline minRows={2} />
|
||||
<RhfTextField<CategoryFormState> name="descriptionEn" label={t('cat_description_en')} multiline minRows={2} />
|
||||
<RhfTextField<CategoryFormState> name="iconKey" label={t('cat_icon_key')} helperText={t('cat_icon_hint')} slotProps={{ htmlInput: { dir: 'ltr' } }} />
|
||||
<RhfTextField<CategoryFormState>
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
label={t('cat_sort_order')}
|
||||
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!formState.isValid || isPending}
|
||||
>
|
||||
{isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</FormProvider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,13 @@ export default function AdminSystemPage() {
|
||||
path: ROUTES.ADMIN_CONFIG,
|
||||
enabled: caps.canConfig,
|
||||
},
|
||||
{
|
||||
title: tn('catalog'),
|
||||
subtitle: t('admin_catalog_sub'),
|
||||
icon: 'category',
|
||||
path: ROUTES.ADMIN_CATALOG,
|
||||
enabled: caps.canManageCatalog,
|
||||
},
|
||||
{
|
||||
title: tn('holidays'),
|
||||
subtitle: t('admin_holidays_sub'),
|
||||
|
||||
@@ -108,6 +108,8 @@ export const ROUTES = {
|
||||
ADMIN_REVIEWS: '/admin/reviews',
|
||||
// Platform config editor + change history.
|
||||
ADMIN_CONFIG: '/admin/config',
|
||||
// Catalog skeleton editor — categories + a category's option groups/values; append `/{categoryId}`.
|
||||
ADMIN_CATALOG: '/admin/catalog',
|
||||
// Iranian-holiday calendar manager (drives payout scheduling; is_bank_closed toggle).
|
||||
ADMIN_HOLIDAYS: '/admin/holidays',
|
||||
// Support-alert triage board — internal-only (assign/resolve).
|
||||
@@ -144,6 +146,10 @@ export const adminPayoutBatchPath = (batchId: number | string): string =>
|
||||
export const adminPartnerCenterPath = (centerId: number | string): string =>
|
||||
`${ROUTES.ADMIN_PARTNERS}/${centerId}`;
|
||||
|
||||
/** The admin catalog category detail — its option groups/values, including inactive ones. */
|
||||
export const adminCatalogCategoryPath = (categoryId: number | string): string =>
|
||||
`${ROUTES.ADMIN_CATALOG}/${categoryId}`;
|
||||
|
||||
/** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */
|
||||
export const partnerBookingDetailPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.PARTNER_BOOKINGS}/${bookingId}`;
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AdminCapabilities {
|
||||
canPayout: boolean;
|
||||
canModerate: boolean;
|
||||
canConfig: boolean;
|
||||
canManageCatalog: boolean;
|
||||
canManageAlerts: boolean;
|
||||
canManageTickets: boolean;
|
||||
canManagePartners: boolean;
|
||||
@@ -57,6 +58,7 @@ export function useAdminCapabilities(): AdminCapabilities {
|
||||
canPayout: has(roles, 'super_admin', 'admin', 'finance'),
|
||||
canModerate: has(roles, 'super_admin', 'admin', 'moderation'),
|
||||
canConfig: has(roles, 'super_admin', 'admin', 'finance'),
|
||||
canManageCatalog: has(roles, 'super_admin', 'admin'),
|
||||
canManageAlerts: has(roles, 'super_admin', 'admin', 'support'),
|
||||
canManageTickets: has(roles, 'super_admin', 'admin', 'support'),
|
||||
canManagePartners: has(roles, 'super_admin', 'admin'),
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
CatalogApi,
|
||||
CategoryInput,
|
||||
CreateOptionValueInput,
|
||||
CreateVariantInput,
|
||||
NurseServiceVariant,
|
||||
OptionGroupInput,
|
||||
ServiceCategory,
|
||||
ServiceOptionGroup,
|
||||
ServiceOptionValue,
|
||||
UpdateOptionValueInput,
|
||||
UpdateVariantInput,
|
||||
} from '../types';
|
||||
|
||||
const CATALOG_BASE = '/api/v1/catalog';
|
||||
const VARIANTS_BASE = '/api/v1/nurse_variants';
|
||||
const ADMIN_CATALOG_BASE = '/api/v1/admin_catalog';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`).
|
||||
@@ -73,4 +79,75 @@ export const catalogClientApi: CatalogApi = {
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
},
|
||||
|
||||
adminListCategories: async (params) => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params?.page ?? 1));
|
||||
query.set('pageSize', String(params?.pageSize ?? ADMIN_CATEGORIES_PAGE_SIZE));
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<ServiceCategory>>>(`${ADMIN_CATALOG_BASE}/list_categories?${query.toString()}`),
|
||||
);
|
||||
},
|
||||
|
||||
createCategory: async (input: CategoryInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/create_category`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateCategory: async (id, input: CategoryInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/update_category/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
setCategoryActive: async (id, isActive) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_CATALOG_BASE}/set_category_active/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
},
|
||||
|
||||
adminListOptionGroups: async (categoryId) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup[]>>(
|
||||
`${ADMIN_CATALOG_BASE}/list_option_groups?category_id=${categoryId}`,
|
||||
),
|
||||
),
|
||||
|
||||
createOptionGroup: async (input: OptionGroupInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/create_option_group`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateOptionGroup: async (id, input: OptionGroupInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/update_option_group/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
createOptionValue: async (input: CreateOptionValueInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/create_option_value`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateOptionValue: async (id, input: UpdateOptionValueInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/update_option_value/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import {
|
||||
isGroupApplicable,
|
||||
optionSetSignature,
|
||||
type CatalogApi,
|
||||
type CategoryInput,
|
||||
type CreateOptionValueInput,
|
||||
type CreateVariantInput,
|
||||
type NurseServiceVariant,
|
||||
type OptionGroupInput,
|
||||
type ServiceCategory,
|
||||
type ServiceOptionGroup,
|
||||
type ServiceOptionValue,
|
||||
type UpdateOptionValueInput,
|
||||
type UpdateVariantInput,
|
||||
type VariantOption,
|
||||
type VariantOptionSelection,
|
||||
@@ -27,6 +31,14 @@ const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder
|
||||
let store: NurseServiceVariant[] = [];
|
||||
let nextVariantId = 1;
|
||||
|
||||
// The admin-managed skeleton — seeded as a mutable copy so a create/update/deactivate in the admin
|
||||
// console is reflected back through the public read methods below, exactly like the real DB.
|
||||
let categoryStore: ServiceCategory[] = SEED_CATEGORIES.map((category) => ({ ...category }));
|
||||
let groupStore: ServiceOptionGroup[] = SEED_OPTION_GROUPS.map((group) => ({ ...group, values: [...group.values] }));
|
||||
let nextCategoryId = Math.max(0, ...categoryStore.map((c) => c.id)) + 1;
|
||||
let nextGroupId = Math.max(0, ...groupStore.map((g) => g.id)) + 1;
|
||||
let nextValueId = Math.max(0, ...groupStore.flatMap((g) => g.values.map((v) => v.id))) + 1;
|
||||
|
||||
/** Active-first (contract list order), then most-recent within each group. */
|
||||
const orderedVariants = () =>
|
||||
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id);
|
||||
@@ -39,9 +51,7 @@ function paginate<T>(all: T[], params?: PageParams, defaultSize = 50): Paginated
|
||||
}
|
||||
|
||||
function applicableGroups(categoryId: number): ServiceOptionGroup[] {
|
||||
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(
|
||||
bySortOrder,
|
||||
);
|
||||
return groupStore.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(bySortOrder);
|
||||
}
|
||||
|
||||
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
|
||||
@@ -128,7 +138,7 @@ function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup
|
||||
export const catalogMockApi: CatalogApi = {
|
||||
listCategories: async (params) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder);
|
||||
const active = categoryStore.filter((category) => category.isActive).sort(bySortOrder);
|
||||
return paginate(active, params, CATEGORIES_PAGE_SIZE);
|
||||
},
|
||||
|
||||
@@ -155,7 +165,7 @@ export const catalogMockApi: CatalogApi = {
|
||||
|
||||
createVariant: async (input) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
|
||||
const category = categoryStore.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
|
||||
if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
|
||||
|
||||
const groups = applicableGroups(category.id);
|
||||
@@ -205,4 +215,92 @@ export const catalogMockApi: CatalogApi = {
|
||||
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
|
||||
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
|
||||
},
|
||||
|
||||
adminListCategories: async (params) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return paginate([...categoryStore].sort(bySortOrder), params, ADMIN_CATEGORIES_PAGE_SIZE);
|
||||
},
|
||||
|
||||
createCategory: async (input: CategoryInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const category: ServiceCategory = { id: nextCategoryId++, ...input, isActive: true };
|
||||
categoryStore = [...categoryStore, category];
|
||||
return category;
|
||||
},
|
||||
|
||||
updateCategory: async (id, input: CategoryInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = categoryStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
|
||||
const updated: ServiceCategory = { ...existing, ...input };
|
||||
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? updated : candidate));
|
||||
return updated;
|
||||
},
|
||||
|
||||
setCategoryActive: async (id, isActive) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = categoryStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
|
||||
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
|
||||
},
|
||||
|
||||
adminListOptionGroups: async (categoryId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return groupStore
|
||||
.filter((group) => isGroupApplicable(group, categoryId))
|
||||
.sort(bySortOrder)
|
||||
.map((group) => ({ ...group, values: [...group.values].sort(bySortOrder) }));
|
||||
},
|
||||
|
||||
createOptionGroup: async (input: OptionGroupInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
|
||||
throw new ApiError(400, 'Category not found', 'invalid_category');
|
||||
}
|
||||
const group: ServiceOptionGroup = { id: nextGroupId++, ...input, isActive: true, values: [] };
|
||||
groupStore = [...groupStore, group];
|
||||
return group;
|
||||
},
|
||||
|
||||
updateOptionGroup: async (id, input: OptionGroupInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = groupStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Option group not found', 'not_found');
|
||||
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
|
||||
throw new ApiError(400, 'Category not found', 'invalid_category');
|
||||
}
|
||||
const updated: ServiceOptionGroup = { ...existing, ...input };
|
||||
groupStore = groupStore.map((candidate) => (candidate.id === id ? updated : candidate));
|
||||
return updated;
|
||||
},
|
||||
|
||||
createOptionValue: async (input: CreateOptionValueInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const group = groupStore.find((candidate) => candidate.id === input.optionGroupId);
|
||||
if (!group) throw new ApiError(400, 'Option group not found', 'invalid_group');
|
||||
const value: ServiceOptionValue = {
|
||||
id: nextValueId++,
|
||||
nameFa: input.nameFa,
|
||||
nameEn: input.nameEn,
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: true,
|
||||
};
|
||||
groupStore = groupStore.map((candidate) =>
|
||||
candidate.id === group.id ? { ...candidate, values: [...candidate.values, value] } : candidate,
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
updateOptionValue: async (id, input: UpdateOptionValueInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const group = groupStore.find((candidate) => candidate.values.some((value) => value.id === id));
|
||||
if (!group) throw new ApiError(404, 'Option value not found', 'not_found');
|
||||
const updated: ServiceOptionValue = { id, ...input };
|
||||
groupStore = groupStore.map((candidate) =>
|
||||
candidate.id === group.id
|
||||
? { ...candidate, values: candidate.values.map((value) => (value.id === id ? updated : value)) }
|
||||
: candidate,
|
||||
);
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,3 +22,10 @@ export const MY_VARIANTS_STALE_TIME = 60_000;
|
||||
/** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
|
||||
export const CATEGORIES_PAGE_SIZE = 50;
|
||||
export const MY_VARIANTS_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* The admin catalog-management reads (categories incl. inactive; a category's groups/values incl.
|
||||
* inactive) are deliberately uncached (`staleTime: 0`) — an admin editing the skeleton expects every
|
||||
* write reflected immediately, and this console's traffic is negligible next to the public browse.
|
||||
*/
|
||||
export const ADMIN_CATEGORIES_PAGE_SIZE = 50;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Every category regardless of active state, for the admin catalog console — unlike the public,
|
||||
* session-cached `useServiceCategories`, this always refetches (`staleTime: 0`) so a deactivate/
|
||||
* reactivate or edit is visible immediately without a stale cached page.
|
||||
*/
|
||||
export function useAdminCategories(params?: PageParams) {
|
||||
return useQuery({
|
||||
queryKey: catalogKeys.adminCategories(params),
|
||||
queryFn: () => catalogApi.adminListCategories(params),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* A category's applicable option groups **including inactive groups, each with every value
|
||||
* including inactive ones** — the admin console's read of the skeleton it manages. Always
|
||||
* refetches (`staleTime: 0`); disabled until a category is chosen.
|
||||
*/
|
||||
export function useAdminOptionGroups(categoryId: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: catalogKeys.adminOptionGroups(categoryId ?? 0),
|
||||
queryFn: () => catalogApi.adminListOptionGroups(categoryId as number),
|
||||
enabled: categoryId != null,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CategoryInput } from '../types';
|
||||
|
||||
/** Adds a top-level category, then invalidates the admin category list. */
|
||||
export function useCreateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CategoryInput) => catalogApi.createCategory(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { OptionGroupInput } from '../types';
|
||||
|
||||
/**
|
||||
* Adds a pricing dimension — invalidates every cached category's option-groups (admin + public), not
|
||||
* just the current one, because a cross-category group (`serviceCategoryId: null`) affects all of them.
|
||||
*/
|
||||
export function useCreateOptionGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: OptionGroupInput) => catalogApi.createOptionGroup(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CreateOptionValueInput } from '../types';
|
||||
|
||||
/** Adds a concrete choice to an option group — invalidates every cached category's option-groups. */
|
||||
export function useCreateOptionValue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateOptionValueInput) => catalogApi.createOptionValue(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Soft deactivates or reactivates a category — **never a hard delete**. Deactivating hides it from
|
||||
* public browse and new variant creation; existing variants in it are left intact. Drives both the
|
||||
* deactivate-with-confirm action and the reactivate affordance on an inactive row.
|
||||
*/
|
||||
export function useSetCategoryActive() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) => catalogApi.setCategoryActive(id, isActive),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CategoryInput } from '../types';
|
||||
|
||||
/** Edits a category's labels/description/icon/order, then invalidates the admin + public lists. */
|
||||
export function useUpdateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: CategoryInput }) => catalogApi.updateCategory(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { OptionGroupInput } from '../types';
|
||||
|
||||
/**
|
||||
* Edits a group's category scope/labels/required flag/order — invalidates every cached category's
|
||||
* option-groups, since re-scoping to/from cross-category changes what more than one category sees.
|
||||
*/
|
||||
export function useUpdateOptionGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: OptionGroupInput }) => catalogApi.updateOptionGroup(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { UpdateOptionValueInput } from '../types';
|
||||
|
||||
/**
|
||||
* Edits a value's labels/order and activates/deactivates it (never a hard delete — re-parenting to a
|
||||
* different group is not supported). Invalidates every cached category's option-groups.
|
||||
*/
|
||||
export function useUpdateOptionValue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: UpdateOptionValueInput }) => catalogApi.updateOptionValue(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,3 +4,12 @@ export { useMyVariants } from './hooks/useMyVariants';
|
||||
export { useCreateVariant } from './hooks/useCreateVariant';
|
||||
export { useUpdateVariant } from './hooks/useUpdateVariant';
|
||||
export { useSetVariantActive } from './hooks/useSetVariantActive';
|
||||
export { useAdminCategories } from './hooks/useAdminCategories';
|
||||
export { useCreateCategory } from './hooks/useCreateCategory';
|
||||
export { useUpdateCategory } from './hooks/useUpdateCategory';
|
||||
export { useSetCategoryActive } from './hooks/useSetCategoryActive';
|
||||
export { useAdminOptionGroups } from './hooks/useAdminOptionGroups';
|
||||
export { useCreateOptionGroup } from './hooks/useCreateOptionGroup';
|
||||
export { useUpdateOptionGroup } from './hooks/useUpdateOptionGroup';
|
||||
export { useCreateOptionValue } from './hooks/useCreateOptionValue';
|
||||
export { useUpdateOptionValue } from './hooks/useUpdateOptionValue';
|
||||
|
||||
@@ -11,12 +11,26 @@ export const catalogKeys = {
|
||||
|
||||
// Reference data — cached for the whole session, never invalidated by this phase.
|
||||
categories: () => [...catalogKeys.all, 'categories'] as const,
|
||||
/** Prefix shared by every `categoryOptionGroups(id)` key — invalidate this to catch every cached
|
||||
* category when a group/value edit is cross-category (its `serviceCategoryId` is null) or its exact
|
||||
* category set isn't known to the caller. */
|
||||
categoryOptionGroupsAll: () => [...catalogKeys.all, 'option-groups'] as const,
|
||||
categoryOptionGroups: (categoryId?: number | null) =>
|
||||
[...catalogKeys.all, 'option-groups', categoryId ?? null] as const,
|
||||
[...catalogKeys.categoryOptionGroupsAll(), categoryId ?? null] as const,
|
||||
|
||||
// The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
|
||||
variants: () => [...catalogKeys.all, 'variants'] as const,
|
||||
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
|
||||
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
|
||||
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const,
|
||||
|
||||
// Admin catalog management — separate from the public/cached reference-data keys above so a mutation
|
||||
// invalidating one never stales the other's cache entry.
|
||||
admin: () => [...catalogKeys.all, 'admin'] as const,
|
||||
adminCategoryLists: () => [...catalogKeys.admin(), 'categories'] as const,
|
||||
adminCategories: (params?: PageParams) => [...catalogKeys.adminCategoryLists(), params ?? {}] as const,
|
||||
/** Prefix shared by every `adminOptionGroups(id)` key — see `categoryOptionGroupsAll`'s doc for why a
|
||||
* group/value mutation invalidates this prefix instead of a single category id. */
|
||||
adminOptionGroupsAll: () => [...catalogKeys.admin(), 'option-groups'] as const,
|
||||
adminOptionGroups: (categoryId: number) => [...catalogKeys.adminOptionGroupsAll(), categoryId] as const,
|
||||
};
|
||||
|
||||
@@ -121,6 +121,47 @@ export interface UpdateVariantInput {
|
||||
displayName?: string | null;
|
||||
}
|
||||
|
||||
// ── Admin write models (AdminCatalogController — categories/option groups/values management) ───────────
|
||||
/** `CreateServiceCategoryCommand`/`UpdateServiceCategoryCommand` body. */
|
||||
export interface CategoryInput {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descriptionFa: string | null;
|
||||
descriptionEn: string | null;
|
||||
iconKey: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** `CreateServiceOptionGroupCommand`/`UpdateServiceOptionGroupCommand` body — `serviceCategoryId = null`
|
||||
* makes the group cross-category (applies to every category), exactly like the read side. */
|
||||
export interface OptionGroupInput {
|
||||
serviceCategoryId: number | null;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** `CreateServiceOptionValueCommand` body. */
|
||||
export interface CreateOptionValueInput {
|
||||
optionGroupId: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UpdateServiceOptionValueCommand` body. Re-parenting to a different group is deliberately not
|
||||
* supported server-side — it would silently change the meaning of variants that already answered
|
||||
* with this value — so there is no `optionGroupId` here.
|
||||
*/
|
||||
export interface UpdateOptionValueInput {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog domain's API seam — the real HTTP client and the in-memory mock both implement this
|
||||
* interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks.
|
||||
@@ -137,6 +178,21 @@ export interface CatalogApi {
|
||||
updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
|
||||
/** Soft deactivate/reactivate — never a hard delete. */
|
||||
setVariantActive(id: number, isActive: boolean): Promise<void>;
|
||||
|
||||
// Admin — every category regardless of active state, so a deactivated one stays reachable to reactivate.
|
||||
adminListCategories(params?: PageParams): Promise<Paginated<ServiceCategory>>;
|
||||
createCategory(input: CategoryInput): Promise<ServiceCategory>;
|
||||
updateCategory(id: number, input: CategoryInput): Promise<ServiceCategory>;
|
||||
/** Soft deactivate/reactivate — categories/groups/values are reference data, never hard-deleted. */
|
||||
setCategoryActive(id: number, isActive: boolean): Promise<void>;
|
||||
|
||||
// Admin — a category's groups including inactive ones, each with every value including inactive.
|
||||
adminListOptionGroups(categoryId: number): Promise<ServiceOptionGroup[]>;
|
||||
createOptionGroup(input: OptionGroupInput): Promise<ServiceOptionGroup>;
|
||||
updateOptionGroup(id: number, input: OptionGroupInput): Promise<ServiceOptionGroup>;
|
||||
/** There is no group-level active toggle in the contract — a group can only be created/edited. */
|
||||
createOptionValue(input: CreateOptionValueInput): Promise<ServiceOptionValue>;
|
||||
updateOptionValue(id: number, input: UpdateOptionValueInput): Promise<ServiceOptionValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,11 +70,6 @@ Effort is a rough size, not a schedule: **S** = small/contained, **M** = a real
|
||||
reach a state where the nurse can actually be paid for the visits she did complete. The "today's visits"
|
||||
list nurses see is also unfiltered — it shows a nurse's entire history, not just today. *(Effort: M)*
|
||||
|
||||
### Catalog / pricing
|
||||
- **Outside of the developer's own local setup, there are zero service categories or pricing options
|
||||
configured at all**, and there is no admin page to add them — only direct database editing. Deployed as-is,
|
||||
every "choose a service" screen collapses to a nearly useless two-step form. *(Effort: M)*
|
||||
|
||||
---
|
||||
|
||||
## B. Before a real stranger's real money touches this
|
||||
|
||||
@@ -7,7 +7,10 @@ using Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive;
|
||||
using Baya.Application.Features.Catalog.Commands.UpdateServiceCategory;
|
||||
using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup;
|
||||
using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue;
|
||||
using Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
|
||||
using Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Infrastructure.Identity.Identity.PermissionManager;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
@@ -24,6 +27,16 @@ namespace Baya.Web.Api.Controllers.V1;
|
||||
[Display(Description = "Admin: curate the catalog skeleton (categories + option groups/values; no delete)")]
|
||||
public sealed class AdminCatalogController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<PagedResult<ServiceCategoryDto>>]
|
||||
public async Task<IActionResult> ListCategories([FromQuery] GetAdminServiceCategoriesQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<IReadOnlyList<OptionGroupDto>>]
|
||||
public async Task<IActionResult> ListOptionGroups([FromQuery(Name = "category_id")] long categoryId, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetAdminOptionGroupsQuery(categoryId), cancellationToken));
|
||||
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<ServiceCategoryDto>]
|
||||
public async Task<IActionResult> CreateCategory(CreateServiceCategoryCommand command, CancellationToken cancellationToken)
|
||||
|
||||
@@ -19,6 +19,15 @@ public interface ICatalogRepository
|
||||
/// its active values, ordered by sort order. Empty is valid (no dimensions defined yet).</summary>
|
||||
Task<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Admin: every category regardless of active state, ordered by sort order — so a deactivated
|
||||
/// category stays reachable to review or reactivate. Not cached (admin traffic, always fresh).</summary>
|
||||
Task<IReadOnlyList<ServiceCategoryDto>> ListAllCategoriesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Admin: the category's own groups plus every cross-category group, <b>including inactive
|
||||
/// groups and inactive values</b>, ordered by sort order — the read side of the active-flag toggles.
|
||||
/// Not cached.</summary>
|
||||
Task<IReadOnlyList<OptionGroupDto>> GetAllApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Active-only category projection for variant creation — null if missing or deactivated.</summary>
|
||||
Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
|
||||
|
||||
internal sealed class GetAdminOptionGroupsQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetAdminOptionGroupsQuery, OperationResult<IReadOnlyList<OptionGroupDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<IReadOnlyList<OptionGroupDto>>> Handle(GetAdminOptionGroupsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var groups = await unitOfWork.CatalogRepository.GetAllApplicableGroupsAsync(request.CategoryId, cancellationToken);
|
||||
return OperationResult<IReadOnlyList<OptionGroupDto>>.SuccessResult(groups);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
|
||||
|
||||
/// <summary>
|
||||
/// Admin: a category's applicable option groups — its own plus every cross-category (NULL) group —
|
||||
/// <b>including inactive groups, each with every value including inactive ones</b>. The read side of
|
||||
/// group/value management; the public <c>GetCategoryOptionGroupsQuery</c> deliberately hides both.
|
||||
/// </summary>
|
||||
public record GetAdminOptionGroupsQuery(long CategoryId) : IRequest<OperationResult<IReadOnlyList<OptionGroupDto>>>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
|
||||
|
||||
internal sealed class GetAdminServiceCategoriesQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetAdminServiceCategoriesQuery, OperationResult<PagedResult<ServiceCategoryDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<ServiceCategoryDto>>> Handle(GetAdminServiceCategoriesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
// Unlike the public read, this is deliberately uncached — an admin editing a category expects to
|
||||
// see their own write reflected immediately, and admin list traffic is negligible.
|
||||
var all = await unitOfWork.CatalogRepository.ListAllCategoriesAsync(cancellationToken);
|
||||
var items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||
|
||||
return OperationResult<PagedResult<ServiceCategoryDto>>.SuccessResult(
|
||||
new PagedResult<ServiceCategoryDto>(items, all.Count, page, pageSize));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Catalog;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
|
||||
|
||||
/// <summary>Admin: every category regardless of active state, ordered by sort order, paginated — the read
|
||||
/// side of category management (a deactivated category must stay reachable to review or reactivate).</summary>
|
||||
public record GetAdminServiceCategoriesQuery(int Page = 1, int PageSize = 50)
|
||||
: IRequest<OperationResult<PagedResult<ServiceCategoryDto>>>;
|
||||
+30
@@ -45,6 +45,36 @@ internal sealed class CatalogRepository : ICatalogRepository
|
||||
.ToList()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ServiceCategoryDto>> ListAllCategoriesAsync(CancellationToken cancellationToken)
|
||||
=> await _db.Set<ServiceCategory>()
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ThenBy(c => c.Id)
|
||||
.Select(c => new ServiceCategoryDto(
|
||||
c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<OptionGroupDto>> GetAllApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken)
|
||||
=> await _db.Set<ServiceOptionGroup>()
|
||||
.AsNoTracking()
|
||||
.Where(g => g.ServiceCategoryId == categoryId || g.ServiceCategoryId == null)
|
||||
.OrderBy(g => g.SortOrder)
|
||||
.ThenBy(g => g.Id)
|
||||
.Select(g => new OptionGroupDto(
|
||||
g.Id,
|
||||
g.ServiceCategoryId,
|
||||
g.NameFa,
|
||||
g.NameEn,
|
||||
g.IsRequired,
|
||||
g.SortOrder,
|
||||
g.IsActive,
|
||||
g.Values
|
||||
.OrderBy(v => v.SortOrder)
|
||||
.ThenBy(v => v.Id)
|
||||
.Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive))
|
||||
.ToList()))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken)
|
||||
=> _db.Set<ServiceCategory>()
|
||||
.AsNoTracking()
|
||||
|
||||
Reference in New Issue
Block a user