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", "payouts": "Payouts",
"reviews": "Reviews", "reviews": "Reviews",
"config": "Configuration", "config": "Configuration",
"catalog": "Catalog",
"holidays": "Holidays", "holidays": "Holidays",
"alerts": "Alerts", "alerts": "Alerts",
"audit": "Audit log", "audit": "Audit log",
@@ -1687,6 +1688,45 @@
"hol_bank_hint": "When on, payouts falling on this day shift to the next business day.", "hol_bank_hint": "When on, payouts falling on this day shift to the next business day.",
"hol_saved": "Holiday saved.", "hol_saved": "Holiday saved.",
"hol_year": "Year", "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_title": "Support alerts",
"alert_subtitle": "Internal triage — never shown to customers or nurses.", "alert_subtitle": "Internal triage — never shown to customers or nurses.",
"alert_empty": "No open alerts.", "alert_empty": "No open alerts.",
@@ -2104,6 +2144,7 @@
"admin_tickets_sub": "The global ticket queue", "admin_tickets_sub": "The global ticket queue",
"admin_alerts_sub": "The internal alert worklist", "admin_alerts_sub": "The internal alert worklist",
"admin_config_sub": "Config keys and their change history", "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_holidays_sub": "The bank-holiday calendar",
"admin_audit_sub": "Read-only record of every change", "admin_audit_sub": "Read-only record of every change",
"admin_partners_sub": "Partner centers and sponsored nurses", "admin_partners_sub": "Partner centers and sponsored nurses",
+41
View File
@@ -23,6 +23,7 @@
"payouts": "تسویه‌ها", "payouts": "تسویه‌ها",
"reviews": "نظرات", "reviews": "نظرات",
"config": "پیکربندی", "config": "پیکربندی",
"catalog": "کاتالوگ",
"holidays": "تعطیلات", "holidays": "تعطیلات",
"alerts": "هشدارها", "alerts": "هشدارها",
"audit": "گزارش ممیزی", "audit": "گزارش ممیزی",
@@ -1687,6 +1688,45 @@
"hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.", "hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.",
"hol_saved": "تعطیلی ذخیره شد.", "hol_saved": "تعطیلی ذخیره شد.",
"hol_year": "سال", "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_title": "هشدارهای پشتیبانی",
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.", "alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.",
"alert_empty": "هشداری برای رسیدگی نیست.", "alert_empty": "هشداری برای رسیدگی نیست.",
@@ -2104,6 +2144,7 @@
"admin_tickets_sub": "صف سراسری تیکت‌ها", "admin_tickets_sub": "صف سراسری تیکت‌ها",
"admin_alerts_sub": "کارتابل داخلی هشدارها", "admin_alerts_sub": "کارتابل داخلی هشدارها",
"admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها", "admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها",
"admin_catalog_sub": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری آن‌ها",
"admin_holidays_sub": "تقویم تعطیلات بانکی", "admin_holidays_sub": "تقویم تعطیلات بانکی",
"admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی", "admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی",
"admin_partners_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('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: '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('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('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('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 }, { 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, path: ROUTES.ADMIN_CONFIG,
enabled: caps.canConfig, enabled: caps.canConfig,
}, },
{
title: tn('catalog'),
subtitle: t('admin_catalog_sub'),
icon: 'category',
path: ROUTES.ADMIN_CATALOG,
enabled: caps.canManageCatalog,
},
{ {
title: tn('holidays'), title: tn('holidays'),
subtitle: t('admin_holidays_sub'), subtitle: t('admin_holidays_sub'),
+6
View File
@@ -108,6 +108,8 @@ export const ROUTES = {
ADMIN_REVIEWS: '/admin/reviews', ADMIN_REVIEWS: '/admin/reviews',
// Platform config editor + change history. // Platform config editor + change history.
ADMIN_CONFIG: '/admin/config', 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). // Iranian-holiday calendar manager (drives payout scheduling; is_bank_closed toggle).
ADMIN_HOLIDAYS: '/admin/holidays', ADMIN_HOLIDAYS: '/admin/holidays',
// Support-alert triage board — internal-only (assign/resolve). // 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 => export const adminPartnerCenterPath = (centerId: number | string): string =>
`${ROUTES.ADMIN_PARTNERS}/${centerId}`; `${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). */ /** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */
export const partnerBookingDetailPath = (bookingId: number | string): string => export const partnerBookingDetailPath = (bookingId: number | string): string =>
`${ROUTES.PARTNER_BOOKINGS}/${bookingId}`; `${ROUTES.PARTNER_BOOKINGS}/${bookingId}`;
+2
View File
@@ -23,6 +23,7 @@ export interface AdminCapabilities {
canPayout: boolean; canPayout: boolean;
canModerate: boolean; canModerate: boolean;
canConfig: boolean; canConfig: boolean;
canManageCatalog: boolean;
canManageAlerts: boolean; canManageAlerts: boolean;
canManageTickets: boolean; canManageTickets: boolean;
canManagePartners: boolean; canManagePartners: boolean;
@@ -57,6 +58,7 @@ export function useAdminCapabilities(): AdminCapabilities {
canPayout: has(roles, 'super_admin', 'admin', 'finance'), canPayout: has(roles, 'super_admin', 'admin', 'finance'),
canModerate: has(roles, 'super_admin', 'admin', 'moderation'), canModerate: has(roles, 'super_admin', 'admin', 'moderation'),
canConfig: has(roles, 'super_admin', 'admin', 'finance'), canConfig: has(roles, 'super_admin', 'admin', 'finance'),
canManageCatalog: has(roles, 'super_admin', 'admin'),
canManageAlerts: has(roles, 'super_admin', 'admin', 'support'), canManageAlerts: has(roles, 'super_admin', 'admin', 'support'),
canManageTickets: has(roles, 'super_admin', 'admin', 'support'), canManageTickets: has(roles, 'super_admin', 'admin', 'support'),
canManagePartners: has(roles, 'super_admin', 'admin'), canManagePartners: has(roles, 'super_admin', 'admin'),
+78 -1
View File
@@ -1,17 +1,23 @@
import { clientFetch } from '@/lib/api/client'; import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; 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 { import type {
CatalogApi, CatalogApi,
CategoryInput,
CreateOptionValueInput,
CreateVariantInput, CreateVariantInput,
NurseServiceVariant, NurseServiceVariant,
OptionGroupInput,
ServiceCategory, ServiceCategory,
ServiceOptionGroup, ServiceOptionGroup,
ServiceOptionValue,
UpdateOptionValueInput,
UpdateVariantInput, UpdateVariantInput,
} from '../types'; } from '../types';
const CATALOG_BASE = '/api/v1/catalog'; const CATALOG_BASE = '/api/v1/catalog';
const VARIANTS_BASE = '/api/v1/nurse_variants'; 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`). * 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 }), 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 { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types'; 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 { import {
isGroupApplicable, isGroupApplicable,
optionSetSignature, optionSetSignature,
type CatalogApi, type CatalogApi,
type CategoryInput,
type CreateOptionValueInput,
type CreateVariantInput, type CreateVariantInput,
type NurseServiceVariant, type NurseServiceVariant,
type OptionGroupInput,
type ServiceCategory, type ServiceCategory,
type ServiceOptionGroup, type ServiceOptionGroup,
type ServiceOptionValue, type ServiceOptionValue,
type UpdateOptionValueInput,
type UpdateVariantInput, type UpdateVariantInput,
type VariantOption, type VariantOption,
type VariantOptionSelection, type VariantOptionSelection,
@@ -27,6 +31,14 @@ const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder
let store: NurseServiceVariant[] = []; let store: NurseServiceVariant[] = [];
let nextVariantId = 1; 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. */ /** Active-first (contract list order), then most-recent within each group. */
const orderedVariants = () => const orderedVariants = () =>
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id); [...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[] { function applicableGroups(categoryId: number): ServiceOptionGroup[] {
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort( return groupStore.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(bySortOrder);
bySortOrder,
);
} }
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined { function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
@@ -128,7 +138,7 @@ function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup
export const catalogMockApi: CatalogApi = { export const catalogMockApi: CatalogApi = {
listCategories: async (params) => { listCategories: async (params) => {
await sleep(MOCK_LATENCY_MS); 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); return paginate(active, params, CATEGORIES_PAGE_SIZE);
}, },
@@ -155,7 +165,7 @@ export const catalogMockApi: CatalogApi = {
createVariant: async (input) => { createVariant: async (input) => {
await sleep(MOCK_LATENCY_MS); 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'); if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
const groups = applicableGroups(category.id); const groups = applicableGroups(category.id);
@@ -205,4 +215,92 @@ export const catalogMockApi: CatalogApi = {
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found'); if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate)); 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. */ /** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
export const CATEGORIES_PAGE_SIZE = 50; export const CATEGORIES_PAGE_SIZE = 50;
export const MY_VARIANTS_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 { useCreateVariant } from './hooks/useCreateVariant';
export { useUpdateVariant } from './hooks/useUpdateVariant'; export { useUpdateVariant } from './hooks/useUpdateVariant';
export { useSetVariantActive } from './hooks/useSetVariantActive'; 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. // Reference data — cached for the whole session, never invalidated by this phase.
categories: () => [...catalogKeys.all, 'categories'] as const, 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) => 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. // The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
variants: () => [...catalogKeys.all, 'variants'] as const, variants: () => [...catalogKeys.all, 'variants'] as const,
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const, myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const, myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] 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; 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 * 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. * 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>; updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
/** Soft deactivate/reactivate — never a hard delete. */ /** Soft deactivate/reactivate — never a hard delete. */
setVariantActive(id: number, isActive: boolean): Promise<void>; 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" 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)* 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 ## 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.UpdateServiceCategory;
using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup; using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup;
using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue; 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.Catalog;
using Baya.Application.Models.Common;
using Baya.Infrastructure.Identity.Identity.PermissionManager; using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; 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)")] [Display(Description = "Admin: curate the catalog skeleton (categories + option groups/values; no delete)")]
public sealed class AdminCatalogController(ISender sender) : BaseController 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]")] [HttpPost("[action]")]
[ProducesOkApiResponseType<ServiceCategoryDto>] [ProducesOkApiResponseType<ServiceCategoryDto>]
public async Task<IActionResult> CreateCategory(CreateServiceCategoryCommand command, CancellationToken cancellationToken) 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> /// its active values, ordered by sort order. Empty is valid (no dimensions defined yet).</summary>
Task<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken); 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> /// <summary>Active-only category projection for variant creation — null if missing or deactivated.</summary>
Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken); 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())) .ToList()))
.ToListAsync(cancellationToken); .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) public Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceCategory>() => _db.Set<ServiceCategory>()
.AsNoTracking() .AsNoTracking()