blocker phase 6, catalog admin

This commit is contained in:
hamid
2026-08-02 22:09:45 +03:30
parent e3c988e961
commit 9a55846df3
31 changed files with 1385 additions and 13 deletions
+41
View File
@@ -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",
+41
View File
@@ -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'),
+6
View File
@@ -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}`;
+2
View File
@@ -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'),
+78 -1
View File
@@ -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),
}),
),
};
+104 -6
View File
@@ -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;
},
};
+7
View File
@@ -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() });
},
});
}
+9
View File
@@ -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';
+15 -1
View File
@@ -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,
};
+56
View File
@@ -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>;
}
/**
-5
View File
@@ -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);
@@ -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);
}
}
@@ -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>>>;
@@ -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));
}
}
@@ -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>>>;
@@ -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()