blocker phase 6, catalog admin
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
using Baya.Application.Contracts.Persistence;
|
||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
|
||||||
|
|
||||||
|
internal sealed class GetAdminOptionGroupsQueryHandler(IUnitOfWork unitOfWork)
|
||||||
|
: IRequestHandler<GetAdminOptionGroupsQuery, OperationResult<IReadOnlyList<OptionGroupDto>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<IReadOnlyList<OptionGroupDto>>> Handle(GetAdminOptionGroupsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var groups = await unitOfWork.CatalogRepository.GetAllApplicableGroupsAsync(request.CategoryId, cancellationToken);
|
||||||
|
return OperationResult<IReadOnlyList<OptionGroupDto>>.SuccessResult(groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin: a category's applicable option groups — its own plus every cross-category (NULL) group —
|
||||||
|
/// <b>including inactive groups, each with every value including inactive ones</b>. The read side of
|
||||||
|
/// group/value management; the public <c>GetCategoryOptionGroupsQuery</c> deliberately hides both.
|
||||||
|
/// </summary>
|
||||||
|
public record GetAdminOptionGroupsQuery(long CategoryId) : IRequest<OperationResult<IReadOnlyList<OptionGroupDto>>>;
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
using Baya.Application.Common;
|
||||||
|
using Baya.Application.Contracts.Persistence;
|
||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
|
||||||
|
|
||||||
|
internal sealed class GetAdminServiceCategoriesQueryHandler(IUnitOfWork unitOfWork)
|
||||||
|
: IRequestHandler<GetAdminServiceCategoriesQuery, OperationResult<PagedResult<ServiceCategoryDto>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<PagedResult<ServiceCategoryDto>>> Handle(GetAdminServiceCategoriesQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||||
|
|
||||||
|
// Unlike the public read, this is deliberately uncached — an admin editing a category expects to
|
||||||
|
// see their own write reflected immediately, and admin list traffic is negligible.
|
||||||
|
var all = await unitOfWork.CatalogRepository.ListAllCategoriesAsync(cancellationToken);
|
||||||
|
var items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||||
|
|
||||||
|
return OperationResult<PagedResult<ServiceCategoryDto>>.SuccessResult(
|
||||||
|
new PagedResult<ServiceCategoryDto>(items, all.Count, page, pageSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
|
||||||
|
|
||||||
|
/// <summary>Admin: every category regardless of active state, ordered by sort order, paginated — the read
|
||||||
|
/// side of category management (a deactivated category must stay reachable to review or reactivate).</summary>
|
||||||
|
public record GetAdminServiceCategoriesQuery(int Page = 1, int PageSize = 50)
|
||||||
|
: IRequest<OperationResult<PagedResult<ServiceCategoryDto>>>;
|
||||||
+30
@@ -45,6 +45,36 @@ internal sealed class CatalogRepository : ICatalogRepository
|
|||||||
.ToList()))
|
.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()
|
||||||
|
|||||||
Reference in New Issue
Block a user