Skip to content

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) from src/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 per docs/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 paginated useListItemGroups). Fired only when exactly one type is selected; disabled (no fetch) otherwise. Returns a bare Array<{ 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 own updated_* / created_* fields per ui-direction.md § Decisions.

ItemCreatePage

  • Group selector (inside the form): useListItemGroupOptions({ item_type }) — same options endpoint as ItemListPage's FilterBar. Fired once item_type is chosen; refetched on every item_type change (BR-IC-05 type scoping). Cache key shared with ItemListPage / ItemEditPage — one fetch per (tenant, item_type) for the session.
  • Mutation: useCreateItem wrapped in useCreateItemMutation (features/item-catalog/hooks/useCreateItemMutation.ts). Responsibilities:
  • On success: queryClient.invalidateQueries({ queryKey: getListItemsQueryKey() }) (prefix-match invalidates every list variant), plus invalidateQueries({ queryKey: getGetItemStatsQueryKey() }). Toast t('item-catalog:toast.created'). Navigate to itemCatalogRoutes.itemDetail(item_id).
  • On 409 ITEM_CODE_ALREADY_IN_USE → set field error on code. On 409 EAN_ALREADY_IN_USE → set field error on ean. On 409 GROUP_ITEM_TYPE_MISMATCH → set field error on group_id. On 404 ITEM_GROUP_NOT_FOUND → set field error on group_id ("Selected group no longer exists"). On 400 SHELF_LIFE_DAYS_NOT_APPLICABLE → set field error on shelf_life_days. Field-error mapping is the shared error parser's job — see docs/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). The version field on ItemDetailResponse is kept in the form snapshot.
  • Group selector (inside the form): useListItemGroupOptions({ item_type }). The item's item_type is immutable so the picker scope is fixed at mount — one fetch per page visit. Disabled (no fetch / control disabled) if the user lacks Permission.ITEM_GROUP_ASSIGN.
  • Mutation hooks composed inside saveItem:
  • useEditItem (PATCH) — body excludes immutable fields and only contains the dirty editable fields plus the version echo.
  • 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 to null.
  • Composite helper: features/item-catalog/hooks/saveItem.ts (full pattern in docs/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_REMAININGt('item-catalog:deactivateError.stockRemaining')
  • ITEM_HAS_OPEN_PRODUCTION_ORDERSt('item-catalog:deactivateError.openProductionOrders')
  • ITEM_IS_IN_PUBLISHED_BOMt('item-catalog:deactivateError.inPublishedBom')
  • ITEM_HAS_OPEN_SALES_ORDERSt('item-catalog:deactivateError.openSalesOrders')
  • Reactivate: only 400 INVALID_ITEM_STATE_TRANSITION is expected (item already active) — defensive only; the FE shouldn't expose Reactivate when status === 'active'.

GroupListPage

  • List: useListItemGroups(search). Same loading/refetch behaviour as ItemListPage.
  • Stats: useGetItemGroupStats().
  • Create: useCreateItemGroup wrapped in useCreateItemGroupMutation. On success: invalidate getListItemGroupsQueryKey() + getGetItemGroupStatsQueryKey() + getListItemGroupOptionsQueryKey() (per ../../standards.md § Options endpoints — the options cache is independent of the list cache and must be invalidated separately); toast; close FormDialog. On 409 ITEM_GROUP_NAME_ALREADY_IN_USE → field error on name.
  • Edit: useEditItemGroup wrapped in useEditItemGroupMutation. PATCH body includes the form's name plus the version echo (BE already returns version on EditItemGroupResponse). On success: invalidate getListItemGroupsQueryKey() + the affected getGetItemGroupQueryKey(groupId) if cached + getListItemGroupOptionsQueryKey() (rename changes the picker label); toast; close. On 409 CONCURRENT_MODIFICATION: render alert in dialog body; invalidate detail; reset form with fresh prefill via re-fetch.
  • Delete: useDeleteItemGroup wrapped in useDeleteItemGroupMutation. On success: invalidate list + stats + getListItemGroupOptionsQueryKey() (a deleted group disappears from pickers); toast; close ConfirmDialog. On 409 ITEM_GROUP_HAS_MEMBERS: keep dialog open; surface inline alert; "Confirm" stays disabled (the FE should have pre-empted this when member_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 to itemCatalogRoutes.groupList.
  • Remove item from group: useRemoveItemFromGroup wrapped in useRemoveItemFromGroupMutation. On success: invalidate getListItemsByGroupQueryKey(groupId) + getGetItemQueryKey(itemId) + getListItemsQueryKey(). Toast t('item-catalog:toast.removedFromGroup'). On 400 ITEM_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 (or fetch) directly against the generated getImportTemplate axios 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 via URL.createObjectURL + a temporary <a download> click. The exact wiring decision is implementation-detail for the M4 ticket; both shapes satisfy the standard.
  • Import mutation: useImportItems wrapped in useImportItemsMutation. Multipart body (FormData with file). Idempotency-Key carried per standard. On success: invalidate getListItemsQueryKey() + getGetItemStatsQueryKey() + getListItemGroupsQueryKey() (groups can be auto-created indirectly if the importer matches by group_name — defensive invalidation even if BE doesn't auto-create today). Surface created_count in 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> from error.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. See docs/standards.md § Cache Invalidation.
  • Skeletons for isLoading; overlays for isFetching on existing data — <DataTable> accepts both as slot props.
  • Errors are funnelled through the shared handleApiError(e, { setError }) (RHF) or handleApiError(e, { toast: true }) (route-level). The envelope shape { error: { code, message, details } } is parsed once per response. No per-call hand-rolled error switches.