Item Catalog — Data Fetching¶
Per-page Orval hook usage. All hook names below match operationId values in openapi/openapi.json after Orval's normalisation (FastAPI's _api_vN_<path>_<verb> suffix is stripped by output.override.operationName in orval.config.ts, so the stem becomes the hook name: useGet… / useList… / use<Verb> per Orval conventions). Hooks are imported directly from src/generated/item-catalog.ts in components — no bespoke queryOptions wrapper layer, per docs/standards.md § Data Fetching.
The Idempotency-Key header is added by the shared Orval axios instance configured in Foundation M4 — component code and the per-feature hooks do not set it manually. The shared useIdempotentMutation wrapper (Foundation M4) gives every logical Save click a stable UUID so retries replay the cached response.
Composite-save flows that span multiple endpoints (ItemEditPage) wrap several Orval mutation hooks behind a per-aggregate save<Aggregate> helper in features/item-catalog/hooks/, per docs/standards.md § Composite Edits.
ItemListPage¶
- List:
useListItems(search)fromsrc/generated/item-catalog.ts. Triggers on every committed search-param change (URL).isLoading→<DataTableSkeleton>(rendered inside<DataTable>'s loading slot).isFetching(after first success) → subtle overlay on the existing rows, never destroy them perdocs/standards.md§ Data Fetching. - Stats:
useGetItemStats(). Independent query — invalidated only on item create / deactivate / reactivate / import success, not on filter changes (filter-agnostic stable counts). - Group selector (inside
FilterBar):useListItemGroupOptions({ item_type })— the BE options endpoint per../../standards.md§ Options endpoints (NOT the paginateduseListItemGroups). Fired only when exactly one type is selected; disabled (no fetch) otherwise. Returns a bareArray<{ id, label }>consumed directly by<Combobox>.
ItemDetailPage¶
- Read:
useGetItem(itemId)— drives header + main column + Audit card. - Cross-module reads (deferred):
useGetStockSummary(itemId)and similar live in other modules' generated clients. Not in scope for Phase 1 — the right-rail Activity card placeholder uses only the item's ownupdated_*/created_*fields perui-direction.md§ Decisions.
ItemCreatePage¶
- Group selector (inside the form):
useListItemGroupOptions({ item_type })— same options endpoint as ItemListPage's FilterBar. Fired onceitem_typeis chosen; refetched on everyitem_typechange (BR-IC-05 type scoping). Cache key shared with ItemListPage / ItemEditPage — one fetch per(tenant, item_type)for the session. - Mutation:
useCreateItemwrapped inuseCreateItemMutation(features/item-catalog/hooks/useCreateItemMutation.ts). Responsibilities: - On success:
queryClient.invalidateQueries({ queryKey: getListItemsQueryKey() })(prefix-match invalidates every list variant), plusinvalidateQueries({ queryKey: getGetItemStatsQueryKey() }). Toastt('item-catalog:toast.created'). Navigate toitemCatalogRoutes.itemDetail(item_id). - On 409
ITEM_CODE_ALREADY_IN_USE→ set field error oncode. On 409EAN_ALREADY_IN_USE→ set field error onean. On 409GROUP_ITEM_TYPE_MISMATCH→ set field error ongroup_id. On 404ITEM_GROUP_NOT_FOUND→ set field error ongroup_id("Selected group no longer exists"). On 400SHELF_LIFE_DAYS_NOT_APPLICABLE→ set field error onshelf_life_days. Field-error mapping is the shared error parser's job — seedocs/standards.md§ Error Envelope.
ItemEditPage¶
Uses the composite-save pattern (docs/standards.md § Composite Edits). The page does not consume Orval mutation hooks directly — it dispatches saveItem which composes them.
- Read (prefill):
useGetItem(itemId). Theversionfield onItemDetailResponseis kept in the form snapshot. - Group selector (inside the form):
useListItemGroupOptions({ item_type }). The item'sitem_typeis immutable so the picker scope is fixed at mount — one fetch per page visit. Disabled (no fetch / control disabled) if the user lacksPermission.ITEM_GROUP_ASSIGN. - Mutation hooks composed inside
saveItem: useEditItem(PATCH) — body excludes immutable fields and only contains the dirty editable fields plus theversionecho.useAssignItemToGroup(PUT/items/{id}/group) — fired only when group changed to a non-null value.useRemoveItemFromGroup(DELETE/items/{id}/group) — fired only when group changed from a value tonull.- Composite helper:
features/item-catalog/hooks/saveItem.ts(full pattern indocs/standards.md§ Composite Edits). Signature:
saveItem(
itemId: string,
initial: ItemFormSnapshot, // includes `version` and current group_id
next: ItemFormValues,
mutators: { editItem, assignGroup, removeGroup },
): Promise<ItemSaveOutcome>
Dispatches the per-sub-resource mutations in parallel via Promise.allSettled. Each sub-mutation carries its own Idempotency-Key so retries replay only the failed sub-action (handled by useIdempotentMutation).
- Cache invalidation on the page after saveItem resolves:
- Any 'saved' outcome → invalidateQueries({ queryKey: getGetItemQueryKey(itemId) }) + invalidateQueries({ queryKey: getListItemsQueryKey() }).
- Group 'saved' outcome additionally invalidates: the previous-group's items-in-group list (getListItemsByGroupQueryKey(prevGroupId)) if applicable + the new group's items-in-group list. The previous-group invalidation needs prevGroupId from the snapshot — keep it in ItemFormSnapshot.
- Outcome handling:
- All 'saved' / 'unchanged' → success toast + navigate to itemCatalogRoutes.itemDetail(itemId).
- Mixed 'saved' + 'error' → "Saved with errors" toast; stay on form; surface per-sub-resource errors per pages.md § ItemEditPage. The successful sub-resources are not retried — the BE state has already changed and the form re-baselines from the now-refreshed detail query.
- All 'error' → "Failed to save" toast; stay on form.
- 409 CONCURRENT_MODIFICATION on the field PATCH: treat as { error: ApiError } on the fields outcome; render the conflict alert per pages.md § ItemEditPage. Do not auto-retry — the user picks "Reload latest" which invalidates the detail query and resets the form from the new prefill.
Deactivate / Reactivate actions (on ItemDetailPage)¶
- Mutations:
useDeactivateItem,useReactivateItem. Each wrapped in a feature hook (useDeactivateItemMutation,useReactivateItemMutation) that handles invalidation: - On success:
invalidateQueries(getGetItemQueryKey(itemId))+invalidateQueries(getListItemsQueryKey())+invalidateQueries(getGetItemStatsQueryKey()). - Toast copy:
t('item-catalog:toast.deactivated')/t('item-catalog:toast.reactivated'). - Deactivation guard-error mapping: the four 409 codes map to localized messages rendered inside the
ConfirmDialog's body (the dialog stays open so the user can read the reason and dismiss): ITEM_HAS_STOCK_REMAINING→t('item-catalog:deactivateError.stockRemaining')ITEM_HAS_OPEN_PRODUCTION_ORDERS→t('item-catalog:deactivateError.openProductionOrders')ITEM_IS_IN_PUBLISHED_BOM→t('item-catalog:deactivateError.inPublishedBom')ITEM_HAS_OPEN_SALES_ORDERS→t('item-catalog:deactivateError.openSalesOrders')- Reactivate: only 400
INVALID_ITEM_STATE_TRANSITIONis expected (item already active) — defensive only; the FE shouldn't expose Reactivate whenstatus === 'active'.
GroupListPage¶
- List:
useListItemGroups(search). Same loading/refetch behaviour as ItemListPage. - Stats:
useGetItemGroupStats(). - Create:
useCreateItemGroupwrapped inuseCreateItemGroupMutation. On success: invalidategetListItemGroupsQueryKey()+getGetItemGroupStatsQueryKey()+getListItemGroupOptionsQueryKey()(per../../standards.md§ Options endpoints — the options cache is independent of the list cache and must be invalidated separately); toast; closeFormDialog. On 409ITEM_GROUP_NAME_ALREADY_IN_USE→ field error onname. - Edit:
useEditItemGroupwrapped inuseEditItemGroupMutation. PATCH body includes the form'snameplus theversionecho (BE already returnsversiononEditItemGroupResponse). On success: invalidategetListItemGroupsQueryKey()+ the affectedgetGetItemGroupQueryKey(groupId)if cached +getListItemGroupOptionsQueryKey()(rename changes the picker label); toast; close. On 409CONCURRENT_MODIFICATION: render alert in dialog body; invalidate detail; reset form with fresh prefill via re-fetch. - Delete:
useDeleteItemGroupwrapped inuseDeleteItemGroupMutation. On success: invalidate list + stats +getListItemGroupOptionsQueryKey()(a deleted group disappears from pickers); toast; closeConfirmDialog. On 409ITEM_GROUP_HAS_MEMBERS: keep dialog open; surface inline alert; "Confirm" stays disabled (the FE should have pre-empted this whenmember_count > 0, but the BE is authoritative).
GroupDetailPage¶
- Read:
useGetItemGroup(groupId). - Items-in-group list:
useListItemsByGroup(groupId, search). Same loading/refetch pattern as ItemListPage. - Edit / Delete: same mutation hooks as GroupListPage (
useEditItemGroupMutation,useDeleteItemGroupMutation). After delete, navigate back toitemCatalogRoutes.groupList. - Remove item from group:
useRemoveItemFromGroupwrapped inuseRemoveItemFromGroupMutation. On success: invalidategetListItemsByGroupQueryKey(groupId)+getGetItemQueryKey(itemId)+getListItemsQueryKey(). Toastt('item-catalog:toast.removedFromGroup'). On 400ITEM_GROUP_NOT_ASSIGNED(defensive — shouldn't happen since the FE only exposes Remove when the item is in this group): toast + invalidate to refresh the table.
ItemImportPage¶
- Template download: the M0 wiring uses
axios(orfetch) directly against the generatedgetImportTemplateaxios instance —useGetImportTemplate(Orval query hook) is also acceptable as long as the response is treated as binary (responseType: 'blob') and the browser is given the file viaURL.createObjectURL+ a temporary<a download>click. The exact wiring decision is implementation-detail for the M4 ticket; both shapes satisfy the standard. - Import mutation:
useImportItemswrapped inuseImportItemsMutation. Multipart body (FormDatawithfile). Idempotency-Key carried per standard. On success: invalidategetListItemsQueryKey()+getGetItemStatsQueryKey()+getListItemGroupsQueryKey()(groups can be auto-created indirectly if the importer matches bygroup_name— defensive invalidation even if BE doesn't auto-create today). Surfacecreated_countin Step 3. - 400 error mapping:
IMPORT_FILE_TOO_LARGE→ toast + bounce to Step 1.IMPORT_TOO_MANY_ROWS→ toast + bounce to Step 1.IMPORT_VALIDATION_FAILED→ render<ImportErrorList>fromerror.details[](each row:{ row, column, message }); stay on Step 2; block Continue. User edits their Excel offline and re-uploads.
Cross-cutting policies (apply to every hook above)¶
- Cache invalidation uses generated query-key helpers (
getListItemsQueryKey(),getGetItemQueryKey(itemId), etc.). Never hand-built arrays. Prefix-match invalidates all parameter variants of a list. Seedocs/standards.md§ Cache Invalidation. - Skeletons for
isLoading; overlays forisFetchingon existing data —<DataTable>accepts both as slot props. - Errors are funnelled through the shared
handleApiError(e, { setError })(RHF) orhandleApiError(e, { toast: true })(route-level). The envelope shape{ error: { code, message, details } }is parsed once per response. No per-call hand-rolled error switches.