blocker phase 6, catalog admin
This commit is contained in:
@@ -1,17 +1,23 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
CatalogApi,
|
||||
CategoryInput,
|
||||
CreateOptionValueInput,
|
||||
CreateVariantInput,
|
||||
NurseServiceVariant,
|
||||
OptionGroupInput,
|
||||
ServiceCategory,
|
||||
ServiceOptionGroup,
|
||||
ServiceOptionValue,
|
||||
UpdateOptionValueInput,
|
||||
UpdateVariantInput,
|
||||
} from '../types';
|
||||
|
||||
const CATALOG_BASE = '/api/v1/catalog';
|
||||
const VARIANTS_BASE = '/api/v1/nurse_variants';
|
||||
const ADMIN_CATALOG_BASE = '/api/v1/admin_catalog';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`).
|
||||
@@ -73,4 +79,75 @@ export const catalogClientApi: CatalogApi = {
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
},
|
||||
|
||||
adminListCategories: async (params) => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params?.page ?? 1));
|
||||
query.set('pageSize', String(params?.pageSize ?? ADMIN_CATEGORIES_PAGE_SIZE));
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<ServiceCategory>>>(`${ADMIN_CATALOG_BASE}/list_categories?${query.toString()}`),
|
||||
);
|
||||
},
|
||||
|
||||
createCategory: async (input: CategoryInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/create_category`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateCategory: async (id, input: CategoryInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/update_category/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
setCategoryActive: async (id, isActive) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_CATALOG_BASE}/set_category_active/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
},
|
||||
|
||||
adminListOptionGroups: async (categoryId) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup[]>>(
|
||||
`${ADMIN_CATALOG_BASE}/list_option_groups?category_id=${categoryId}`,
|
||||
),
|
||||
),
|
||||
|
||||
createOptionGroup: async (input: OptionGroupInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/create_option_group`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateOptionGroup: async (id, input: OptionGroupInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/update_option_group/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
createOptionValue: async (input: CreateOptionValueInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/create_option_value`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateOptionValue: async (id, input: UpdateOptionValueInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/update_option_value/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import {
|
||||
isGroupApplicable,
|
||||
optionSetSignature,
|
||||
type CatalogApi,
|
||||
type CategoryInput,
|
||||
type CreateOptionValueInput,
|
||||
type CreateVariantInput,
|
||||
type NurseServiceVariant,
|
||||
type OptionGroupInput,
|
||||
type ServiceCategory,
|
||||
type ServiceOptionGroup,
|
||||
type ServiceOptionValue,
|
||||
type UpdateOptionValueInput,
|
||||
type UpdateVariantInput,
|
||||
type VariantOption,
|
||||
type VariantOptionSelection,
|
||||
@@ -27,6 +31,14 @@ const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder
|
||||
let store: NurseServiceVariant[] = [];
|
||||
let nextVariantId = 1;
|
||||
|
||||
// The admin-managed skeleton — seeded as a mutable copy so a create/update/deactivate in the admin
|
||||
// console is reflected back through the public read methods below, exactly like the real DB.
|
||||
let categoryStore: ServiceCategory[] = SEED_CATEGORIES.map((category) => ({ ...category }));
|
||||
let groupStore: ServiceOptionGroup[] = SEED_OPTION_GROUPS.map((group) => ({ ...group, values: [...group.values] }));
|
||||
let nextCategoryId = Math.max(0, ...categoryStore.map((c) => c.id)) + 1;
|
||||
let nextGroupId = Math.max(0, ...groupStore.map((g) => g.id)) + 1;
|
||||
let nextValueId = Math.max(0, ...groupStore.flatMap((g) => g.values.map((v) => v.id))) + 1;
|
||||
|
||||
/** Active-first (contract list order), then most-recent within each group. */
|
||||
const orderedVariants = () =>
|
||||
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id);
|
||||
@@ -39,9 +51,7 @@ function paginate<T>(all: T[], params?: PageParams, defaultSize = 50): Paginated
|
||||
}
|
||||
|
||||
function applicableGroups(categoryId: number): ServiceOptionGroup[] {
|
||||
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(
|
||||
bySortOrder,
|
||||
);
|
||||
return groupStore.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(bySortOrder);
|
||||
}
|
||||
|
||||
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
|
||||
@@ -128,7 +138,7 @@ function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup
|
||||
export const catalogMockApi: CatalogApi = {
|
||||
listCategories: async (params) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder);
|
||||
const active = categoryStore.filter((category) => category.isActive).sort(bySortOrder);
|
||||
return paginate(active, params, CATEGORIES_PAGE_SIZE);
|
||||
},
|
||||
|
||||
@@ -155,7 +165,7 @@ export const catalogMockApi: CatalogApi = {
|
||||
|
||||
createVariant: async (input) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
|
||||
const category = categoryStore.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
|
||||
if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
|
||||
|
||||
const groups = applicableGroups(category.id);
|
||||
@@ -205,4 +215,92 @@ export const catalogMockApi: CatalogApi = {
|
||||
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
|
||||
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
|
||||
},
|
||||
|
||||
adminListCategories: async (params) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return paginate([...categoryStore].sort(bySortOrder), params, ADMIN_CATEGORIES_PAGE_SIZE);
|
||||
},
|
||||
|
||||
createCategory: async (input: CategoryInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const category: ServiceCategory = { id: nextCategoryId++, ...input, isActive: true };
|
||||
categoryStore = [...categoryStore, category];
|
||||
return category;
|
||||
},
|
||||
|
||||
updateCategory: async (id, input: CategoryInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = categoryStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
|
||||
const updated: ServiceCategory = { ...existing, ...input };
|
||||
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? updated : candidate));
|
||||
return updated;
|
||||
},
|
||||
|
||||
setCategoryActive: async (id, isActive) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = categoryStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
|
||||
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
|
||||
},
|
||||
|
||||
adminListOptionGroups: async (categoryId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return groupStore
|
||||
.filter((group) => isGroupApplicable(group, categoryId))
|
||||
.sort(bySortOrder)
|
||||
.map((group) => ({ ...group, values: [...group.values].sort(bySortOrder) }));
|
||||
},
|
||||
|
||||
createOptionGroup: async (input: OptionGroupInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
|
||||
throw new ApiError(400, 'Category not found', 'invalid_category');
|
||||
}
|
||||
const group: ServiceOptionGroup = { id: nextGroupId++, ...input, isActive: true, values: [] };
|
||||
groupStore = [...groupStore, group];
|
||||
return group;
|
||||
},
|
||||
|
||||
updateOptionGroup: async (id, input: OptionGroupInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = groupStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Option group not found', 'not_found');
|
||||
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
|
||||
throw new ApiError(400, 'Category not found', 'invalid_category');
|
||||
}
|
||||
const updated: ServiceOptionGroup = { ...existing, ...input };
|
||||
groupStore = groupStore.map((candidate) => (candidate.id === id ? updated : candidate));
|
||||
return updated;
|
||||
},
|
||||
|
||||
createOptionValue: async (input: CreateOptionValueInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const group = groupStore.find((candidate) => candidate.id === input.optionGroupId);
|
||||
if (!group) throw new ApiError(400, 'Option group not found', 'invalid_group');
|
||||
const value: ServiceOptionValue = {
|
||||
id: nextValueId++,
|
||||
nameFa: input.nameFa,
|
||||
nameEn: input.nameEn,
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: true,
|
||||
};
|
||||
groupStore = groupStore.map((candidate) =>
|
||||
candidate.id === group.id ? { ...candidate, values: [...candidate.values, value] } : candidate,
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
updateOptionValue: async (id, input: UpdateOptionValueInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const group = groupStore.find((candidate) => candidate.values.some((value) => value.id === id));
|
||||
if (!group) throw new ApiError(404, 'Option value not found', 'not_found');
|
||||
const updated: ServiceOptionValue = { id, ...input };
|
||||
groupStore = groupStore.map((candidate) =>
|
||||
candidate.id === group.id
|
||||
? { ...candidate, values: candidate.values.map((value) => (value.id === id ? updated : value)) }
|
||||
: candidate,
|
||||
);
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,3 +22,10 @@ export const MY_VARIANTS_STALE_TIME = 60_000;
|
||||
/** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
|
||||
export const CATEGORIES_PAGE_SIZE = 50;
|
||||
export const MY_VARIANTS_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* The admin catalog-management reads (categories incl. inactive; a category's groups/values incl.
|
||||
* inactive) are deliberately uncached (`staleTime: 0`) — an admin editing the skeleton expects every
|
||||
* write reflected immediately, and this console's traffic is negligible next to the public browse.
|
||||
*/
|
||||
export const ADMIN_CATEGORIES_PAGE_SIZE = 50;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Every category regardless of active state, for the admin catalog console — unlike the public,
|
||||
* session-cached `useServiceCategories`, this always refetches (`staleTime: 0`) so a deactivate/
|
||||
* reactivate or edit is visible immediately without a stale cached page.
|
||||
*/
|
||||
export function useAdminCategories(params?: PageParams) {
|
||||
return useQuery({
|
||||
queryKey: catalogKeys.adminCategories(params),
|
||||
queryFn: () => catalogApi.adminListCategories(params),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* A category's applicable option groups **including inactive groups, each with every value
|
||||
* including inactive ones** — the admin console's read of the skeleton it manages. Always
|
||||
* refetches (`staleTime: 0`); disabled until a category is chosen.
|
||||
*/
|
||||
export function useAdminOptionGroups(categoryId: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: catalogKeys.adminOptionGroups(categoryId ?? 0),
|
||||
queryFn: () => catalogApi.adminListOptionGroups(categoryId as number),
|
||||
enabled: categoryId != null,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CategoryInput } from '../types';
|
||||
|
||||
/** Adds a top-level category, then invalidates the admin category list. */
|
||||
export function useCreateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CategoryInput) => catalogApi.createCategory(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { OptionGroupInput } from '../types';
|
||||
|
||||
/**
|
||||
* Adds a pricing dimension — invalidates every cached category's option-groups (admin + public), not
|
||||
* just the current one, because a cross-category group (`serviceCategoryId: null`) affects all of them.
|
||||
*/
|
||||
export function useCreateOptionGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: OptionGroupInput) => catalogApi.createOptionGroup(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CreateOptionValueInput } from '../types';
|
||||
|
||||
/** Adds a concrete choice to an option group — invalidates every cached category's option-groups. */
|
||||
export function useCreateOptionValue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateOptionValueInput) => catalogApi.createOptionValue(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Soft deactivates or reactivates a category — **never a hard delete**. Deactivating hides it from
|
||||
* public browse and new variant creation; existing variants in it are left intact. Drives both the
|
||||
* deactivate-with-confirm action and the reactivate affordance on an inactive row.
|
||||
*/
|
||||
export function useSetCategoryActive() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) => catalogApi.setCategoryActive(id, isActive),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CategoryInput } from '../types';
|
||||
|
||||
/** Edits a category's labels/description/icon/order, then invalidates the admin + public lists. */
|
||||
export function useUpdateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: CategoryInput }) => catalogApi.updateCategory(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { OptionGroupInput } from '../types';
|
||||
|
||||
/**
|
||||
* Edits a group's category scope/labels/required flag/order — invalidates every cached category's
|
||||
* option-groups, since re-scoping to/from cross-category changes what more than one category sees.
|
||||
*/
|
||||
export function useUpdateOptionGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: OptionGroupInput }) => catalogApi.updateOptionGroup(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { UpdateOptionValueInput } from '../types';
|
||||
|
||||
/**
|
||||
* Edits a value's labels/order and activates/deactivates it (never a hard delete — re-parenting to a
|
||||
* different group is not supported). Invalidates every cached category's option-groups.
|
||||
*/
|
||||
export function useUpdateOptionValue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: UpdateOptionValueInput }) => catalogApi.updateOptionValue(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,3 +4,12 @@ export { useMyVariants } from './hooks/useMyVariants';
|
||||
export { useCreateVariant } from './hooks/useCreateVariant';
|
||||
export { useUpdateVariant } from './hooks/useUpdateVariant';
|
||||
export { useSetVariantActive } from './hooks/useSetVariantActive';
|
||||
export { useAdminCategories } from './hooks/useAdminCategories';
|
||||
export { useCreateCategory } from './hooks/useCreateCategory';
|
||||
export { useUpdateCategory } from './hooks/useUpdateCategory';
|
||||
export { useSetCategoryActive } from './hooks/useSetCategoryActive';
|
||||
export { useAdminOptionGroups } from './hooks/useAdminOptionGroups';
|
||||
export { useCreateOptionGroup } from './hooks/useCreateOptionGroup';
|
||||
export { useUpdateOptionGroup } from './hooks/useUpdateOptionGroup';
|
||||
export { useCreateOptionValue } from './hooks/useCreateOptionValue';
|
||||
export { useUpdateOptionValue } from './hooks/useUpdateOptionValue';
|
||||
|
||||
@@ -11,12 +11,26 @@ export const catalogKeys = {
|
||||
|
||||
// Reference data — cached for the whole session, never invalidated by this phase.
|
||||
categories: () => [...catalogKeys.all, 'categories'] as const,
|
||||
/** Prefix shared by every `categoryOptionGroups(id)` key — invalidate this to catch every cached
|
||||
* category when a group/value edit is cross-category (its `serviceCategoryId` is null) or its exact
|
||||
* category set isn't known to the caller. */
|
||||
categoryOptionGroupsAll: () => [...catalogKeys.all, 'option-groups'] as const,
|
||||
categoryOptionGroups: (categoryId?: number | null) =>
|
||||
[...catalogKeys.all, 'option-groups', categoryId ?? null] as const,
|
||||
[...catalogKeys.categoryOptionGroupsAll(), categoryId ?? null] as const,
|
||||
|
||||
// The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
|
||||
variants: () => [...catalogKeys.all, 'variants'] as const,
|
||||
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
|
||||
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
|
||||
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const,
|
||||
|
||||
// Admin catalog management — separate from the public/cached reference-data keys above so a mutation
|
||||
// invalidating one never stales the other's cache entry.
|
||||
admin: () => [...catalogKeys.all, 'admin'] as const,
|
||||
adminCategoryLists: () => [...catalogKeys.admin(), 'categories'] as const,
|
||||
adminCategories: (params?: PageParams) => [...catalogKeys.adminCategoryLists(), params ?? {}] as const,
|
||||
/** Prefix shared by every `adminOptionGroups(id)` key — see `categoryOptionGroupsAll`'s doc for why a
|
||||
* group/value mutation invalidates this prefix instead of a single category id. */
|
||||
adminOptionGroupsAll: () => [...catalogKeys.admin(), 'option-groups'] as const,
|
||||
adminOptionGroups: (categoryId: number) => [...catalogKeys.adminOptionGroupsAll(), categoryId] as const,
|
||||
};
|
||||
|
||||
@@ -121,6 +121,47 @@ export interface UpdateVariantInput {
|
||||
displayName?: string | null;
|
||||
}
|
||||
|
||||
// ── Admin write models (AdminCatalogController — categories/option groups/values management) ───────────
|
||||
/** `CreateServiceCategoryCommand`/`UpdateServiceCategoryCommand` body. */
|
||||
export interface CategoryInput {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descriptionFa: string | null;
|
||||
descriptionEn: string | null;
|
||||
iconKey: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** `CreateServiceOptionGroupCommand`/`UpdateServiceOptionGroupCommand` body — `serviceCategoryId = null`
|
||||
* makes the group cross-category (applies to every category), exactly like the read side. */
|
||||
export interface OptionGroupInput {
|
||||
serviceCategoryId: number | null;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** `CreateServiceOptionValueCommand` body. */
|
||||
export interface CreateOptionValueInput {
|
||||
optionGroupId: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UpdateServiceOptionValueCommand` body. Re-parenting to a different group is deliberately not
|
||||
* supported server-side — it would silently change the meaning of variants that already answered
|
||||
* with this value — so there is no `optionGroupId` here.
|
||||
*/
|
||||
export interface UpdateOptionValueInput {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog domain's API seam — the real HTTP client and the in-memory mock both implement this
|
||||
* interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks.
|
||||
@@ -137,6 +178,21 @@ export interface CatalogApi {
|
||||
updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
|
||||
/** Soft deactivate/reactivate — never a hard delete. */
|
||||
setVariantActive(id: number, isActive: boolean): Promise<void>;
|
||||
|
||||
// Admin — every category regardless of active state, so a deactivated one stays reachable to reactivate.
|
||||
adminListCategories(params?: PageParams): Promise<Paginated<ServiceCategory>>;
|
||||
createCategory(input: CategoryInput): Promise<ServiceCategory>;
|
||||
updateCategory(id: number, input: CategoryInput): Promise<ServiceCategory>;
|
||||
/** Soft deactivate/reactivate — categories/groups/values are reference data, never hard-deleted. */
|
||||
setCategoryActive(id: number, isActive: boolean): Promise<void>;
|
||||
|
||||
// Admin — a category's groups including inactive ones, each with every value including inactive.
|
||||
adminListOptionGroups(categoryId: number): Promise<ServiceOptionGroup[]>;
|
||||
createOptionGroup(input: OptionGroupInput): Promise<ServiceOptionGroup>;
|
||||
updateOptionGroup(id: number, input: OptionGroupInput): Promise<ServiceOptionGroup>;
|
||||
/** There is no group-level active toggle in the contract — a group can only be created/edited. */
|
||||
createOptionValue(input: CreateOptionValueInput): Promise<ServiceOptionValue>;
|
||||
updateOptionValue(id: number, input: UpdateOptionValueInput): Promise<ServiceOptionValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user