diff --git a/client/messages/en.json b/client/messages/en.json index 96fce49..b7a70e9 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -23,6 +23,7 @@ "payouts": "Payouts", "reviews": "Reviews", "config": "Configuration", + "catalog": "Catalog", "holidays": "Holidays", "alerts": "Alerts", "audit": "Audit log", @@ -1687,6 +1688,45 @@ "hol_bank_hint": "When on, payouts falling on this day shift to the next business day.", "hol_saved": "Holiday saved.", "hol_year": "Year", + "status_active": "Active", + "status_inactive": "Inactive", + "cat_title": "Catalog", + "cat_subtitle": "Categories and the pricing dimensions nurses build their offerings from.", + "cat_empty": "No categories yet.", + "cat_col_name": "Category", + "cat_col_order": "Order", + "cat_col_status": "Status", + "cat_add": "Add category", + "cat_edit": "Edit", + "cat_activate": "Activate", + "cat_deactivate": "Deactivate", + "cat_activated": "Category activated.", + "cat_deactivated": "Category deactivated.", + "cat_saved": "Category saved.", + "cat_not_found": "This category could not be found.", + "cat_name_fa": "Name (Persian)", + "cat_name_en": "Name (English)", + "cat_description_fa": "Description (Persian)", + "cat_description_en": "Description (English)", + "cat_icon_key": "Icon key (optional)", + "cat_icon_hint": "Matches a name in the icon registry; an unknown or missing key falls back gracefully.", + "cat_sort_order": "Sort order", + "og_section_title": "Pricing options", + "og_empty": "No pricing dimensions yet.", + "og_add": "Add dimension", + "og_edit": "Edit", + "og_cross_category_badge": "All categories", + "og_required_badge": "Required", + "og_optional_badge": "Optional", + "og_scope_all": "Applies to all categories", + "og_scope_all_hint": "A cross-category dimension is offered for every category, not just this one.", + "og_scope_this_hint": "Offered only for this category.", + "og_is_required": "Must be answered before booking", + "og_saved": "Pricing dimension saved.", + "og_value_add": "Add value", + "og_value_edit": "Edit value", + "og_value_saved": "Value saved.", + "og_value_empty": "No values yet.", "alert_title": "Support alerts", "alert_subtitle": "Internal triage — never shown to customers or nurses.", "alert_empty": "No open alerts.", @@ -2104,6 +2144,7 @@ "admin_tickets_sub": "The global ticket queue", "admin_alerts_sub": "The internal alert worklist", "admin_config_sub": "Config keys and their change history", + "admin_catalog_sub": "Categories and their pricing dimensions", "admin_holidays_sub": "The bank-holiday calendar", "admin_audit_sub": "Read-only record of every change", "admin_partners_sub": "Partner centers and sponsored nurses", diff --git a/client/messages/fa.json b/client/messages/fa.json index b383c03..6922b8e 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -23,6 +23,7 @@ "payouts": "تسویه‌ها", "reviews": "نظرات", "config": "پیکربندی", + "catalog": "کاتالوگ", "holidays": "تعطیلات", "alerts": "هشدارها", "audit": "گزارش ممیزی", @@ -1687,6 +1688,45 @@ "hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.", "hol_saved": "تعطیلی ذخیره شد.", "hol_year": "سال", + "status_active": "فعال", + "status_inactive": "غیرفعال", + "cat_title": "کاتالوگ خدمات", + "cat_subtitle": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری که پرستاران خدمات خود را بر اساس آن‌ها می‌سازند.", + "cat_empty": "هنوز دسته‌بندی‌ای ثبت نشده است.", + "cat_col_name": "دسته‌بندی", + "cat_col_order": "ترتیب", + "cat_col_status": "وضعیت", + "cat_add": "افزودن دسته‌بندی", + "cat_edit": "ویرایش", + "cat_activate": "فعال‌سازی", + "cat_deactivate": "غیرفعال‌سازی", + "cat_activated": "دسته‌بندی فعال شد.", + "cat_deactivated": "دسته‌بندی غیرفعال شد.", + "cat_saved": "دسته‌بندی ذخیره شد.", + "cat_not_found": "این دسته‌بندی پیدا نشد.", + "cat_name_fa": "نام (فارسی)", + "cat_name_en": "نام (انگلیسی)", + "cat_description_fa": "توضیحات (فارسی)", + "cat_description_en": "توضیحات (انگلیسی)", + "cat_icon_key": "کلید آیکون (اختیاری)", + "cat_icon_hint": "باید با یکی از نام‌های ثبت‌شدهٔ آیکون مطابقت داشته باشد؛ کلید ناشناخته یا خالی به‌صورت خودکار جایگزین می‌شود.", + "cat_sort_order": "ترتیب نمایش", + "og_section_title": "ابعاد قیمت‌گذاری", + "og_empty": "هنوز بُعد قیمت‌گذاری‌ای ثبت نشده است.", + "og_add": "افزودن بُعد", + "og_edit": "ویرایش", + "og_cross_category_badge": "همهٔ دسته‌بندی‌ها", + "og_required_badge": "اجباری", + "og_optional_badge": "اختیاری", + "og_scope_all": "برای همهٔ دسته‌بندی‌ها اعمال شود", + "og_scope_all_hint": "یک بُعد بین‌دسته‌ای برای همهٔ دسته‌بندی‌ها نمایش داده می‌شود، نه فقط همین یکی.", + "og_scope_this_hint": "فقط برای همین دسته‌بندی نمایش داده می‌شود.", + "og_is_required": "پیش از رزرو باید پاسخ داده شود", + "og_saved": "بُعد قیمت‌گذاری ذخیره شد.", + "og_value_add": "افزودن مقدار", + "og_value_edit": "ویرایش مقدار", + "og_value_saved": "مقدار ذخیره شد.", + "og_value_empty": "هنوز مقداری ثبت نشده است.", "alert_title": "هشدارهای پشتیبانی", "alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.", "alert_empty": "هشداری برای رسیدگی نیست.", @@ -2104,6 +2144,7 @@ "admin_tickets_sub": "صف سراسری تیکت‌ها", "admin_alerts_sub": "کارتابل داخلی هشدارها", "admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها", + "admin_catalog_sub": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری آن‌ها", "admin_holidays_sub": "تقویم تعطیلات بانکی", "admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی", "admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش", diff --git a/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx b/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx index e4e8ef9..4bb5e4e 100644 --- a/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx @@ -35,6 +35,7 @@ export default function AdminOverviewScreen() { { section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets }, { section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts }, { section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig }, + { section: 'system', title: tNav('catalog'), subtitle: th('admin_catalog_sub'), path: ROUTES.ADMIN_CATALOG, icon: 'category', enabled: caps.canManageCatalog }, { section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig }, { section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit }, { section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners }, diff --git a/client/src/app/[locale]/(private-routes)/admin/catalog/[categoryId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/catalog/[categoryId]/page.tsx new file mode 100644 index 0000000..6d56c12 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/catalog/[categoryId]/page.tsx @@ -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(null); + const [valueDialog, setValueDialog] = useState<{ groupId: number; value: ServiceOptionValue | 'new' } | null>(null); + + const back = ; + + if (categories.isLoading) { + return ( + + {back} + + + + ); + } + + if (categories.isError) { + return ( + + {back} + categories.refetch()} /> + + ); + } + + if (!category) { + return ( + + {back} + + + ); + } + + const onToggleActive = () => { + setActive.mutate( + { id: category.id, isActive: !category.isActive }, + { onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) }, + ); + }; + + return ( + + } + /> + + {category.descriptionFa || category.descriptionEn ? ( + + + {category.descriptionFa || category.descriptionEn} + + + ) : null} + + {caps.canManageCatalog ? ( + + setEditingCategory(true)}> + {t('cat_edit')} + + + {t(category.isActive ? 'cat_deactivate' : 'cat_activate')} + + + ) : null} + + + + + {t('og_section_title')} + + {caps.canManageCatalog ? ( + setGroupDialog('new')}> + {t('og_add')} + + ) : null} + + + {groups.isLoading ? ( + + ) : groups.isError ? ( + groups.refetch()} /> + ) : (groups.data ?? []).length === 0 ? ( + + ) : ( + (groups.data ?? []).map((group) => ( + + + + + + {group.nameFa} · {group.nameEn} + + + {group.serviceCategoryId == null ? : null} + + + + {caps.canManageCatalog ? ( + setGroupDialog(group)}> + {t('cat_edit')} + + ) : null} + + + + + + {group.values.length === 0 ? ( + + {t('og_value_empty')} + + ) : ( + group.values.map((value) => ( + + + + {value.nameFa} · {value.nameEn} + + + + {caps.canManageCatalog ? ( + setValueDialog({ groupId: group.id, value })}> + {t('cat_edit')} + + ) : null} + + )) + )} + + + {caps.canManageCatalog ? ( + setValueDialog({ groupId: group.id, value: 'new' })} + sx={{ alignSelf: 'flex-start' }} + > + {t('og_value_add')} + + ) : null} + + + )) + )} + + + {editingCategory ? setEditingCategory(false)} /> : null} + {groupDialog ? ( + setGroupDialog(null)} + /> + ) : null} + {valueDialog ? ( + setValueDialog(null)} + /> + ) : null} + + ); +} + +/** `'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({ 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 ( + + {isEdit ? t('og_edit') : t('og_add')} + + + {/* A real
so Enter submits from any field; the footer button (outside DialogContent) + calls the same handler directly rather than relying on cross-element form association. */} + + + name="nameFa" + label={t('cat_name_fa')} + rules={{ validate: (value) => String(value ?? '').trim() !== '' }} + /> + + name="nameEn" + label={t('cat_name_en')} + rules={{ validate: (value) => String(value ?? '').trim() !== '' }} + /> + + name="scope"> + {({ field }) => ( + field.onChange(event.target.checked ? 'all' : 'this')} + /> + } + label={t('og_scope_all')} + /> + )} + + + {scope === 'all' ? t('og_scope_all_hint') : t('og_scope_this_hint')} + + + name="isRequired"> + {({ field }) => ( + field.onChange(event.target.checked)} />} + label={t('og_is_required')} + /> + )} + + + name="sortOrder" + type="number" + label={t('cat_sort_order')} + rules={{ validate: (value) => Number.isFinite(Number(value)) }} + slotProps={{ htmlInput: { step: 1 } }} + /> + + + + + {t('cancel')} + + + {isPending ? t('saving') : t('save')} + + + +
+ ); +} + +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({ 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 ( + + {isEdit ? t('og_value_edit') : t('og_value_add')} + + + + + name="nameFa" + label={t('cat_name_fa')} + rules={{ validate: (val) => String(val ?? '').trim() !== '' }} + /> + + name="nameEn" + label={t('cat_name_en')} + rules={{ validate: (val) => String(val ?? '').trim() !== '' }} + /> + + name="sortOrder" + type="number" + label={t('cat_sort_order')} + rules={{ validate: (val) => Number.isFinite(Number(val)) }} + slotProps={{ htmlInput: { step: 1 } }} + /> + {isEdit ? ( + name="isActive"> + {({ field }) => ( + field.onChange(event.target.checked)} />} + label={t(field.value ? 'status_active' : 'status_inactive')} + /> + )} + + ) : null} + + + + + {t('cancel')} + + + {isPending ? t('saving') : t('save')} + + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/catalog/page.tsx b/client/src/app/[locale]/(private-routes)/admin/catalog/page.tsx new file mode 100644 index 0000000..2ab4146 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/catalog/page.tsx @@ -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; +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 ( + }> + + + ); +} + +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({ + 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[] = [ + { 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) => , + }, + ...(caps.canManageCatalog + ? [ + { + key: 'actions', + header: '', + render: (c: ServiceCategory) => ( + + { + event.stopPropagation(); + onToggleActive(c); + }} + disabled={setActive.isPending} + > + {t(c.isActive ? 'cat_deactivate' : 'cat_activate')} + + + ), + } as AdminTableColumn, + ] + : []), + ]; + + return ( + + setCreating(true)}> + {t('cat_add')} + + ) : undefined + } + /> + + {categories.isLoading ? ( + + ) : categories.isError ? ( + categories.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + c.id} + ariaLabel={t('cat_title')} + onRowClick={(c) => router.push(`/${locale}${adminCatalogCategoryPath(c.id)}`)} + footer={total > 0 ? t('showing_range', { from, to, total }) : undefined} + /> + )} + + 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 ? setCreating(false)} /> : null} + + ); +} + +/** 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({ 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 ( + + {isEdit ? t('cat_edit') : t('cat_add')} + + + {/* A real so Enter submits from any field; the footer button (outside DialogContent) + calls the same handler directly rather than relying on cross-element form association. */} + + + name="nameFa" + label={t('cat_name_fa')} + rules={{ validate: (value) => String(value ?? '').trim() !== '' }} + /> + + name="nameEn" + label={t('cat_name_en')} + rules={{ validate: (value) => String(value ?? '').trim() !== '' }} + /> + name="descriptionFa" label={t('cat_description_fa')} multiline minRows={2} /> + name="descriptionEn" label={t('cat_description_en')} multiline minRows={2} /> + name="iconKey" label={t('cat_icon_key')} helperText={t('cat_icon_hint')} slotProps={{ htmlInput: { dir: 'ltr' } }} /> + + name="sortOrder" + type="number" + label={t('cat_sort_order')} + rules={{ validate: (value) => Number.isFinite(Number(value)) }} + slotProps={{ htmlInput: { step: 1 } }} + /> + + + + + {t('cancel')} + + + {isPending ? t('saving') : t('save')} + + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/system/page.tsx b/client/src/app/[locale]/(private-routes)/admin/system/page.tsx index 7769cf6..adbc0a8 100644 --- a/client/src/app/[locale]/(private-routes)/admin/system/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/system/page.tsx @@ -34,6 +34,13 @@ export default function AdminSystemPage() { path: ROUTES.ADMIN_CONFIG, enabled: caps.canConfig, }, + { + title: tn('catalog'), + subtitle: t('admin_catalog_sub'), + icon: 'category', + path: ROUTES.ADMIN_CATALOG, + enabled: caps.canManageCatalog, + }, { title: tn('holidays'), subtitle: t('admin_holidays_sub'), diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 17b4118..8856e55 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -108,6 +108,8 @@ export const ROUTES = { ADMIN_REVIEWS: '/admin/reviews', // Platform config editor + change history. ADMIN_CONFIG: '/admin/config', + // Catalog skeleton editor — categories + a category's option groups/values; append `/{categoryId}`. + ADMIN_CATALOG: '/admin/catalog', // Iranian-holiday calendar manager (drives payout scheduling; is_bank_closed toggle). ADMIN_HOLIDAYS: '/admin/holidays', // Support-alert triage board — internal-only (assign/resolve). @@ -144,6 +146,10 @@ export const adminPayoutBatchPath = (batchId: number | string): string => export const adminPartnerCenterPath = (centerId: number | string): string => `${ROUTES.ADMIN_PARTNERS}/${centerId}`; +/** The admin catalog category detail — its option groups/values, including inactive ones. */ +export const adminCatalogCategoryPath = (categoryId: number | string): string => + `${ROUTES.ADMIN_CATALOG}/${categoryId}`; + /** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */ export const partnerBookingDetailPath = (bookingId: number | string): string => `${ROUTES.PARTNER_BOOKINGS}/${bookingId}`; diff --git a/client/src/hooks/capabilities.ts b/client/src/hooks/capabilities.ts index 30058e5..a0fd5f3 100644 --- a/client/src/hooks/capabilities.ts +++ b/client/src/hooks/capabilities.ts @@ -23,6 +23,7 @@ export interface AdminCapabilities { canPayout: boolean; canModerate: boolean; canConfig: boolean; + canManageCatalog: boolean; canManageAlerts: boolean; canManageTickets: boolean; canManagePartners: boolean; @@ -57,6 +58,7 @@ export function useAdminCapabilities(): AdminCapabilities { canPayout: has(roles, 'super_admin', 'admin', 'finance'), canModerate: has(roles, 'super_admin', 'admin', 'moderation'), canConfig: has(roles, 'super_admin', 'admin', 'finance'), + canManageCatalog: has(roles, 'super_admin', 'admin'), canManageAlerts: has(roles, 'super_admin', 'admin', 'support'), canManageTickets: has(roles, 'super_admin', 'admin', 'support'), canManagePartners: has(roles, 'super_admin', 'admin'), diff --git a/client/src/services/catalog/apis/clientApi.ts b/client/src/services/catalog/apis/clientApi.ts index ba7b2cd..ba24b66 100644 --- a/client/src/services/catalog/apis/clientApi.ts +++ b/client/src/services/catalog/apis/clientApi.ts @@ -1,17 +1,23 @@ import { clientFetch } from '@/lib/api/client'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; -import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants'; +import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants'; import type { CatalogApi, + CategoryInput, + CreateOptionValueInput, CreateVariantInput, NurseServiceVariant, + OptionGroupInput, ServiceCategory, ServiceOptionGroup, + ServiceOptionValue, + UpdateOptionValueInput, UpdateVariantInput, } from '../types'; const CATALOG_BASE = '/api/v1/catalog'; const VARIANTS_BASE = '/api/v1/nurse_variants'; +const ADMIN_CATALOG_BASE = '/api/v1/admin_catalog'; /** * Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`). @@ -73,4 +79,75 @@ export const catalogClientApi: CatalogApi = { body: JSON.stringify({ isActive }), }); }, + + adminListCategories: async (params) => { + const query = new URLSearchParams(); + query.set('page', String(params?.page ?? 1)); + query.set('pageSize', String(params?.pageSize ?? ADMIN_CATEGORIES_PAGE_SIZE)); + return unwrap( + await clientFetch>>(`${ADMIN_CATALOG_BASE}/list_categories?${query.toString()}`), + ); + }, + + createCategory: async (input: CategoryInput) => + unwrap( + await clientFetch>(`${ADMIN_CATALOG_BASE}/create_category`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + updateCategory: async (id, input: CategoryInput) => + unwrap( + await clientFetch>(`${ADMIN_CATALOG_BASE}/update_category/${id}`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + setCategoryActive: async (id, isActive) => { + await clientFetch>(`${ADMIN_CATALOG_BASE}/set_category_active/${id}`, { + method: 'POST', + body: JSON.stringify({ isActive }), + }); + }, + + adminListOptionGroups: async (categoryId) => + unwrap( + await clientFetch>( + `${ADMIN_CATALOG_BASE}/list_option_groups?category_id=${categoryId}`, + ), + ), + + createOptionGroup: async (input: OptionGroupInput) => + unwrap( + await clientFetch>(`${ADMIN_CATALOG_BASE}/create_option_group`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + updateOptionGroup: async (id, input: OptionGroupInput) => + unwrap( + await clientFetch>(`${ADMIN_CATALOG_BASE}/update_option_group/${id}`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + createOptionValue: async (input: CreateOptionValueInput) => + unwrap( + await clientFetch>(`${ADMIN_CATALOG_BASE}/create_option_value`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + updateOptionValue: async (id, input: UpdateOptionValueInput) => + unwrap( + await clientFetch>(`${ADMIN_CATALOG_BASE}/update_option_value/${id}`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), }; diff --git a/client/src/services/catalog/apis/mockApi.ts b/client/src/services/catalog/apis/mockApi.ts index a2fdf4a..0537945 100644 --- a/client/src/services/catalog/apis/mockApi.ts +++ b/client/src/services/catalog/apis/mockApi.ts @@ -1,16 +1,20 @@ import { sleep } from '@/utils'; import { ApiError } from '@/lib/api/errors'; import type { PageParams, Paginated } from '@/lib/api/types'; -import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants'; +import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants'; import { isGroupApplicable, optionSetSignature, type CatalogApi, + type CategoryInput, + type CreateOptionValueInput, type CreateVariantInput, type NurseServiceVariant, + type OptionGroupInput, type ServiceCategory, type ServiceOptionGroup, type ServiceOptionValue, + type UpdateOptionValueInput, type UpdateVariantInput, type VariantOption, type VariantOptionSelection, @@ -27,6 +31,14 @@ const bySortOrder = (a: T, b: T) => a.sortOrder let store: NurseServiceVariant[] = []; let nextVariantId = 1; +// The admin-managed skeleton — seeded as a mutable copy so a create/update/deactivate in the admin +// console is reflected back through the public read methods below, exactly like the real DB. +let categoryStore: ServiceCategory[] = SEED_CATEGORIES.map((category) => ({ ...category })); +let groupStore: ServiceOptionGroup[] = SEED_OPTION_GROUPS.map((group) => ({ ...group, values: [...group.values] })); +let nextCategoryId = Math.max(0, ...categoryStore.map((c) => c.id)) + 1; +let nextGroupId = Math.max(0, ...groupStore.map((g) => g.id)) + 1; +let nextValueId = Math.max(0, ...groupStore.flatMap((g) => g.values.map((v) => v.id))) + 1; + /** Active-first (contract list order), then most-recent within each group. */ const orderedVariants = () => [...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id); @@ -39,9 +51,7 @@ function paginate(all: T[], params?: PageParams, defaultSize = 50): Paginated } function applicableGroups(categoryId: number): ServiceOptionGroup[] { - return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort( - bySortOrder, - ); + return groupStore.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(bySortOrder); } function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined { @@ -128,7 +138,7 @@ function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup export const catalogMockApi: CatalogApi = { listCategories: async (params) => { await sleep(MOCK_LATENCY_MS); - const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder); + const active = categoryStore.filter((category) => category.isActive).sort(bySortOrder); return paginate(active, params, CATEGORIES_PAGE_SIZE); }, @@ -155,7 +165,7 @@ export const catalogMockApi: CatalogApi = { createVariant: async (input) => { await sleep(MOCK_LATENCY_MS); - const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId); + const category = categoryStore.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId); if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category'); const groups = applicableGroups(category.id); @@ -205,4 +215,92 @@ export const catalogMockApi: CatalogApi = { if (!existing) throw new ApiError(404, 'Variant not found', 'not_found'); store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate)); }, + + adminListCategories: async (params) => { + await sleep(MOCK_LATENCY_MS); + return paginate([...categoryStore].sort(bySortOrder), params, ADMIN_CATEGORIES_PAGE_SIZE); + }, + + createCategory: async (input: CategoryInput) => { + await sleep(MOCK_LATENCY_MS); + const category: ServiceCategory = { id: nextCategoryId++, ...input, isActive: true }; + categoryStore = [...categoryStore, category]; + return category; + }, + + updateCategory: async (id, input: CategoryInput) => { + await sleep(MOCK_LATENCY_MS); + const existing = categoryStore.find((candidate) => candidate.id === id); + if (!existing) throw new ApiError(404, 'Category not found', 'not_found'); + const updated: ServiceCategory = { ...existing, ...input }; + categoryStore = categoryStore.map((candidate) => (candidate.id === id ? updated : candidate)); + return updated; + }, + + setCategoryActive: async (id, isActive) => { + await sleep(MOCK_LATENCY_MS); + const existing = categoryStore.find((candidate) => candidate.id === id); + if (!existing) throw new ApiError(404, 'Category not found', 'not_found'); + categoryStore = categoryStore.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate)); + }, + + adminListOptionGroups: async (categoryId) => { + await sleep(MOCK_LATENCY_MS); + return groupStore + .filter((group) => isGroupApplicable(group, categoryId)) + .sort(bySortOrder) + .map((group) => ({ ...group, values: [...group.values].sort(bySortOrder) })); + }, + + createOptionGroup: async (input: OptionGroupInput) => { + await sleep(MOCK_LATENCY_MS); + if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) { + throw new ApiError(400, 'Category not found', 'invalid_category'); + } + const group: ServiceOptionGroup = { id: nextGroupId++, ...input, isActive: true, values: [] }; + groupStore = [...groupStore, group]; + return group; + }, + + updateOptionGroup: async (id, input: OptionGroupInput) => { + await sleep(MOCK_LATENCY_MS); + const existing = groupStore.find((candidate) => candidate.id === id); + if (!existing) throw new ApiError(404, 'Option group not found', 'not_found'); + if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) { + throw new ApiError(400, 'Category not found', 'invalid_category'); + } + const updated: ServiceOptionGroup = { ...existing, ...input }; + groupStore = groupStore.map((candidate) => (candidate.id === id ? updated : candidate)); + return updated; + }, + + createOptionValue: async (input: CreateOptionValueInput) => { + await sleep(MOCK_LATENCY_MS); + const group = groupStore.find((candidate) => candidate.id === input.optionGroupId); + if (!group) throw new ApiError(400, 'Option group not found', 'invalid_group'); + const value: ServiceOptionValue = { + id: nextValueId++, + nameFa: input.nameFa, + nameEn: input.nameEn, + sortOrder: input.sortOrder, + isActive: true, + }; + groupStore = groupStore.map((candidate) => + candidate.id === group.id ? { ...candidate, values: [...candidate.values, value] } : candidate, + ); + return value; + }, + + updateOptionValue: async (id, input: UpdateOptionValueInput) => { + await sleep(MOCK_LATENCY_MS); + const group = groupStore.find((candidate) => candidate.values.some((value) => value.id === id)); + if (!group) throw new ApiError(404, 'Option value not found', 'not_found'); + const updated: ServiceOptionValue = { id, ...input }; + groupStore = groupStore.map((candidate) => + candidate.id === group.id + ? { ...candidate, values: candidate.values.map((value) => (value.id === id ? updated : value)) } + : candidate, + ); + return updated; + }, }; diff --git a/client/src/services/catalog/constants.ts b/client/src/services/catalog/constants.ts index 10db9f9..3047d0c 100644 --- a/client/src/services/catalog/constants.ts +++ b/client/src/services/catalog/constants.ts @@ -22,3 +22,10 @@ export const MY_VARIANTS_STALE_TIME = 60_000; /** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */ export const CATEGORIES_PAGE_SIZE = 50; export const MY_VARIANTS_PAGE_SIZE = 50; + +/** + * The admin catalog-management reads (categories incl. inactive; a category's groups/values incl. + * inactive) are deliberately uncached (`staleTime: 0`) — an admin editing the skeleton expects every + * write reflected immediately, and this console's traffic is negligible next to the public browse. + */ +export const ADMIN_CATEGORIES_PAGE_SIZE = 50; diff --git a/client/src/services/catalog/hooks/useAdminCategories.ts b/client/src/services/catalog/hooks/useAdminCategories.ts new file mode 100644 index 0000000..4939545 --- /dev/null +++ b/client/src/services/catalog/hooks/useAdminCategories.ts @@ -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, + }); +} diff --git a/client/src/services/catalog/hooks/useAdminOptionGroups.ts b/client/src/services/catalog/hooks/useAdminOptionGroups.ts new file mode 100644 index 0000000..5756dcc --- /dev/null +++ b/client/src/services/catalog/hooks/useAdminOptionGroups.ts @@ -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, + }); +} diff --git a/client/src/services/catalog/hooks/useCreateCategory.ts b/client/src/services/catalog/hooks/useCreateCategory.ts new file mode 100644 index 0000000..10f21d1 --- /dev/null +++ b/client/src/services/catalog/hooks/useCreateCategory.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useCreateOptionGroup.ts b/client/src/services/catalog/hooks/useCreateOptionGroup.ts new file mode 100644 index 0000000..8dadae4 --- /dev/null +++ b/client/src/services/catalog/hooks/useCreateOptionGroup.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useCreateOptionValue.ts b/client/src/services/catalog/hooks/useCreateOptionValue.ts new file mode 100644 index 0000000..efcae05 --- /dev/null +++ b/client/src/services/catalog/hooks/useCreateOptionValue.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useSetCategoryActive.ts b/client/src/services/catalog/hooks/useSetCategoryActive.ts new file mode 100644 index 0000000..7dbd877 --- /dev/null +++ b/client/src/services/catalog/hooks/useSetCategoryActive.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useUpdateCategory.ts b/client/src/services/catalog/hooks/useUpdateCategory.ts new file mode 100644 index 0000000..78ae331 --- /dev/null +++ b/client/src/services/catalog/hooks/useUpdateCategory.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useUpdateOptionGroup.ts b/client/src/services/catalog/hooks/useUpdateOptionGroup.ts new file mode 100644 index 0000000..b4bf387 --- /dev/null +++ b/client/src/services/catalog/hooks/useUpdateOptionGroup.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/catalog/hooks/useUpdateOptionValue.ts b/client/src/services/catalog/hooks/useUpdateOptionValue.ts new file mode 100644 index 0000000..a1b3012 --- /dev/null +++ b/client/src/services/catalog/hooks/useUpdateOptionValue.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/catalog/index.ts b/client/src/services/catalog/index.ts index 3b40d97..d708482 100644 --- a/client/src/services/catalog/index.ts +++ b/client/src/services/catalog/index.ts @@ -4,3 +4,12 @@ export { useMyVariants } from './hooks/useMyVariants'; export { useCreateVariant } from './hooks/useCreateVariant'; export { useUpdateVariant } from './hooks/useUpdateVariant'; export { useSetVariantActive } from './hooks/useSetVariantActive'; +export { useAdminCategories } from './hooks/useAdminCategories'; +export { useCreateCategory } from './hooks/useCreateCategory'; +export { useUpdateCategory } from './hooks/useUpdateCategory'; +export { useSetCategoryActive } from './hooks/useSetCategoryActive'; +export { useAdminOptionGroups } from './hooks/useAdminOptionGroups'; +export { useCreateOptionGroup } from './hooks/useCreateOptionGroup'; +export { useUpdateOptionGroup } from './hooks/useUpdateOptionGroup'; +export { useCreateOptionValue } from './hooks/useCreateOptionValue'; +export { useUpdateOptionValue } from './hooks/useUpdateOptionValue'; diff --git a/client/src/services/catalog/keys.ts b/client/src/services/catalog/keys.ts index b5d826d..eac64b5 100644 --- a/client/src/services/catalog/keys.ts +++ b/client/src/services/catalog/keys.ts @@ -11,12 +11,26 @@ export const catalogKeys = { // Reference data — cached for the whole session, never invalidated by this phase. categories: () => [...catalogKeys.all, 'categories'] as const, + /** Prefix shared by every `categoryOptionGroups(id)` key — invalidate this to catch every cached + * category when a group/value edit is cross-category (its `serviceCategoryId` is null) or its exact + * category set isn't known to the caller. */ + categoryOptionGroupsAll: () => [...catalogKeys.all, 'option-groups'] as const, categoryOptionGroups: (categoryId?: number | null) => - [...catalogKeys.all, 'option-groups', categoryId ?? null] as const, + [...catalogKeys.categoryOptionGroupsAll(), categoryId ?? null] as const, // The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix. variants: () => [...catalogKeys.all, 'variants'] as const, myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const, myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const, variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const, + + // Admin catalog management — separate from the public/cached reference-data keys above so a mutation + // invalidating one never stales the other's cache entry. + admin: () => [...catalogKeys.all, 'admin'] as const, + adminCategoryLists: () => [...catalogKeys.admin(), 'categories'] as const, + adminCategories: (params?: PageParams) => [...catalogKeys.adminCategoryLists(), params ?? {}] as const, + /** Prefix shared by every `adminOptionGroups(id)` key — see `categoryOptionGroupsAll`'s doc for why a + * group/value mutation invalidates this prefix instead of a single category id. */ + adminOptionGroupsAll: () => [...catalogKeys.admin(), 'option-groups'] as const, + adminOptionGroups: (categoryId: number) => [...catalogKeys.adminOptionGroupsAll(), categoryId] as const, }; diff --git a/client/src/services/catalog/types.ts b/client/src/services/catalog/types.ts index f0c0ec2..39305e8 100644 --- a/client/src/services/catalog/types.ts +++ b/client/src/services/catalog/types.ts @@ -121,6 +121,47 @@ export interface UpdateVariantInput { displayName?: string | null; } +// ── Admin write models (AdminCatalogController — categories/option groups/values management) ─────────── +/** `CreateServiceCategoryCommand`/`UpdateServiceCategoryCommand` body. */ +export interface CategoryInput { + nameFa: string; + nameEn: string; + descriptionFa: string | null; + descriptionEn: string | null; + iconKey: string | null; + sortOrder: number; +} + +/** `CreateServiceOptionGroupCommand`/`UpdateServiceOptionGroupCommand` body — `serviceCategoryId = null` + * makes the group cross-category (applies to every category), exactly like the read side. */ +export interface OptionGroupInput { + serviceCategoryId: number | null; + nameFa: string; + nameEn: string; + isRequired: boolean; + sortOrder: number; +} + +/** `CreateServiceOptionValueCommand` body. */ +export interface CreateOptionValueInput { + optionGroupId: number; + nameFa: string; + nameEn: string; + sortOrder: number; +} + +/** + * `UpdateServiceOptionValueCommand` body. Re-parenting to a different group is deliberately not + * supported server-side — it would silently change the meaning of variants that already answered + * with this value — so there is no `optionGroupId` here. + */ +export interface UpdateOptionValueInput { + nameFa: string; + nameEn: string; + sortOrder: number; + isActive: boolean; +} + /** * The catalog domain's API seam — the real HTTP client and the in-memory mock both implement this * interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks. @@ -137,6 +178,21 @@ export interface CatalogApi { updateVariant(id: number, input: UpdateVariantInput): Promise; /** Soft deactivate/reactivate — never a hard delete. */ setVariantActive(id: number, isActive: boolean): Promise; + + // Admin — every category regardless of active state, so a deactivated one stays reachable to reactivate. + adminListCategories(params?: PageParams): Promise>; + createCategory(input: CategoryInput): Promise; + updateCategory(id: number, input: CategoryInput): Promise; + /** Soft deactivate/reactivate — categories/groups/values are reference data, never hard-deleted. */ + setCategoryActive(id: number, isActive: boolean): Promise; + + // Admin — a category's groups including inactive ones, each with every value including inactive. + adminListOptionGroups(categoryId: number): Promise; + createOptionGroup(input: OptionGroupInput): Promise; + updateOptionGroup(id: number, input: OptionGroupInput): Promise; + /** There is no group-level active toggle in the contract — a group can only be created/edited. */ + createOptionValue(input: CreateOptionValueInput): Promise; + updateOptionValue(id: number, input: UpdateOptionValueInput): Promise; } /** diff --git a/mvp/blockers.md b/mvp/blockers.md index b378d82..a7fb16d 100644 --- a/mvp/blockers.md +++ b/mvp/blockers.md @@ -70,11 +70,6 @@ Effort is a rough size, not a schedule: **S** = small/contained, **M** = a real reach a state where the nurse can actually be paid for the visits she did complete. The "today's visits" list nurses see is also unfiltered — it shows a nurse's entire history, not just today. *(Effort: M)* -### Catalog / pricing -- **Outside of the developer's own local setup, there are zero service categories or pricing options - configured at all**, and there is no admin page to add them — only direct database editing. Deployed as-is, - every "choose a service" screen collapses to a nearly useless two-step form. *(Effort: M)* - --- ## B. Before a real stranger's real money touches this diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminCatalogController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminCatalogController.cs index 56e4d85..d549f26 100644 --- a/server/src/API/Baya.Web.Api/Controllers/V1/AdminCatalogController.cs +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminCatalogController.cs @@ -7,7 +7,10 @@ using Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive; using Baya.Application.Features.Catalog.Commands.UpdateServiceCategory; using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup; using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue; +using Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups; +using Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories; using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; using Baya.Infrastructure.Identity.Identity.PermissionManager; using Baya.WebFramework.Attributes; using Baya.WebFramework.BaseController; @@ -24,6 +27,16 @@ namespace Baya.Web.Api.Controllers.V1; [Display(Description = "Admin: curate the catalog skeleton (categories + option groups/values; no delete)")] public sealed class AdminCatalogController(ISender sender) : BaseController { + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task ListCategories([FromQuery] GetAdminServiceCategoriesQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task ListOptionGroups([FromQuery(Name = "category_id")] long categoryId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetAdminOptionGroupsQuery(categoryId), cancellationToken)); + [HttpPost("[action]")] [ProducesOkApiResponseType] public async Task CreateCategory(CreateServiceCategoryCommand command, CancellationToken cancellationToken) diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/ICatalogRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/ICatalogRepository.cs index b7b563d..14d81b3 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/ICatalogRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/ICatalogRepository.cs @@ -19,6 +19,15 @@ public interface ICatalogRepository /// its active values, ordered by sort order. Empty is valid (no dimensions defined yet). Task> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken); + /// 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). + Task> ListAllCategoriesAsync(CancellationToken cancellationToken); + + /// Admin: the category's own groups plus every cross-category group, including inactive + /// groups and inactive values, ordered by sort order — the read side of the active-flag toggles. + /// Not cached. + Task> GetAllApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken); + /// Active-only category projection for variant creation — null if missing or deactivated. Task GetActiveCategoryAsync(long id, CancellationToken cancellationToken); diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminOptionGroups/GetAdminOptionGroupsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminOptionGroups/GetAdminOptionGroupsQuery.Handler.cs new file mode 100644 index 0000000..56b9479 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminOptionGroups/GetAdminOptionGroupsQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> Handle(GetAdminOptionGroupsQuery request, CancellationToken cancellationToken) + { + var groups = await unitOfWork.CatalogRepository.GetAllApplicableGroupsAsync(request.CategoryId, cancellationToken); + return OperationResult>.SuccessResult(groups); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminOptionGroups/GetAdminOptionGroupsQuery.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminOptionGroups/GetAdminOptionGroupsQuery.cs new file mode 100644 index 0000000..d9206ec --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminOptionGroups/GetAdminOptionGroupsQuery.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups; + +/// +/// Admin: a category's applicable option groups — its own plus every cross-category (NULL) group — +/// including inactive groups, each with every value including inactive ones. The read side of +/// group/value management; the public GetCategoryOptionGroupsQuery deliberately hides both. +/// +public record GetAdminOptionGroupsQuery(long CategoryId) : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminServiceCategories/GetAdminServiceCategoriesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminServiceCategories/GetAdminServiceCategoriesQuery.Handler.cs new file mode 100644 index 0000000..b521e04 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminServiceCategories/GetAdminServiceCategoriesQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> 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>.SuccessResult( + new PagedResult(items, all.Count, page, pageSize)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminServiceCategories/GetAdminServiceCategoriesQuery.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminServiceCategories/GetAdminServiceCategoriesQuery.cs new file mode 100644 index 0000000..e0e547e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetAdminServiceCategories/GetAdminServiceCategoriesQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories; + +/// 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). +public record GetAdminServiceCategoriesQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CatalogRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CatalogRepository.cs index 162ca89..e7e07f7 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CatalogRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CatalogRepository.cs @@ -45,6 +45,36 @@ internal sealed class CatalogRepository : ICatalogRepository .ToList())) .ToListAsync(cancellationToken); + public async Task> ListAllCategoriesAsync(CancellationToken cancellationToken) + => await _db.Set() + .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> GetAllApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken) + => await _db.Set() + .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 GetActiveCategoryAsync(long id, CancellationToken cancellationToken) => _db.Set() .AsNoTracking()