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.
This commit is contained in:
Adam Moussa 2026-07-13 19:06:08 -04:00
parent 2e1099a560
commit 1f55a59445
No known key found for this signature in database
27 changed files with 283 additions and 337 deletions

View file

@ -65,7 +65,9 @@ public class CustomerService : ICustomerService
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = request.Name, Name = request.Name,
Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null, Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null,
ContactEmail = request.ContactEmail, // Normalize empty to null, matching the update path — clients may
// send contactEmail: "" (the web form always includes the field).
ContactEmail = string.IsNullOrEmpty(request.ContactEmail) ? null : request.ContactEmail,
CreatedAt = now, CreatedAt = now,
UpdatedAt = now, UpdatedAt = now,
}; };

View file

@ -4,6 +4,16 @@
"private": true, "private": true,
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./schemas": {
"types": "./src/schemas.ts",
"default": "./src/schemas.ts"
}
},
"scripts": { "scripts": {
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View file

@ -1,37 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { Box, Typography, Skeleton, Chip } from '@mui/material'; import { Box, Typography, Skeleton, Chip } from '@mui/material';
import apiClient from '../../lib/api/client'; import { useVendorProposals } from '../../domain/proposals/use-cases';
import { formatCurrency } from '../../lib/format'; import { formatCurrency } from '../../lib/format';
interface VendorProposal {
id: string;
vendorName: string;
fileName: string;
totalVendorCost: number;
processingStatus: string;
extractedData: {
lineItems?: Array<{
description: string;
quantity?: number;
unitPrice?: number;
total?: number;
}>;
} | null;
}
interface VendorDataPanelProps { interface VendorDataPanelProps {
proposalId: string; proposalId: string;
} }
export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) { export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
const { data: vendors, isLoading } = useQuery<VendorProposal[]>({ const { data: vendors, isLoading } = useVendorProposals(proposalId);
queryKey: ['vendorProposals', proposalId],
queryFn: async () => {
const res = await apiClient.get(`/proposals/${proposalId}/vendors`);
return res.data;
},
enabled: !!proposalId,
});
if (isLoading) { if (isLoading) {
return <Skeleton variant="rectangular" height={60} />; return <Skeleton variant="rectangular" height={60} />;

View file

@ -12,6 +12,7 @@ export const DEFAULT_PAGE_SIZE = 12;
export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE = 1;
export const DEBOUNCE_SEARCH = 600; export const DEBOUNCE_SEARCH = 600;
export const DEBOUNCE_AUTOCOMPLETE = 300; // typeahead fields respond faster than list search
export const STORAGE_KEY_TOKEN = 'proposal_system_token'; export const STORAGE_KEY_TOKEN = 'proposal_system_token';
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen'; export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';

View file

@ -66,6 +66,18 @@ describe('useApproveProposal', () => {
expect(invalidateSpy).toHaveBeenCalledWith({ expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
}); });
// ...AND every cached view of the queue: lists/stats/KPIs sit under the
// global 5-minute staleTime, so without these the admin returns to a
// stale queue after approving (code-review finding, Phase 4).
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: proposalsKeys.lists(),
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: proposalsKeys.stats(),
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ['admin', 'dashboard'],
});
expect(toast.success).toHaveBeenCalledWith('Proposal approved'); expect(toast.success).toHaveBeenCalledWith('Proposal approved');
}); });

View file

@ -1,5 +1,6 @@
// Customers domain use-case hooks: create/update must invalidate the list // Customers domain use-case hooks: create/update must invalidate the list
// key family (customersKeys.lists()) and toast. The domain api module is // whole domain key family (customersKeys.all — lists AND cached search
// autocompletes; code-review finding, Phase 4) and toast. The domain api module is
// mocked — no axios traffic. // mocked — no axios traffic.
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
@ -42,7 +43,7 @@ describe('customersKeys', () => {
}); });
describe('useCreateCustomer', () => { describe('useCreateCustomer', () => {
it('invalidates customersKeys.lists() and toasts on success', async () => { it('invalidates the whole customers domain (lists + search caches) and toasts on success', async () => {
vi.mocked(customersApi.create).mockResolvedValue(customer); vi.mocked(customersApi.create).mockResolvedValue(customer);
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@ -51,7 +52,7 @@ describe('useCreateCustomer', () => {
result.current.mutate({ name: 'Acme Corp' } as never); result.current.mutate({ name: 'Acme Corp' } as never);
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all });
expect(toast.success).toHaveBeenCalledWith('Customer created'); expect(toast.success).toHaveBeenCalledWith('Customer created');
}); });
@ -70,7 +71,7 @@ describe('useCreateCustomer', () => {
}); });
describe('useUpdateCustomer', () => { describe('useUpdateCustomer', () => {
it('passes id + payload through and invalidates customersKeys.lists() on success', async () => { it('passes id + payload through and invalidates the whole customers domain on success', async () => {
vi.mocked(customersApi.update).mockResolvedValue(customer); vi.mocked(customersApi.update).mockResolvedValue(customer);
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@ -81,7 +82,7 @@ describe('useUpdateCustomer', () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(customersApi.update).toHaveBeenCalledWith('c-1', data); expect(customersApi.update).toHaveBeenCalledWith('c-1', data);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all });
expect(toast.success).toHaveBeenCalledWith('Customer updated'); expect(toast.success).toHaveBeenCalledWith('Customer updated');
}); });

View file

@ -1,15 +1,34 @@
// Line items domain use-case hooks: bulk update must invalidate BOTH the // Line-item persistence flows through useSaveProposalWorkspace (admin
// proposal's line-items key and the proposal detail key (totals change). // domain) — the live workspace save path: update proposal, bulk-replace line
// The domain api module is mocked — no axios traffic. // items, broad-invalidate every cached view. The domain api modules are
// mocked — no axios traffic.
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { createQueryHarness } from './hookTestUtils'; import { createQueryHarness } from './hookTestUtils';
import { lineItemsKeys, useBulkUpdateLineItems } from '../lineItems/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases';
import { useSaveProposalWorkspace } from '../admin/use-cases';
import { proposalsKeys } from '../proposals/use-cases'; import { proposalsKeys } from '../proposals/use-cases';
import { adminApi } from '../admin/api';
import { lineItemsApi } from '../lineItems/api'; import { lineItemsApi } from '../lineItems/api';
import type { UpdateLineItemEntry } from '../lineItems/types'; import type { UpdateLineItemEntry } from '../lineItems/types';
vi.mock('../admin/api', () => ({
adminApi: {
getDashboard: vi.fn(),
updateProposal: vi.fn(),
approveProposal: vi.fn(),
sendProposal: vi.fn(),
reviseProposal: vi.fn(),
returnToReview: vi.fn(),
generateSuggestions: vi.fn(),
getSimilar: vi.fn(),
getHistory: vi.fn(),
getAudit: vi.fn(),
getPdf: vi.fn(),
},
}));
vi.mock('../lineItems/api', () => ({ vi.mock('../lineItems/api', () => ({
lineItemsApi: { lineItemsApi: {
getAll: vi.fn(), getAll: vi.fn(),
@ -39,36 +58,43 @@ describe('lineItemsKeys', () => {
}); });
}); });
describe('useBulkUpdateLineItems', () => { describe('useSaveProposalWorkspace', () => {
it('sends the entries and invalidates the proposal line-items + detail keys on success', async () => { it('persists scope + entries and invalidates line items, detail, and list/stats views', async () => {
vi.mocked(adminApi.updateProposal).mockResolvedValue(undefined);
vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]); vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]);
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
result.current.mutate(entries); result.current.mutate({ refinedScope: 'refined', lineItems: entries });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' });
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries); expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries);
expect(invalidateSpy).toHaveBeenCalledWith({ expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
}); });
// Totals live on the proposal detail — it must be refetched too. // Totals live on the proposal detail AND the cached queue/list views.
expect(invalidateSpy).toHaveBeenCalledWith({ expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: proposalsKeys.detail(PROPOSAL_ID), queryKey: proposalsKeys.detail(PROPOSAL_ID),
}); });
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: proposalsKeys.lists(),
});
expect(toast.success).toHaveBeenCalledWith('Changes saved');
}); });
it('toasts a save failure and skips invalidation', async () => { it('toasts a save failure and skips invalidation', async () => {
vi.mocked(lineItemsApi.bulkUpdate).mockRejectedValue(new Error('409 conflict')); vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict'));
const { queryClient, wrapper } = createQueryHarness(); const { queryClient, wrapper } = createQueryHarness();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
result.current.mutate(entries); result.current.mutate({ refinedScope: 'refined', lineItems: entries });
await waitFor(() => expect(result.current.isError).toBe(true)); await waitFor(() => expect(result.current.isError).toBe(true));
expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict'); expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict');
expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled();
expect(invalidateSpy).not.toHaveBeenCalled(); expect(invalidateSpy).not.toHaveBeenCalled();
}); });
}); });

View file

@ -13,7 +13,6 @@ import {
pricingLibraryItemFormSchema, pricingLibraryItemFormSchema,
toPricingLibraryItemRequest, toPricingLibraryItemRequest,
} from '../pricingLibrary/schemas'; } from '../pricingLibrary/schemas';
import { lineItemRowFormSchema, toUpdateLineItemEntry } from '../lineItems/schemas';
describe('createProposalFormSchema', () => { describe('createProposalFormSchema', () => {
const valid = { const valid = {
@ -85,7 +84,9 @@ describe('customerFormSchema', () => {
{ name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, { name: ' Acme ', address: ' 2 New St ', contactEmail: '' },
['1 Old St', '3 Extra Ave'] ['1 Old St', '3 Extra Ave']
); );
expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'] }); // contactEmail is ALWAYS sent — empty string clears the stored email
// (code-review finding, Phase 4: omission made emails impossible to clear).
expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'], contactEmail: '' });
}); });
}); });
@ -118,40 +119,19 @@ describe('pricingLibraryItemFormSchema', () => {
}); });
}); });
describe('lineItemRowFormSchema', () => { describe('shared numeric form fields', () => {
const valid = { it('rejects trailing garbage that parseFloat would silently coerce', () => {
description: 'Install thermostat', // Code-review finding (Phase 4): '12abc' passed the old parseFloat-based
unit: 'each', // pricing refine as 12; the shared Number-based field must reject it.
pricingMode: 'UnitPrice' as const, expect(
quantity: '2', pricingLibraryItemFormSchema.safeParse({
unitPrice: '150', title: 'Coil cleaning',
totalPrice: '300', serviceCategory: 'HVAC',
}; unit: '',
unitPrice: '12abc',
it('accepts numeric strings and an empty unitPrice', () => { keywords: '',
expect(lineItemRowFormSchema.safeParse(valid).success).toBe(true); description: '',
expect(lineItemRowFormSchema.safeParse({ ...valid, unitPrice: '' }).success).toBe(true); }).success
expect(lineItemRowFormSchema.safeParse({ ...valid, quantity: '' }).success).toBe(false); ).toBe(false);
expect(lineItemRowFormSchema.safeParse({ ...valid, totalPrice: '-1' }).success).toBe(false);
});
it('maps to the wire entry with editor-managed identity', () => {
const entry = toUpdateLineItemEntry(valid, { id: null, sortOrder: 3, source: 'Manual' });
expect(entry).toEqual({
id: null,
description: 'Install thermostat',
quantity: 2,
unit: 'each',
unitPrice: 150,
totalPrice: 300,
pricingMode: 'UnitPrice',
sortOrder: 3,
source: 'Manual',
});
const noUnitPrice = toUpdateLineItemEntry(
{ ...valid, unitPrice: '' },
{ id: 'li-1', sortOrder: 0, source: 'AI' }
);
expect(noUnitPrice.unitPrice).toBeNull();
}); });
}); });

View file

@ -4,23 +4,35 @@
// the same toasts the AdminWorkspace page shows today. Page-specific side // the same toasts the AdminWorkspace page shows today. Page-specific side
// effects (dialog close, dirty reset, window.open, navigate) stay at the // effects (dialog close, dirty reset, window.open, navigate) stay at the
// call site via the mutation's callbacks. // call site via the mutation's callbacks.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { adminApi } from './api'; import { adminApi } from './api';
import { lineItemsApi } from '../lineItems/api'; import { lineItemsApi } from '../lineItems/api';
import { proposalsKeys } from '../proposals/use-cases'; import { proposalsKeys } from '../proposals/use-cases';
import { lineItemsKeys } from '../lineItems/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases';
import type { UpdateProposalRequest } from './types';
import type { UpdateLineItemEntry } from '../lineItems/types'; import type { UpdateLineItemEntry } from '../lineItems/types';
export const adminKeys = { export const adminKeys = {
all: ['admin'] as const, all: ['admin'] as const,
dashboard: () => [...adminKeys.all, 'dashboard'] as const, dashboard: () => [...adminKeys.all, 'dashboard'] as const,
similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const, similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const,
history: (proposalId: string) => [...adminKeys.all, 'history', proposalId] as const,
audit: (proposalId: string) => [...adminKeys.all, 'audit', proposalId] as const,
}; };
/**
* 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() });
}
/** Admin dashboard KPIs (AdminDashboard). */ /** Admin dashboard KPIs (AdminDashboard). */
export function useAdminDashboard() { export function useAdminDashboard() {
return useQuery({ return useQuery({
@ -38,22 +50,6 @@ export function useSimilarProposals(proposalId: string | undefined) {
}); });
} }
export function useProposalHistory(proposalId: string | undefined) {
return useQuery({
queryKey: adminKeys.history(proposalId ?? ''),
queryFn: () => adminApi.getHistory(proposalId!),
enabled: !!proposalId,
});
}
export function useProposalAudit(proposalId: string | undefined) {
return useQuery({
queryKey: adminKeys.audit(proposalId ?? ''),
queryFn: () => adminApi.getAudit(proposalId!),
enabled: !!proposalId,
});
}
export interface SaveWorkspaceVariables { export interface SaveWorkspaceVariables {
refinedScope: string; refinedScope: string;
lineItems: UpdateLineItemEntry[]; lineItems: UpdateLineItemEntry[];
@ -71,8 +67,7 @@ export function useSaveProposalWorkspace(proposalId: string) {
await lineItemsApi.bulkUpdate(proposalId, lineItems); await lineItemsApi.bulkUpdate(proposalId, lineItems);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); invalidateProposalViews(queryClient, proposalId);
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
toast.success('Changes saved'); toast.success('Changes saved');
}, },
onError: (error: Error) => { onError: (error: Error) => {
@ -81,26 +76,12 @@ export function useSaveProposalWorkspace(proposalId: string) {
}); });
} }
export function useUpdateProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateProposalRequest) => adminApi.updateProposal(proposalId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
}
export function useApproveProposal(proposalId: string) { export function useApproveProposal(proposalId: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => adminApi.approveProposal(proposalId), mutationFn: () => adminApi.approveProposal(proposalId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); invalidateProposalViews(queryClient, proposalId);
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
toast.success('Proposal approved'); toast.success('Proposal approved');
}, },
onError: (error: Error) => { onError: (error: Error) => {
@ -114,9 +95,11 @@ export function useSendProposal(proposalId: string) {
return useMutation({ return useMutation({
mutationFn: () => adminApi.sendProposal(proposalId), mutationFn: () => adminApi.sendProposal(proposalId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); invalidateProposalViews(queryClient, proposalId);
toast.success('Proposal marked as sent'); toast.success('Proposal marked as sent');
}, },
// Fix: WEB-H5 — mutation must surface failures to the user (relocated
// from AdminWorkspace during the domain-layer refactor)
onError: (error: Error) => { onError: (error: Error) => {
toast.error(`Send failed: ${error.message}`); toast.error(`Send failed: ${error.message}`);
}, },
@ -128,9 +111,11 @@ export function useReviseProposal(proposalId: string) {
return useMutation({ return useMutation({
mutationFn: () => adminApi.reviseProposal(proposalId), mutationFn: () => adminApi.reviseProposal(proposalId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); invalidateProposalViews(queryClient, proposalId);
toast.success('Revision created'); toast.success('Revision created');
}, },
// Fix: WEB-H6 — mutation must surface failures to the user (relocated
// from AdminWorkspace during the domain-layer refactor)
onError: (error: Error) => { onError: (error: Error) => {
toast.error(`Revision failed: ${error.message}`); toast.error(`Revision failed: ${error.message}`);
}, },
@ -142,8 +127,7 @@ export function useReturnToReview(proposalId: string) {
return useMutation({ return useMutation({
mutationFn: () => adminApi.returnToReview(proposalId), mutationFn: () => adminApi.returnToReview(proposalId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); invalidateProposalViews(queryClient, proposalId);
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
toast.success('Proposal returned to review'); toast.success('Proposal returned to review');
}, },
onError: (error: Error) => { onError: (error: Error) => {

View file

@ -33,8 +33,10 @@ export type CustomerFormValues = z.infer<typeof customerFormSchema>;
/** /**
* Map validated form values to the create/update request. The form edits the * Map validated form values to the create/update request. The form edits the
* primary address; any additional existing addresses are preserved * primary address; any additional existing addresses are preserved.
* (mirrors the current CustomerManagementPage submit mapping). * contactEmail is always sent: the API clears the stored email on empty
* string (CustomerService normalizes '' → null), so omitting the key when the
* admin blanks the field would make the email impossible to clear.
*/ */
export function toCustomerRequest( export function toCustomerRequest(
values: CustomerFormValues, values: CustomerFormValues,
@ -43,6 +45,6 @@ export function toCustomerRequest(
return { return {
name: values.name.trim(), name: values.name.trim(),
addresses: [values.address.trim(), ...existingAddresses.slice(1)], addresses: [values.address.trim(), ...existingAddresses.slice(1)],
...(values.contactEmail.trim() ? { contactEmail: values.contactEmail.trim() } : {}), contactEmail: values.contactEmail.trim(),
}; };
} }

View file

@ -1,5 +1,5 @@
// Customers domain — TanStack Query hooks + query keys (the only public surface). // Customers domain — TanStack Query hooks + query keys (the only public surface).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { customersApi } from './api'; import { customersApi } from './api';
import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types'; import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types';
@ -21,11 +21,15 @@ export function useCustomerSearch(query: string) {
}); });
} }
/** Paginated management list (CustomerManagementPage). */ /** Paginated management list (CustomerManagementPage). keepPreviousData
* keeps rows/totalCount rendered while a new page/search fetches (matches
* the old usePaginatedList behavior and keeps TablePagination's count from
* collapsing to 0 mid-fetch). */
export function useCustomerList(params: CustomerListParams = {}) { export function useCustomerList(params: CustomerListParams = {}) {
return useQuery({ return useQuery({
queryKey: customersKeys.list(params), queryKey: customersKeys.list(params),
queryFn: () => customersApi.list(params), queryFn: () => customersApi.list(params),
placeholderData: keepPreviousData,
}); });
} }
@ -34,7 +38,11 @@ export function useCreateCustomer() {
return useMutation({ return useMutation({
mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), mutationFn: (data: CreateCustomerRequest) => customersApi.create(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: customersKeys.lists() }); // Invalidate the whole domain: lists AND cached search-autocomplete
// results (['customers','search',q]), which lists() would not cover —
// otherwise a just-created customer is invisible in New Proposal's
// autocomplete for the 5-minute staleTime.
queryClient.invalidateQueries({ queryKey: customersKeys.all });
toast.success('Customer created'); toast.success('Customer created');
}, },
onError: (error: Error) => { onError: (error: Error) => {
@ -49,7 +57,8 @@ export function useUpdateCustomer() {
mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) =>
customersApi.update(id, data), customersApi.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: customersKeys.lists() }); // Whole-domain invalidation — see useCreateCustomer.
queryClient.invalidateQueries({ queryKey: customersKeys.all });
toast.success('Customer updated'); toast.success('Customer updated');
}, },
onError: (error: Error) => { onError: (error: Error) => {

View file

@ -1,8 +1,6 @@
// Line items domain — contract schema re-exports + line-item row form schema. // Line items domain — contract schema re-exports.
import { z } from 'zod'; // A row-level form schema will arrive with the LineItemEditor migration to
import { updateLineItemEntrySchema } from '@proposal-system/api-contracts/schemas'; // react-hook-form; until a component consumes it, none is defined here.
import type { UpdateLineItemEntry } from './types';
export { export {
pricingModeSchema, pricingModeSchema,
lineItemSourceSchema, lineItemSourceSchema,
@ -11,51 +9,3 @@ export {
updateLineItemEntrySchema, updateLineItemEntrySchema,
bulkUpdateLineItemsRequestSchema, bulkUpdateLineItemsRequestSchema,
} from '@proposal-system/api-contracts/schemas'; } from '@proposal-system/api-contracts/schemas';
/** Required numeric text input (>= 0). */
const numericString = z
.string()
.refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)) && Number(v) >= 0, {
message: 'Must be a number >= 0',
});
/** Optional numeric text input — empty maps to null on the wire. */
const optionalNumericString = z
.string()
.refine((v) => v.trim() === '' || (!Number.isNaN(Number(v)) && Number(v) >= 0), {
message: 'Must be a number >= 0',
});
/**
* A single editable line-item row. Derived from updateLineItemEntrySchema —
* numeric inputs are strings in form state; toUpdateLineItemEntry() converts
* to the wire type. Row identity (id/sortOrder/source) is editor-managed
* state, not user input, so it is supplied to the mapper instead.
*/
export const lineItemRowFormSchema = updateLineItemEntrySchema
.pick({ description: true, unit: true, pricingMode: true })
.extend({
quantity: numericString,
unitPrice: optionalNumericString,
totalPrice: numericString,
});
export type LineItemRowFormValues = z.infer<typeof lineItemRowFormSchema>;
/** Map a validated row to the bulk-update wire entry. */
export function toUpdateLineItemEntry(
values: LineItemRowFormValues,
meta: { id: string | null; sortOrder: number; source: UpdateLineItemEntry['source'] }
): UpdateLineItemEntry {
return {
id: meta.id,
description: values.description,
quantity: Number(values.quantity),
unit: values.unit,
unitPrice: values.unitPrice.trim() !== '' ? Number(values.unitPrice) : null,
totalPrice: Number(values.totalPrice),
pricingMode: values.pricingMode,
sortOrder: meta.sortOrder,
source: meta.source,
};
}

View file

@ -1,9 +1,8 @@
// Line items domain — TanStack Query hooks + query keys (the only public surface). // Line items domain — TanStack Query hooks + query keys (the only public surface).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; // Mutations on line items flow through useSaveProposalWorkspace (admin domain);
import { toast } from 'react-toastify'; // add dedicated mutation hooks here when a page actually consumes them.
import { useQuery } from '@tanstack/react-query';
import { lineItemsApi } from './api'; import { lineItemsApi } from './api';
import { proposalsKeys } from '../proposals/use-cases';
import type { CreateLineItemRequest, UpdateLineItemEntry } from './types';
export const lineItemsKeys = { export const lineItemsKeys = {
all: ['lineItems'] as const, all: ['lineItems'] as const,
@ -18,47 +17,3 @@ export function useLineItems(proposalId: string | undefined) {
enabled: !!proposalId, enabled: !!proposalId,
}); });
} }
export function useCreateLineItem(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateLineItemRequest) => lineItemsApi.create(proposalId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Failed to add line item: ${error.message}`);
},
});
}
/** Replace-all update of a proposal's line items. Totals change, so the
* proposal detail is invalidated alongside the line items. */
export function useBulkUpdateLineItems(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (lineItems: UpdateLineItemEntry[]) => lineItemsApi.bulkUpdate(proposalId, lineItems),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
}
export function useDeleteLineItem(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: string) => lineItemsApi.delete(proposalId, itemId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Failed to delete line item: ${error.message}`);
},
});
}

View file

@ -1,6 +1,7 @@
// Pricing library domain — contract schema re-exports + item form schema. // Pricing library domain — contract schema re-exports + item form schema.
import { z } from 'zod'; import { z } from 'zod';
import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas'; import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas';
import { optionalNumericString, toNumberOrNull } from '../shared/formFields';
import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types'; import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types';
export { export {
@ -20,11 +21,9 @@ export const pricingLibraryItemFormSchema = createPricingLibraryItemRequestSchem
.extend({ .extend({
title: z.string().trim().min(1, 'Title is required'), title: z.string().trim().min(1, 'Title is required'),
unit: z.string(), unit: z.string(),
unitPrice: z // Shared Number-based field — rejects trailing garbage ('12abc') that
.string() // the old parseFloat refine silently coerced to 12.
.refine((v) => v.trim() === '' || (!Number.isNaN(parseFloat(v)) && parseFloat(v) >= 0), { unitPrice: optionalNumericString,
message: 'Must be a number >= 0',
}),
keywords: z.string(), keywords: z.string(),
description: z.string(), description: z.string(),
}); });
@ -43,7 +42,7 @@ export function toPricingLibraryItemRequest(
title: values.title.trim(), title: values.title.trim(),
serviceCategory: values.serviceCategory, serviceCategory: values.serviceCategory,
...(values.unit.trim() ? { unit: values.unit.trim() } : {}), ...(values.unit.trim() ? { unit: values.unit.trim() } : {}),
unitPrice: values.unitPrice.trim() !== '' ? parseFloat(values.unitPrice) : null, unitPrice: toNumberOrNull(values.unitPrice),
...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}), ...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}),
...(values.description.trim() ? { description: values.description.trim() } : {}), ...(values.description.trim() ? { description: values.description.trim() } : {}),
}; };

View file

@ -1,5 +1,5 @@
// Pricing library domain — TanStack Query hooks + query keys (the only public surface). // Pricing library domain — TanStack Query hooks + query keys (the only public surface).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { pricingLibraryApi } from './api'; import { pricingLibraryApi } from './api';
import type { import type {
@ -14,11 +14,14 @@ export const pricingLibraryKeys = {
list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const, list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const,
}; };
/** Paginated pricing library list (PricingLibraryPage). */ /** Paginated pricing library list (PricingLibraryPage). keepPreviousData
* keeps rows/totalCount rendered while a new page fetches (prevents
* TablePagination's count collapsing to 0 mid-fetch). */
export function usePricingLibraryList(params: PricingLibraryListParams = {}) { export function usePricingLibraryList(params: PricingLibraryListParams = {}) {
return useQuery({ return useQuery({
queryKey: pricingLibraryKeys.list(params), queryKey: pricingLibraryKeys.list(params),
queryFn: () => pricingLibraryApi.list(params), queryFn: () => pricingLibraryApi.list(params),
placeholderData: keepPreviousData,
}); });
} }

View file

@ -88,4 +88,21 @@ export const proposalsApi = {
const downloadUrl = URL.createObjectURL(blob); const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl }; return { downloadUrl };
}, },
/**
* PUT a file to a presigned S3 URL. Plain fetch by design — the presigned
* URL must NOT carry our auth headers (the axios client would attach them).
* fetch resolves on HTTP errors, so the ok check is what surfaces S3
* rejections (expired signature, size/content-type mismatch) as failures.
*/
uploadToPresignedUrl: async (uploadUrl: string, file: File): Promise<void> => {
const res = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type || 'application/pdf' },
});
if (!res.ok) {
throw new Error(`Vendor PDF upload failed (${res.status})`);
}
},
}; };

View file

@ -72,11 +72,8 @@ export function useCreateProposal() {
proposal.id, proposal.id,
vendorFile.name vendorFile.name
); );
await fetch(uploadUrl, { // Throws on S3 rejection (non-2xx) so a failed upload is never confirmed.
method: 'PUT', await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile);
body: vendorFile,
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
await proposalsApi.confirmUpload(proposal.id, vendorProposalId); await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch { } catch {
toast.warning( toast.warning(

View file

@ -0,0 +1,23 @@
// Shared form-field schemas for numeric text inputs (single source — the
// Number-based validation deliberately rejects trailing garbage like '12abc',
// which parseFloat would silently coerce to 12).
import { z } from 'zod';
/** Required numeric text input (>= 0). */
export const numericString = z
.string()
.refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)) && Number(v) >= 0, {
message: 'Must be a number >= 0',
});
/** Optional numeric text input — empty maps to null on the wire. */
export const optionalNumericString = z
.string()
.refine((v) => v.trim() === '' || (!Number.isNaN(Number(v)) && Number(v) >= 0), {
message: 'Must be a number >= 0',
});
/** Convert an optionalNumericString form value to the wire number|null. */
export function toNumberOrNull(value: string): number | null {
return value.trim() !== '' ? Number(value) : null;
}

View file

@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react';
/**
* Debounce a changing value. Single home for the debounce logic that was
* previously centralized in usePaginatedList and then hand-copied into four
* pages during the domain-layer migration (each copy had drifted: one leaked
* its timer on unmount, two hardcoded 300ms).
*
* @param onSettle optional callback fired when the debounced value actually
* changes — used by list pages to reset pagination to page 1 on a new search.
*/
export function useDebouncedValue<T>(value: T, delayMs: number, onSettle?: (value: T) => void): T {
const [debounced, setDebounced] = useState(value);
const debouncedRef = useRef(value);
// Latest-callback ref so consumers can pass inline callbacks without
// re-arming the timer every render.
const onSettleRef = useRef(onSettle);
onSettleRef.current = onSettle;
useEffect(() => {
const timer = setTimeout(() => {
if (debouncedRef.current !== value) {
debouncedRef.current = value;
setDebounced(value);
onSettleRef.current?.(value);
}
}, delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}

View file

@ -42,6 +42,7 @@ import {
} from '../../../domain/customers/schemas'; } from '../../../domain/customers/schemas';
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const COL_COUNT = 4; // Name, Address, Contact Email, Actions const COL_COUNT = 4; // Name, Address, Contact Email, Actions
@ -52,22 +53,13 @@ export default function CustomerManagementPage() {
// --- List state --- // --- List state ---
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE); const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Debounce search input // Debounced search resets to page 1 (shared hook — the previous inline
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | null>(null); // timer leaked on unmount).
const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => setSearch(value);
setSearch(value);
if (debounceTimer) clearTimeout(debounceTimer);
const timer = setTimeout(() => {
setDebouncedSearch(value);
setPage(1);
}, DEBOUNCE_SEARCH);
setDebounceTimer(timer);
};
const { data, isLoading, isError, refetch } = useCustomerList({ const { data, isLoading, isError, refetch } = useCustomerList({
search: debouncedSearch || undefined, search: debouncedSearch || undefined,

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, Fragment } from 'react'; import { useMemo, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Box, Box,
@ -42,6 +42,7 @@ import { useAdminDashboard } from '../../../domain/admin/use-cases';
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants'; import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format'; import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const COL_COUNT = 11; const COL_COUNT = 11;
const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
@ -81,18 +82,11 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE); const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Debounced search resets to page 1 (mirrors the old usePaginatedList behavior). // Debounced search resets to page 1 (mirrors the old usePaginatedList behavior).
useEffect(() => { const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
const handle = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, DEBOUNCE_SEARCH);
return () => clearTimeout(handle);
}, [search]);
const { const {
data: listData, data: listData,
@ -415,7 +409,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
</Fragment> </Fragment>
); );
})} })}
{!loading && rows.length === 0 && ( {/* Empty state only when the fetch actually succeeded. */}
{!loading && !err && rows.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}> <TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary" sx={{ mb: 1 }}> <Typography color="text.secondary" sx={{ mb: 1 }}>

View file

@ -162,10 +162,13 @@ export default function AdminWorkspace() {
// with the previous approve mutation, which saved first when dirty). // with the previous approve mutation, which saved first when dirty).
if (dirty) { if (dirty) {
await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) }); await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) });
// The edits are persisted regardless of whether the approve below
// succeeds — clear dirty NOW so a failed approve doesn't leave a
// false unsaved-changes prompt over already-saved work.
setDirty(false);
} }
await approveMutation.mutateAsync(); await approveMutation.mutateAsync();
setApproveDialogOpen(false); setApproveDialogOpen(false);
setDirty(false);
} catch { } catch {
// error toasts are handled by the domain hooks // error toasts are handled by the domain hooks
} }

View file

@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -25,7 +25,8 @@ import {
} from '../../../domain/proposals/schemas'; } from '../../../domain/proposals/schemas';
import { useSiteSearch } from '../../../domain/sites/use-cases'; import { useSiteSearch } from '../../../domain/sites/use-cases';
import { useCustomerSearch } from '../../../domain/customers/use-cases'; import { useCustomerSearch } from '../../../domain/customers/use-cases';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants'; import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, DEBOUNCE_AUTOCOMPLETE } from '../../../constants';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['application/pdf']; const ALLOWED_MIME_TYPES = ['application/pdf'];
@ -54,7 +55,7 @@ export default function ProposalFormPage() {
}); });
const [vendorFile, setVendorFile] = useState<File | null>(null); const [vendorFile, setVendorFile] = useState<File | null>(null);
const [siteQuery, setSiteQuery] = useState(''); const [siteSearchInput, setSiteSearchInput] = useState('');
const [siteInputValue, setSiteInputValue] = useState(''); const [siteInputValue, setSiteInputValue] = useState('');
const [manualEntry, setManualEntry] = useState(false); const [manualEntry, setManualEntry] = useState(false);
const [manualSiteCode, setManualSiteCode] = useState(''); const [manualSiteCode, setManualSiteCode] = useState('');
@ -62,12 +63,15 @@ export default function ProposalFormPage() {
const [manualCity, setManualCity] = useState(''); const [manualCity, setManualCity] = useState('');
const [manualState, setManualState] = useState(''); const [manualState, setManualState] = useState('');
const [manualZip, setManualZip] = useState(''); const [manualZip, setManualZip] = useState('');
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// PR4: customer search state — freeSolo Autocomplete backed by the customers domain // PR4: customer search state — freeSolo Autocomplete backed by the customers domain
const [customerQuery, setCustomerQuery] = useState(''); const [customerSearchInput, setCustomerSearchInput] = useState('');
const [customerInputValue, setCustomerInputValue] = useState(''); const [customerInputValue, setCustomerInputValue] = useState('');
const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Shared debounce hook (search input is only updated on typing, so
// selecting an option never fires a search request).
const siteQuery = useDebouncedValue(siteSearchInput, DEBOUNCE_AUTOCOMPLETE);
const customerQuery = useDebouncedValue(customerSearchInput, DEBOUNCE_AUTOCOMPLETE);
// Domain search hooks (disabled below 2 characters, matching prior behavior). // Domain search hooks (disabled below 2 characters, matching prior behavior).
const siteSearch = useSiteSearch(siteQuery); const siteSearch = useSiteSearch(siteQuery);
@ -78,13 +82,6 @@ export default function ProposalFormPage() {
const customerOptions = (customerSearch.data ?? []).map((c) => c.name); const customerOptions = (customerSearch.data ?? []).map((c) => c.name);
const customerLoading = customerSearch.isFetching; const customerLoading = customerSearch.isFetching;
useEffect(() => {
return () => {
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
};
}, []);
useEffect(() => { useEffect(() => {
if (!manualEntry) return; if (!manualEntry) return;
const addressParts = [ const addressParts = [
@ -124,25 +121,28 @@ export default function ProposalFormPage() {
const scopeTouched = scopeOfWork.length > 0; const scopeTouched = scopeOfWork.length > 0;
const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH; const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
const isValid = // SINGLE source for the submit gate and the missing-fields caption —
workOrderNumber.trim() && // mirrors createProposalFormSchema (zodResolver re-enforces at submit).
customerName.trim() && // Deriving both from one list keeps the button and the caption from
customerAddress.trim() && // drifting (the previous split logic disabled Submit for a missing
scopeOfWork.trim().length >= MIN_SCOPE_LENGTH && // customer while the caption never mentioned it).
serviceCategory &&
(serviceCategory !== 'Other' || customCategory.trim());
const missingFields = useMemo(() => { const missingFields = useMemo(() => {
const missing: string[] = []; const checks: Array<{ label: string; ok: boolean }> = [
if (!workOrderNumber.trim()) missing.push('Work Order Number'); { label: 'Work Order Number', ok: !!workOrderNumber.trim() },
if (!customerAddress.trim()) missing.push('Site address'); { label: 'Customer', ok: !!customerName.trim() },
if (!serviceCategory) missing.push('Service Category'); { label: 'Site address', ok: !!customerAddress.trim() },
if (serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category'); { label: 'Service Category', ok: !!serviceCategory },
// Fix: WEB-M3 — show minimum length requirement in missing fields summary { label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() },
if (!scopeOfWork.trim()) missing.push('Scope of Work'); {
else if (scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`); // Fix: WEB-M3 — show minimum length requirement in missing fields summary
return missing; label: !scopeOfWork.trim() ? 'Scope of Work' : `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`,
}, [workOrderNumber, customerAddress, serviceCategory, scopeOfWork, customCategory]); ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH,
},
];
return checks.filter((c) => !c.ok).map((c) => c.label);
}, [workOrderNumber, customerName, customerAddress, serviceCategory, scopeOfWork, customCategory]);
const isValid = missingFields.length === 0;
return ( return (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}> <Box sx={{ maxWidth: 1200, mx: 'auto' }}>
@ -183,12 +183,11 @@ export default function ProposalFormPage() {
if (reason === 'input') { if (reason === 'input') {
setCustomerInputValue(value); setCustomerInputValue(value);
setValue('customerName', value); setValue('customerName', value);
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current); setCustomerSearchInput(value);
customerDebounceRef.current = setTimeout(() => setCustomerQuery(value), 300);
} else if (reason === 'clear') { } else if (reason === 'clear') {
setCustomerInputValue(''); setCustomerInputValue('');
setValue('customerName', ''); setValue('customerName', '');
setCustomerQuery(''); setCustomerSearchInput('');
} }
}} }}
onChange={(_, value) => { onChange={(_, value) => {
@ -337,8 +336,7 @@ export default function ProposalFormPage() {
onInputChange={(_, value, reason) => { onInputChange={(_, value, reason) => {
if (reason === 'input') { if (reason === 'input') {
setSiteInputValue(value); setSiteInputValue(value);
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); setSiteSearchInput(value);
siteDebounceRef.current = setTimeout(() => setSiteQuery(value), 300);
} }
}} }}
onChange={(_, value) => { onChange={(_, value) => {

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Box, Box,
@ -21,6 +21,7 @@ import {
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle'; import AddCircleIcon from '@mui/icons-material/AddCircle';
import { useProposals } from '../../../domain/proposals/use-cases'; import { useProposals } from '../../../domain/proposals/use-cases';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants'; import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format'; import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
@ -30,22 +31,11 @@ export default function ProposalListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE); const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounced search resets to page 1 (mirrors usePaginatedList). // Debounced search resets to page 1 (mirrors usePaginatedList).
useEffect(() => { const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, DEBOUNCE_SEARCH);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const { data, isLoading, isPlaceholderData, error } = useProposals({ const { data, isLoading, isPlaceholderData, error } = useProposals({
search: debouncedSearch, search: debouncedSearch,
@ -153,7 +143,9 @@ export default function ProposalListPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{!loading && rows.length === 0 && ( {/* Empty state only when the fetch actually succeeded — on error
the banner above is the whole story. */}
{!loading && !err && rows.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}> <TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary"> <Typography color="text.secondary">

View file

@ -18,7 +18,7 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"paths": { "paths": {
"@proposal-system/api-contracts": ["../shared/api-contracts/src"], "@proposal-system/api-contracts": ["../shared/api-contracts/src"],
"@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"] "@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas.ts"]
} }
}, },
"include": ["src", "../shared/api-contracts/src"] "include": ["src", "../shared/api-contracts/src"]

View file

@ -1,4 +1,3 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
@ -7,16 +6,9 @@ export default defineConfig({
resolve: { resolve: {
// @proposal-system/api-contracts is a file: symlink; preserve it so its // @proposal-system/api-contracts is a file: symlink; preserve it so its
// 'zod' import resolves from web/node_modules in CI (shared has no // 'zod' import resolves from web/node_modules in CI (shared has no
// installed node_modules there). // installed node_modules there). Subpath entrypoints (./schemas) resolve
// via the package's `exports` map.
preserveSymlinks: true, preserveSymlinks: true,
alias: {
// The package has no `exports` map (main points at src/index.ts), so
// the /schemas subpath entrypoint needs an explicit alias (mirrors the
// tsconfig `paths` mapping; vitest.config.ts carries the same alias).
'@proposal-system/api-contracts/schemas': fileURLToPath(
new URL('../shared/api-contracts/src/schemas.ts', import.meta.url)
),
},
}, },
server: { server: {
port: 5173, port: 5173,

View file

@ -1,18 +1,13 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { // @proposal-system/api-contracts is a file: symlink; subpath entrypoints
// @proposal-system/api-contracts has no `exports` map (main points at // (./schemas) resolve via the package's `exports` map. preserveSymlinks
// src/index.ts), so the /schemas subpath entrypoint needs an explicit // keeps its 'zod' import resolving from web/node_modules in CI.
// alias for vitest's resolver (mirrors the tsconfig `paths` mapping). preserveSymlinks: true,
'@proposal-system/api-contracts/schemas': fileURLToPath(
new URL('../shared/api-contracts/src/schemas.ts', import.meta.url)
),
},
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',