mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 05:52:00 +00:00
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:
parent
2e1099a560
commit
1f55a59445
27 changed files with 283 additions and 337 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<VendorProposal[]>({
|
||||
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 <Skeleton variant="rectangular" height={60} />;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ export type CustomerFormValues = z.infer<typeof customerFormSchema>;
|
|||
|
||||
/**
|
||||
* 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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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})`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
23
web/src/domain/shared/formFields.ts
Normal file
23
web/src/domain/shared/formFields.ts
Normal 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;
|
||||
}
|
||||
32
web/src/hooks/useDebouncedValue.ts
Normal file
32
web/src/hooks/useDebouncedValue.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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<ReturnType<typeof setTimeout> | 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,
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(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
|
|||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{!loading && rows.length === 0 && (
|
||||
{/* Empty state only when the fetch actually succeeded. */}
|
||||
{!loading && !err && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<File | null>(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<ReturnType<typeof setTimeout> | 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<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).
|
||||
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 (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof setTimeout> | 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() {
|
|||
</TableCell>
|
||||
</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>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||
<Typography color="text.secondary">
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue