proposal-system/web/src/domain/admin/use-cases.ts

224 lines
8.9 KiB
TypeScript
Raw Normal View History

// Admin domain — TanStack Query hooks + query keys (the only public surface).
// State-transition mutations invalidate the proposals/lineItems domain keys
// (rule 3 — cross-domain invalidation via the sibling key objects) and carry
// the same toasts the AdminWorkspace page shows today. Page-specific side
// effects (dialog close, dirty reset, window.open, navigate) stay at the
// call site via the mutation's callbacks.
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { adminApi } from './api';
import { lineItemsApi } from '../lineItems/api';
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
import { proposalsApi } from '../proposals/api';
import { proposalsKeys } from '../proposals/use-cases';
import { lineItemsKeys } from '../lineItems/use-cases';
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
import { ConflictError } from '../../lib/api/errors';
import type { UpdateLineItemEntry } from '../lineItems/types';
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
import type { ProposalDetail } from '../proposals/types';
export const adminKeys = {
all: ['admin'] as const,
dashboard: () => [...adminKeys.all, 'dashboard'] as const,
similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const,
};
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
/**
* Every state transition must refresh every view of the proposal: the
* workspace (detail + line items) AND the cached queue/list/stats/KPI
* queries — lists sit under the global 5-minute staleTime, so without the
* broad invalidation an admin returning to the queue after approving sees
* stale statuses.
*/
function invalidateProposalViews(queryClient: QueryClient, proposalId: string) {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.lists() });
queryClient.invalidateQueries({ queryKey: proposalsKeys.stats() });
queryClient.invalidateQueries({ queryKey: adminKeys.dashboard() });
}
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
/**
* The optimistic-concurrency token for a guarded mutation, read from the
* cached proposal detail at mutate time (the workspace always fetches the
* detail before any action is possible). Read lazily inside mutationFn —
* never captured at render — so a save-then-approve chain sees the token the
* save wrote back, not the one the page rendered with.
*/
function cachedProposalVersion(queryClient: QueryClient, proposalId: string): string | undefined {
return queryClient.getQueryData<ProposalDetail>(proposalsKeys.detail(proposalId))?.rowVersion;
}
/**
* 409 recovery (refresh-and-retry semantics): write the server's reloaded
* currentState into the detail cache (fresh token immediately available),
* refetch every proposal view, and tell the user their view was stale.
* Returns true when the error was a concurrency conflict — callers skip
* their generic failure toast in that case.
*/
function handleConcurrencyConflict(
queryClient: QueryClient,
proposalId: string,
error: Error,
): boolean {
if (!(error instanceof ConflictError)) return false;
if (error.currentState) {
queryClient.setQueryData(proposalsKeys.detail(proposalId), error.currentState);
}
invalidateProposalViews(queryClient, proposalId);
toast.warning(error.message);
return true;
}
/** Admin dashboard KPIs (AdminDashboard). */
export function useAdminDashboard() {
return useQuery({
queryKey: adminKeys.dashboard(),
queryFn: adminApi.getDashboard,
});
}
/** RAG similarity results (AdminWorkspace, SimilarProposalsPanel). */
export function useSimilarProposals(proposalId: string | undefined) {
return useQuery({
queryKey: adminKeys.similar(proposalId ?? ''),
queryFn: () => adminApi.getSimilar(proposalId!),
enabled: !!proposalId,
});
}
export interface SaveWorkspaceVariables {
refinedScope: string;
lineItems: UpdateLineItemEntry[];
}
/**
* The workspace "Save" action: persist the refined scope, then replace the
* proposal's line items (mirrors AdminWorkspace saveMutation exactly).
*/
export function useSaveProposalWorkspace(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => {
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
// Guarded pair: the PUT rotates the proposal token, so the bulk replace
// must send the PUT response's token, not the one the save started with.
const updated = await adminApi.updateProposal(proposalId, {
refinedScope,
proposalVersion: cachedProposalVersion(queryClient, proposalId),
});
await lineItemsApi.bulkUpdate(proposalId, lineItems, updated.rowVersion);
// The bulk replace rotates the token AGAIN but returns only line items —
// refetch the detail so a chained guarded action (approve-after-save)
// holds the current token instead of racing the invalidation refetch.
return proposalsApi.getById(proposalId);
},
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
onSuccess: (fresh) => {
queryClient.setQueryData(proposalsKeys.detail(proposalId), fresh);
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
invalidateProposalViews(queryClient, proposalId);
toast.success('Changes saved');
},
onError: (error: Error) => {
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Save failed: ${error.message}`);
},
});
}
export function useApproveProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
mutationFn: () =>
adminApi.approveProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (proposal) => {
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
invalidateProposalViews(queryClient, proposalId);
toast.success('Proposal approved');
},
onError: (error: Error) => {
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Approval failed: ${error.message}`);
},
});
}
export function useSendProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
mutationFn: () =>
adminApi.sendProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (proposal) => {
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
invalidateProposalViews(queryClient, proposalId);
toast.success('Proposal marked as sent');
},
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
// Fix: WEB-H5 — mutation must surface failures to the user (relocated
// from AdminWorkspace during the domain-layer refactor)
onError: (error: Error) => {
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Send failed: ${error.message}`);
},
});
}
export function useReviseProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
mutationFn: () =>
adminApi.reviseProposal(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (revision) => {
// The response is the NEW revision (different id) — seed its detail
// cache; the invalidation below refreshes the original's views.
queryClient.setQueryData(proposalsKeys.detail(revision.id), revision);
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
invalidateProposalViews(queryClient, proposalId);
toast.success('Revision created');
},
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
// Fix: WEB-H6 — mutation must surface failures to the user (relocated
// from AdminWorkspace during the domain-layer refactor)
onError: (error: Error) => {
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Revision failed: ${error.message}`);
},
});
}
export function useReturnToReview(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
mutationFn: () =>
adminApi.returnToReview(proposalId, cachedProposalVersion(queryClient, proposalId)),
onSuccess: (proposal) => {
queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal);
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00
invalidateProposalViews(queryClient, proposalId);
toast.success('Proposal returned to review');
},
onError: (error: Error) => {
feat(contracts+web): thread concurrency tokens and 409 conflict handling through the domain layer Phase 6b of the SHOC-alignment plan — client side of the optimistic-concurrency wire contract shipped in 6a. shared/api-contracts (additive): - rowVersion: string on ProposalListItem, ProposalDetail, and LineItem - proposalVersion?: string on UpdateProposalRequest and BulkUpdateLineItemsRequest; new ProposalVersionRequest - ConcurrencyConflict<T> { message, currentState } — the SHOC ADR 0004 409 envelope (guarded path; the unguarded fallback carries no state) - matching zod schemas, all kept under the satisfies z.ZodType<T> coupling web: - lib/api/errors.ts: ConflictError carrying the server's reloaded currentState; client.ts interceptor throws it on 409 (WEB-M2 401 handling untouched) - guarded mutations read the token from the cached proposal detail at mutate time; the save flow chains rotated tokens (PUT response token into the bulk replace) and ends with a detail refetch so approve-after-save never sends a stale version - 409 recovery in the admin use-cases: write currentState into the detail cache, invalidateProposalViews() (stale-queue invariant holds on the failure path too), and toast the conflict instead of the generic failure message - e2e smoke mock payloads carry rowVersion; vitest coverage for the interceptor ConflictError paths, token threading/rotation, and 409 cache recovery Verified: shared typecheck, web tsc/vitest (69)/build/prettier/Playwright smoke, mobile tsc (create-only, no changes needed).
2026-07-13 21:01:46 -04:00
if (handleConcurrencyConflict(queryClient, proposalId, error)) return;
toast.error(`Return to review failed: ${error.message}`);
},
});
}
export function useGenerateSuggestions(proposalId: string) {
return useMutation({
mutationFn: () => adminApi.generateSuggestions(proposalId),
onSuccess: () => {
toast.info('AI suggestion generation started');
},
onError: (error: Error) => {
toast.error(`Regeneration failed: ${error.message}`);
},
});
}
/**
* Fetch (or queue generation of) the branded PDF. Resolves null while
* generation is queued (202). Success handling — open the download and
* toast — is page behavior and belongs at the call site.
*/
export function useProposalPdf(proposalId: string) {
return useMutation({
mutationFn: (regenerate?: boolean) => adminApi.getPdf(proposalId, regenerate ?? false),
onError: () => {
toast.error('Failed to generate PDF');
},
});
}