diff --git a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs index a617206..451688e 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs @@ -65,7 +65,9 @@ public class CustomerService : ICustomerService Id = Guid.NewGuid(), Name = request.Name, 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, UpdatedAt = now, }; diff --git a/shared/api-contracts/package.json b/shared/api-contracts/package.json index 59f22a7..7bebfa2 100644 --- a/shared/api-contracts/package.json +++ b/shared/api-contracts/package.json @@ -4,6 +4,16 @@ "private": true, "main": "./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": { "typecheck": "tsc --noEmit" }, diff --git a/web/src/components/admin/VendorDataPanel.tsx b/web/src/components/admin/VendorDataPanel.tsx index f6b662c..a3e956d 100644 --- a/web/src/components/admin/VendorDataPanel.tsx +++ b/web/src/components/admin/VendorDataPanel.tsx @@ -1,37 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; 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'; -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 { proposalId: string; } export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) { - const { data: vendors, isLoading } = useQuery({ - queryKey: ['vendorProposals', proposalId], - queryFn: async () => { - const res = await apiClient.get(`/proposals/${proposalId}/vendors`); - return res.data; - }, - enabled: !!proposalId, - }); + const { data: vendors, isLoading } = useVendorProposals(proposalId); if (isLoading) { return ; diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index 89b2b60..aed79d1 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -12,6 +12,7 @@ export const DEFAULT_PAGE_SIZE = 12; export const DEFAULT_PAGE = 1; 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_SIDEBAR = 'sidebarOpen'; diff --git a/web/src/domain/__tests__/admin.use-cases.test.tsx b/web/src/domain/__tests__/admin.use-cases.test.tsx index 617ee08..fc734f6 100644 --- a/web/src/domain/__tests__/admin.use-cases.test.tsx +++ b/web/src/domain/__tests__/admin.use-cases.test.tsx @@ -66,6 +66,18 @@ describe('useApproveProposal', () => { expect(invalidateSpy).toHaveBeenCalledWith({ 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'); }); diff --git a/web/src/domain/__tests__/customers.use-cases.test.tsx b/web/src/domain/__tests__/customers.use-cases.test.tsx index 65b0e66..059be69 100644 --- a/web/src/domain/__tests__/customers.use-cases.test.tsx +++ b/web/src/domain/__tests__/customers.use-cases.test.tsx @@ -1,5 +1,6 @@ // 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. import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; @@ -42,7 +43,7 @@ describe('customersKeys', () => { }); 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); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); @@ -51,7 +52,7 @@ describe('useCreateCustomer', () => { result.current.mutate({ name: 'Acme Corp' } as never); 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'); }); @@ -70,7 +71,7 @@ describe('useCreateCustomer', () => { }); 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); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); @@ -81,7 +82,7 @@ describe('useUpdateCustomer', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); 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'); }); diff --git a/web/src/domain/__tests__/lineItems.use-cases.test.tsx b/web/src/domain/__tests__/lineItems.use-cases.test.tsx index 6b32dfa..9cde471 100644 --- a/web/src/domain/__tests__/lineItems.use-cases.test.tsx +++ b/web/src/domain/__tests__/lineItems.use-cases.test.tsx @@ -1,15 +1,34 @@ -// Line items domain use-case hooks: bulk update must invalidate BOTH the -// proposal's line-items key and the proposal detail key (totals change). -// The domain api module is mocked — no axios traffic. +// Line-item persistence flows through useSaveProposalWorkspace (admin +// domain) — the live workspace save path: update proposal, bulk-replace line +// items, broad-invalidate every cached view. The domain api modules are +// mocked — no axios traffic. import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { toast } from 'react-toastify'; 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 { adminApi } from '../admin/api'; import { lineItemsApi } from '../lineItems/api'; 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', () => ({ lineItemsApi: { getAll: vi.fn(), @@ -39,36 +58,43 @@ describe('lineItemsKeys', () => { }); }); -describe('useBulkUpdateLineItems', () => { - it('sends the entries and invalidates the proposal line-items + detail keys on success', async () => { +describe('useSaveProposalWorkspace', () => { + 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([]); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); - const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); - result.current.mutate(entries); + const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper }); + result.current.mutate({ refinedScope: 'refined', lineItems: entries }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' }); expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries); expect(invalidateSpy).toHaveBeenCalledWith({ 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({ 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 () => { - vi.mocked(lineItemsApi.bulkUpdate).mockRejectedValue(new Error('409 conflict')); + vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict')); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); - const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); - result.current.mutate(entries); + const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper }); + result.current.mutate({ refinedScope: 'refined', lineItems: entries }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict'); + expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled(); expect(invalidateSpy).not.toHaveBeenCalled(); }); }); diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts index ff344ba..6497fe1 100644 --- a/web/src/domain/__tests__/schemas.test.ts +++ b/web/src/domain/__tests__/schemas.test.ts @@ -13,7 +13,6 @@ import { pricingLibraryItemFormSchema, toPricingLibraryItemRequest, } from '../pricingLibrary/schemas'; -import { lineItemRowFormSchema, toUpdateLineItemEntry } from '../lineItems/schemas'; describe('createProposalFormSchema', () => { const valid = { @@ -85,7 +84,9 @@ describe('customerFormSchema', () => { { name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, ['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', () => { - const valid = { - description: 'Install thermostat', - unit: 'each', - pricingMode: 'UnitPrice' as const, - quantity: '2', - unitPrice: '150', - totalPrice: '300', - }; - - it('accepts numeric strings and an empty unitPrice', () => { - expect(lineItemRowFormSchema.safeParse(valid).success).toBe(true); - expect(lineItemRowFormSchema.safeParse({ ...valid, unitPrice: '' }).success).toBe(true); - expect(lineItemRowFormSchema.safeParse({ ...valid, quantity: '' }).success).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(); +describe('shared numeric form fields', () => { + it('rejects trailing garbage that parseFloat would silently coerce', () => { + // Code-review finding (Phase 4): '12abc' passed the old parseFloat-based + // pricing refine as 12; the shared Number-based field must reject it. + expect( + pricingLibraryItemFormSchema.safeParse({ + title: 'Coil cleaning', + serviceCategory: 'HVAC', + unit: '', + unitPrice: '12abc', + keywords: '', + description: '', + }).success + ).toBe(false); }); }); diff --git a/web/src/domain/admin/use-cases.ts b/web/src/domain/admin/use-cases.ts index dadadb6..9c0f8d7 100644 --- a/web/src/domain/admin/use-cases.ts +++ b/web/src/domain/admin/use-cases.ts @@ -4,23 +4,35 @@ // 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. -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { adminApi } from './api'; import { lineItemsApi } from '../lineItems/api'; import { proposalsKeys } from '../proposals/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases'; -import type { UpdateProposalRequest } from './types'; import type { UpdateLineItemEntry } from '../lineItems/types'; export const adminKeys = { all: ['admin'] as const, dashboard: () => [...adminKeys.all, 'dashboard'] 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). */ export function useAdminDashboard() { 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 { refinedScope: string; lineItems: UpdateLineItemEntry[]; @@ -71,8 +67,7 @@ export function useSaveProposalWorkspace(proposalId: string) { await lineItemsApi.bulkUpdate(proposalId, lineItems); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Changes saved'); }, 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) { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => adminApi.approveProposal(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Proposal approved'); }, onError: (error: Error) => { @@ -114,9 +95,11 @@ export function useSendProposal(proposalId: string) { return useMutation({ mutationFn: () => adminApi.sendProposal(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + invalidateProposalViews(queryClient, proposalId); 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) => { toast.error(`Send failed: ${error.message}`); }, @@ -128,9 +111,11 @@ export function useReviseProposal(proposalId: string) { return useMutation({ mutationFn: () => adminApi.reviseProposal(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + invalidateProposalViews(queryClient, proposalId); 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) => { toast.error(`Revision failed: ${error.message}`); }, @@ -142,8 +127,7 @@ export function useReturnToReview(proposalId: string) { return useMutation({ mutationFn: () => adminApi.returnToReview(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Proposal returned to review'); }, onError: (error: Error) => { diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts index fe5f233..e7c2ab0 100644 --- a/web/src/domain/customers/schemas.ts +++ b/web/src/domain/customers/schemas.ts @@ -33,8 +33,10 @@ export type CustomerFormValues = z.infer; /** * Map validated form values to the create/update request. The form edits the - * primary address; any additional existing addresses are preserved - * (mirrors the current CustomerManagementPage submit mapping). + * primary address; any additional existing addresses are preserved. + * 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( values: CustomerFormValues, @@ -43,6 +45,6 @@ export function toCustomerRequest( return { name: values.name.trim(), addresses: [values.address.trim(), ...existingAddresses.slice(1)], - ...(values.contactEmail.trim() ? { contactEmail: values.contactEmail.trim() } : {}), + contactEmail: values.contactEmail.trim(), }; } diff --git a/web/src/domain/customers/use-cases.ts b/web/src/domain/customers/use-cases.ts index ab37d6b..81ac14a 100644 --- a/web/src/domain/customers/use-cases.ts +++ b/web/src/domain/customers/use-cases.ts @@ -1,5 +1,5 @@ // 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 { customersApi } from './api'; 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 = {}) { return useQuery({ queryKey: customersKeys.list(params), queryFn: () => customersApi.list(params), + placeholderData: keepPreviousData, }); } @@ -34,7 +38,11 @@ export function useCreateCustomer() { return useMutation({ mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), 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'); }, onError: (error: Error) => { @@ -49,7 +57,8 @@ export function useUpdateCustomer() { mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => customersApi.update(id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: customersKeys.lists() }); + // Whole-domain invalidation — see useCreateCustomer. + queryClient.invalidateQueries({ queryKey: customersKeys.all }); toast.success('Customer updated'); }, onError: (error: Error) => { diff --git a/web/src/domain/lineItems/schemas.ts b/web/src/domain/lineItems/schemas.ts index f573810..3163f75 100644 --- a/web/src/domain/lineItems/schemas.ts +++ b/web/src/domain/lineItems/schemas.ts @@ -1,8 +1,6 @@ -// Line items domain — contract schema re-exports + line-item row form schema. -import { z } from 'zod'; -import { updateLineItemEntrySchema } from '@proposal-system/api-contracts/schemas'; -import type { UpdateLineItemEntry } from './types'; - +// Line items domain — contract schema re-exports. +// A row-level form schema will arrive with the LineItemEditor migration to +// react-hook-form; until a component consumes it, none is defined here. export { pricingModeSchema, lineItemSourceSchema, @@ -11,51 +9,3 @@ export { updateLineItemEntrySchema, bulkUpdateLineItemsRequestSchema, } 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; - -/** 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, - }; -} diff --git a/web/src/domain/lineItems/use-cases.ts b/web/src/domain/lineItems/use-cases.ts index 01c02a6..c50a047 100644 --- a/web/src/domain/lineItems/use-cases.ts +++ b/web/src/domain/lineItems/use-cases.ts @@ -1,9 +1,8 @@ // Line items domain — TanStack Query hooks + query keys (the only public surface). -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'react-toastify'; +// Mutations on line items flow through useSaveProposalWorkspace (admin domain); +// add dedicated mutation hooks here when a page actually consumes them. +import { useQuery } from '@tanstack/react-query'; import { lineItemsApi } from './api'; -import { proposalsKeys } from '../proposals/use-cases'; -import type { CreateLineItemRequest, UpdateLineItemEntry } from './types'; export const lineItemsKeys = { all: ['lineItems'] as const, @@ -18,47 +17,3 @@ export function useLineItems(proposalId: string | undefined) { 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}`); - }, - }); -} diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts index 434ce75..5d0c306 100644 --- a/web/src/domain/pricingLibrary/schemas.ts +++ b/web/src/domain/pricingLibrary/schemas.ts @@ -1,6 +1,7 @@ // Pricing library domain — contract schema re-exports + item form schema. import { z } from 'zod'; import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas'; +import { optionalNumericString, toNumberOrNull } from '../shared/formFields'; import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types'; export { @@ -20,11 +21,9 @@ export const pricingLibraryItemFormSchema = createPricingLibraryItemRequestSchem .extend({ title: z.string().trim().min(1, 'Title is required'), unit: z.string(), - unitPrice: z - .string() - .refine((v) => v.trim() === '' || (!Number.isNaN(parseFloat(v)) && parseFloat(v) >= 0), { - message: 'Must be a number >= 0', - }), + // Shared Number-based field — rejects trailing garbage ('12abc') that + // the old parseFloat refine silently coerced to 12. + unitPrice: optionalNumericString, keywords: z.string(), description: z.string(), }); @@ -43,7 +42,7 @@ export function toPricingLibraryItemRequest( title: values.title.trim(), serviceCategory: values.serviceCategory, ...(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.description.trim() ? { description: values.description.trim() } : {}), }; diff --git a/web/src/domain/pricingLibrary/use-cases.ts b/web/src/domain/pricingLibrary/use-cases.ts index e237877..6ca6a74 100644 --- a/web/src/domain/pricingLibrary/use-cases.ts +++ b/web/src/domain/pricingLibrary/use-cases.ts @@ -1,5 +1,5 @@ // 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 { pricingLibraryApi } from './api'; import type { @@ -14,11 +14,14 @@ export const pricingLibraryKeys = { 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 = {}) { return useQuery({ queryKey: pricingLibraryKeys.list(params), queryFn: () => pricingLibraryApi.list(params), + placeholderData: keepPreviousData, }); } diff --git a/web/src/domain/proposals/api.ts b/web/src/domain/proposals/api.ts index 61567cf..b077f1f 100644 --- a/web/src/domain/proposals/api.ts +++ b/web/src/domain/proposals/api.ts @@ -88,4 +88,21 @@ export const proposalsApi = { const downloadUrl = URL.createObjectURL(blob); 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 => { + 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})`); + } + }, }; diff --git a/web/src/domain/proposals/use-cases.ts b/web/src/domain/proposals/use-cases.ts index ba2ac5d..99c3db9 100644 --- a/web/src/domain/proposals/use-cases.ts +++ b/web/src/domain/proposals/use-cases.ts @@ -72,11 +72,8 @@ export function useCreateProposal() { proposal.id, vendorFile.name ); - await fetch(uploadUrl, { - method: 'PUT', - body: vendorFile, - headers: { 'Content-Type': vendorFile.type || 'application/pdf' }, - }); + // Throws on S3 rejection (non-2xx) so a failed upload is never confirmed. + await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile); await proposalsApi.confirmUpload(proposal.id, vendorProposalId); } catch { toast.warning( diff --git a/web/src/domain/shared/formFields.ts b/web/src/domain/shared/formFields.ts new file mode 100644 index 0000000..db3e0c9 --- /dev/null +++ b/web/src/domain/shared/formFields.ts @@ -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; +} diff --git a/web/src/hooks/useDebouncedValue.ts b/web/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..2105437 --- /dev/null +++ b/web/src/hooks/useDebouncedValue.ts @@ -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(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; +} diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index aa6b26a..03ce926 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -42,6 +42,7 @@ import { } from '../../../domain/customers/schemas'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; +import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 4; // Name, Address, Contact Email, Actions @@ -52,22 +53,13 @@ export default function CustomerManagementPage() { // --- List state --- const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - // Debounce search input - const [debounceTimer, setDebounceTimer] = useState | null>(null); - - const handleSearchChange = (value: string) => { - setSearch(value); - if (debounceTimer) clearTimeout(debounceTimer); - const timer = setTimeout(() => { - setDebouncedSearch(value); - setPage(1); - }, DEBOUNCE_SEARCH); - setDebounceTimer(timer); - }; + // Debounced search resets to page 1 (shared hook — the previous inline + // timer leaked on unmount). + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); + const handleSearchChange = (value: string) => setSearch(value); const { data, isLoading, isError, refetch } = useCustomerList({ search: debouncedSearch || undefined, diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index d0d0bce..8bc8700 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, Fragment } from 'react'; +import { useMemo, useState, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; import { 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 { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; +import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 11; const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; @@ -81,18 +82,11 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri const [expanded, setExpanded] = useState>(new Set()); const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); // Debounced search resets to page 1 (mirrors the old usePaginatedList behavior). - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, DEBOUNCE_SEARCH); - return () => clearTimeout(handle); - }, [search]); + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); const { data: listData, @@ -415,7 +409,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri ); })} - {!loading && rows.length === 0 && ( + {/* Empty state only when the fetch actually succeeded. */} + {!loading && !err && rows.length === 0 && ( diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index b73a3a5..b7bb83b 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -162,10 +162,13 @@ export default function AdminWorkspace() { // with the previous approve mutation, which saved first when dirty). if (dirty) { 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(); setApproveDialogOpen(false); - setDirty(false); } catch { // error toasts are handled by the domain hooks } diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 041cb71..89a59a2 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -25,7 +25,8 @@ import { } from '../../../domain/proposals/schemas'; import { useSiteSearch } from '../../../domain/sites/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 ALLOWED_MIME_TYPES = ['application/pdf']; @@ -54,7 +55,7 @@ export default function ProposalFormPage() { }); const [vendorFile, setVendorFile] = useState(null); - const [siteQuery, setSiteQuery] = useState(''); + const [siteSearchInput, setSiteSearchInput] = useState(''); const [siteInputValue, setSiteInputValue] = useState(''); const [manualEntry, setManualEntry] = useState(false); const [manualSiteCode, setManualSiteCode] = useState(''); @@ -62,12 +63,15 @@ export default function ProposalFormPage() { const [manualCity, setManualCity] = useState(''); const [manualState, setManualState] = useState(''); const [manualZip, setManualZip] = useState(''); - const siteDebounceRef = useRef | null>(null); // PR4: customer search state — freeSolo Autocomplete backed by the customers domain - const [customerQuery, setCustomerQuery] = useState(''); + const [customerSearchInput, setCustomerSearchInput] = useState(''); const [customerInputValue, setCustomerInputValue] = useState(''); - const customerDebounceRef = useRef | 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). const siteSearch = useSiteSearch(siteQuery); @@ -78,13 +82,6 @@ export default function ProposalFormPage() { const customerOptions = (customerSearch.data ?? []).map((c) => c.name); const customerLoading = customerSearch.isFetching; - useEffect(() => { - return () => { - if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); - if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current); - }; - }, []); - useEffect(() => { if (!manualEntry) return; const addressParts = [ @@ -124,25 +121,28 @@ export default function ProposalFormPage() { const scopeTouched = scopeOfWork.length > 0; const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH; - const isValid = - workOrderNumber.trim() && - customerName.trim() && - customerAddress.trim() && - scopeOfWork.trim().length >= MIN_SCOPE_LENGTH && - serviceCategory && - (serviceCategory !== 'Other' || customCategory.trim()); - + // SINGLE source for the submit gate and the missing-fields caption — + // mirrors createProposalFormSchema (zodResolver re-enforces at submit). + // Deriving both from one list keeps the button and the caption from + // drifting (the previous split logic disabled Submit for a missing + // customer while the caption never mentioned it). const missingFields = useMemo(() => { - const missing: string[] = []; - if (!workOrderNumber.trim()) missing.push('Work Order Number'); - if (!customerAddress.trim()) missing.push('Site address'); - if (!serviceCategory) missing.push('Service Category'); - if (serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category'); - // Fix: WEB-M3 — show minimum length requirement in missing fields summary - 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)`); - return missing; - }, [workOrderNumber, customerAddress, serviceCategory, scopeOfWork, customCategory]); + const checks: Array<{ label: string; ok: boolean }> = [ + { label: 'Work Order Number', ok: !!workOrderNumber.trim() }, + { label: 'Customer', ok: !!customerName.trim() }, + { label: 'Site address', ok: !!customerAddress.trim() }, + { label: 'Service Category', ok: !!serviceCategory }, + { label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() }, + { + // Fix: WEB-M3 — show minimum length requirement in missing fields summary + label: !scopeOfWork.trim() ? 'Scope of Work' : `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`, + 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 ( @@ -183,12 +183,11 @@ export default function ProposalFormPage() { if (reason === 'input') { setCustomerInputValue(value); setValue('customerName', value); - if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current); - customerDebounceRef.current = setTimeout(() => setCustomerQuery(value), 300); + setCustomerSearchInput(value); } else if (reason === 'clear') { setCustomerInputValue(''); setValue('customerName', ''); - setCustomerQuery(''); + setCustomerSearchInput(''); } }} onChange={(_, value) => { @@ -337,8 +336,7 @@ export default function ProposalFormPage() { onInputChange={(_, value, reason) => { if (reason === 'input') { setSiteInputValue(value); - if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); - siteDebounceRef.current = setTimeout(() => setSiteQuery(value), 300); + setSiteSearchInput(value); } }} onChange={(_, value) => { diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index 3ab2aa6..7fde64d 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, @@ -21,6 +21,7 @@ import { import SearchIcon from '@mui/icons-material/Search'; import AddCircleIcon from '@mui/icons-material/AddCircle'; 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 { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -30,22 +31,11 @@ export default function ProposalListPage() { const navigate = useNavigate(); const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const debounceRef = useRef | null>(null); // Debounced search resets to page 1 (mirrors usePaginatedList). - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, DEBOUNCE_SEARCH); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [search]); + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); const { data, isLoading, isPlaceholderData, error } = useProposals({ search: debouncedSearch, @@ -153,7 +143,9 @@ export default function ProposalListPage() { ))} - {!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 && ( diff --git a/web/tsconfig.json b/web/tsconfig.json index 0651de3..21d273e 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -18,7 +18,7 @@ "noUncheckedIndexedAccess": true, "paths": { "@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"] diff --git a/web/vite.config.ts b/web/vite.config.ts index 5de237b..fde3a10 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,4 +1,3 @@ -import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -7,16 +6,9 @@ export default defineConfig({ resolve: { // @proposal-system/api-contracts is a file: symlink; preserve it so its // '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, - 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: { port: 5173, diff --git a/web/vitest.config.ts b/web/vitest.config.ts index e14846c..2b34195 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,18 +1,13 @@ -import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], resolve: { - alias: { - // @proposal-system/api-contracts has no `exports` map (main points at - // src/index.ts), so the /schemas subpath entrypoint needs an explicit - // alias for vitest's resolver (mirrors the tsconfig `paths` mapping). - '@proposal-system/api-contracts/schemas': fileURLToPath( - new URL('../shared/api-contracts/src/schemas.ts', import.meta.url) - ), - }, + // @proposal-system/api-contracts is a file: symlink; subpath entrypoints + // (./schemas) resolve via the package's `exports` map. preserveSymlinks + // keeps its 'zod' import resolving from web/node_modules in CI. + preserveSymlinks: true, }, test: { environment: 'jsdom',