blocker phase 6, catalog admin

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