mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 12:48:57 +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(),
|
Id = Guid.NewGuid(),
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null,
|
Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null,
|
||||||
ContactEmail = request.ContactEmail,
|
// Normalize empty to null, matching the update path — clients may
|
||||||
|
// send contactEmail: "" (the web form always includes the field).
|
||||||
|
ContactEmail = string.IsNullOrEmpty(request.ContactEmail) ? null : request.ContactEmail,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,16 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./schemas": {
|
||||||
|
"types": "./src/schemas.ts",
|
||||||
|
"default": "./src/schemas.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,13 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Box, Typography, Skeleton, Chip } from '@mui/material';
|
import { Box, Typography, Skeleton, Chip } from '@mui/material';
|
||||||
import apiClient from '../../lib/api/client';
|
import { useVendorProposals } from '../../domain/proposals/use-cases';
|
||||||
import { formatCurrency } from '../../lib/format';
|
import { formatCurrency } from '../../lib/format';
|
||||||
|
|
||||||
interface VendorProposal {
|
|
||||||
id: string;
|
|
||||||
vendorName: string;
|
|
||||||
fileName: string;
|
|
||||||
totalVendorCost: number;
|
|
||||||
processingStatus: string;
|
|
||||||
extractedData: {
|
|
||||||
lineItems?: Array<{
|
|
||||||
description: string;
|
|
||||||
quantity?: number;
|
|
||||||
unitPrice?: number;
|
|
||||||
total?: number;
|
|
||||||
}>;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VendorDataPanelProps {
|
interface VendorDataPanelProps {
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
|
export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
|
||||||
const { data: vendors, isLoading } = useQuery<VendorProposal[]>({
|
const { data: vendors, isLoading } = useVendorProposals(proposalId);
|
||||||
queryKey: ['vendorProposals', proposalId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await apiClient.get(`/proposals/${proposalId}/vendors`);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!proposalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Skeleton variant="rectangular" height={60} />;
|
return <Skeleton variant="rectangular" height={60} />;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const DEFAULT_PAGE_SIZE = 12;
|
||||||
export const DEFAULT_PAGE = 1;
|
export const DEFAULT_PAGE = 1;
|
||||||
|
|
||||||
export const DEBOUNCE_SEARCH = 600;
|
export const DEBOUNCE_SEARCH = 600;
|
||||||
|
export const DEBOUNCE_AUTOCOMPLETE = 300; // typeahead fields respond faster than list search
|
||||||
|
|
||||||
export const STORAGE_KEY_TOKEN = 'proposal_system_token';
|
export const STORAGE_KEY_TOKEN = 'proposal_system_token';
|
||||||
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
|
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,18 @@ describe('useApproveProposal', () => {
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
|
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
|
||||||
});
|
});
|
||||||
|
// ...AND every cached view of the queue: lists/stats/KPIs sit under the
|
||||||
|
// global 5-minute staleTime, so without these the admin returns to a
|
||||||
|
// stale queue after approving (code-review finding, Phase 4).
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: proposalsKeys.lists(),
|
||||||
|
});
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: proposalsKeys.stats(),
|
||||||
|
});
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ['admin', 'dashboard'],
|
||||||
|
});
|
||||||
expect(toast.success).toHaveBeenCalledWith('Proposal approved');
|
expect(toast.success).toHaveBeenCalledWith('Proposal approved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Customers domain use-case hooks: create/update must invalidate the list
|
// Customers domain use-case hooks: create/update must invalidate the list
|
||||||
// key family (customersKeys.lists()) and toast. The domain api module is
|
// whole domain key family (customersKeys.all — lists AND cached search
|
||||||
|
// autocompletes; code-review finding, Phase 4) and toast. The domain api module is
|
||||||
// mocked — no axios traffic.
|
// mocked — no axios traffic.
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
|
@ -42,7 +43,7 @@ describe('customersKeys', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useCreateCustomer', () => {
|
describe('useCreateCustomer', () => {
|
||||||
it('invalidates customersKeys.lists() and toasts on success', async () => {
|
it('invalidates the whole customers domain (lists + search caches) and toasts on success', async () => {
|
||||||
vi.mocked(customersApi.create).mockResolvedValue(customer);
|
vi.mocked(customersApi.create).mockResolvedValue(customer);
|
||||||
const { queryClient, wrapper } = createQueryHarness();
|
const { queryClient, wrapper } = createQueryHarness();
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
|
@ -51,7 +52,7 @@ describe('useCreateCustomer', () => {
|
||||||
result.current.mutate({ name: 'Acme Corp' } as never);
|
result.current.mutate({ name: 'Acme Corp' } as never);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all });
|
||||||
expect(toast.success).toHaveBeenCalledWith('Customer created');
|
expect(toast.success).toHaveBeenCalledWith('Customer created');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ describe('useCreateCustomer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useUpdateCustomer', () => {
|
describe('useUpdateCustomer', () => {
|
||||||
it('passes id + payload through and invalidates customersKeys.lists() on success', async () => {
|
it('passes id + payload through and invalidates the whole customers domain on success', async () => {
|
||||||
vi.mocked(customersApi.update).mockResolvedValue(customer);
|
vi.mocked(customersApi.update).mockResolvedValue(customer);
|
||||||
const { queryClient, wrapper } = createQueryHarness();
|
const { queryClient, wrapper } = createQueryHarness();
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
|
@ -81,7 +82,7 @@ describe('useUpdateCustomer', () => {
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
expect(customersApi.update).toHaveBeenCalledWith('c-1', data);
|
expect(customersApi.update).toHaveBeenCalledWith('c-1', data);
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() });
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all });
|
||||||
expect(toast.success).toHaveBeenCalledWith('Customer updated');
|
expect(toast.success).toHaveBeenCalledWith('Customer updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,34 @@
|
||||||
// Line items domain use-case hooks: bulk update must invalidate BOTH the
|
// Line-item persistence flows through useSaveProposalWorkspace (admin
|
||||||
// proposal's line-items key and the proposal detail key (totals change).
|
// domain) — the live workspace save path: update proposal, bulk-replace line
|
||||||
// The domain api module is mocked — no axios traffic.
|
// items, broad-invalidate every cached view. The domain api modules are
|
||||||
|
// mocked — no axios traffic.
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { createQueryHarness } from './hookTestUtils';
|
import { createQueryHarness } from './hookTestUtils';
|
||||||
import { lineItemsKeys, useBulkUpdateLineItems } from '../lineItems/use-cases';
|
import { lineItemsKeys } from '../lineItems/use-cases';
|
||||||
|
import { useSaveProposalWorkspace } from '../admin/use-cases';
|
||||||
import { proposalsKeys } from '../proposals/use-cases';
|
import { proposalsKeys } from '../proposals/use-cases';
|
||||||
|
import { adminApi } from '../admin/api';
|
||||||
import { lineItemsApi } from '../lineItems/api';
|
import { lineItemsApi } from '../lineItems/api';
|
||||||
import type { UpdateLineItemEntry } from '../lineItems/types';
|
import type { UpdateLineItemEntry } from '../lineItems/types';
|
||||||
|
|
||||||
|
vi.mock('../admin/api', () => ({
|
||||||
|
adminApi: {
|
||||||
|
getDashboard: vi.fn(),
|
||||||
|
updateProposal: vi.fn(),
|
||||||
|
approveProposal: vi.fn(),
|
||||||
|
sendProposal: vi.fn(),
|
||||||
|
reviseProposal: vi.fn(),
|
||||||
|
returnToReview: vi.fn(),
|
||||||
|
generateSuggestions: vi.fn(),
|
||||||
|
getSimilar: vi.fn(),
|
||||||
|
getHistory: vi.fn(),
|
||||||
|
getAudit: vi.fn(),
|
||||||
|
getPdf: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../lineItems/api', () => ({
|
vi.mock('../lineItems/api', () => ({
|
||||||
lineItemsApi: {
|
lineItemsApi: {
|
||||||
getAll: vi.fn(),
|
getAll: vi.fn(),
|
||||||
|
|
@ -39,36 +58,43 @@ describe('lineItemsKeys', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useBulkUpdateLineItems', () => {
|
describe('useSaveProposalWorkspace', () => {
|
||||||
it('sends the entries and invalidates the proposal line-items + detail keys on success', async () => {
|
it('persists scope + entries and invalidates line items, detail, and list/stats views', async () => {
|
||||||
|
vi.mocked(adminApi.updateProposal).mockResolvedValue(undefined);
|
||||||
vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]);
|
vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]);
|
||||||
const { queryClient, wrapper } = createQueryHarness();
|
const { queryClient, wrapper } = createQueryHarness();
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
|
||||||
const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper });
|
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
|
||||||
result.current.mutate(entries);
|
result.current.mutate({ refinedScope: 'refined', lineItems: entries });
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' });
|
||||||
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries);
|
expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries);
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
|
queryKey: lineItemsKeys.byProposal(PROPOSAL_ID),
|
||||||
});
|
});
|
||||||
// Totals live on the proposal detail — it must be refetched too.
|
// Totals live on the proposal detail AND the cached queue/list views.
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
queryKey: proposalsKeys.detail(PROPOSAL_ID),
|
queryKey: proposalsKeys.detail(PROPOSAL_ID),
|
||||||
});
|
});
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: proposalsKeys.lists(),
|
||||||
|
});
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Changes saved');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts a save failure and skips invalidation', async () => {
|
it('toasts a save failure and skips invalidation', async () => {
|
||||||
vi.mocked(lineItemsApi.bulkUpdate).mockRejectedValue(new Error('409 conflict'));
|
vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict'));
|
||||||
const { queryClient, wrapper } = createQueryHarness();
|
const { queryClient, wrapper } = createQueryHarness();
|
||||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
|
||||||
const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper });
|
const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper });
|
||||||
result.current.mutate(entries);
|
result.current.mutate({ refinedScope: 'refined', lineItems: entries });
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict');
|
expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict');
|
||||||
|
expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled();
|
||||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
pricingLibraryItemFormSchema,
|
pricingLibraryItemFormSchema,
|
||||||
toPricingLibraryItemRequest,
|
toPricingLibraryItemRequest,
|
||||||
} from '../pricingLibrary/schemas';
|
} from '../pricingLibrary/schemas';
|
||||||
import { lineItemRowFormSchema, toUpdateLineItemEntry } from '../lineItems/schemas';
|
|
||||||
|
|
||||||
describe('createProposalFormSchema', () => {
|
describe('createProposalFormSchema', () => {
|
||||||
const valid = {
|
const valid = {
|
||||||
|
|
@ -85,7 +84,9 @@ describe('customerFormSchema', () => {
|
||||||
{ name: ' Acme ', address: ' 2 New St ', contactEmail: '' },
|
{ name: ' Acme ', address: ' 2 New St ', contactEmail: '' },
|
||||||
['1 Old St', '3 Extra Ave']
|
['1 Old St', '3 Extra Ave']
|
||||||
);
|
);
|
||||||
expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'] });
|
// contactEmail is ALWAYS sent — empty string clears the stored email
|
||||||
|
// (code-review finding, Phase 4: omission made emails impossible to clear).
|
||||||
|
expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'], contactEmail: '' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -118,40 +119,19 @@ describe('pricingLibraryItemFormSchema', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('lineItemRowFormSchema', () => {
|
describe('shared numeric form fields', () => {
|
||||||
const valid = {
|
it('rejects trailing garbage that parseFloat would silently coerce', () => {
|
||||||
description: 'Install thermostat',
|
// Code-review finding (Phase 4): '12abc' passed the old parseFloat-based
|
||||||
unit: 'each',
|
// pricing refine as 12; the shared Number-based field must reject it.
|
||||||
pricingMode: 'UnitPrice' as const,
|
expect(
|
||||||
quantity: '2',
|
pricingLibraryItemFormSchema.safeParse({
|
||||||
unitPrice: '150',
|
title: 'Coil cleaning',
|
||||||
totalPrice: '300',
|
serviceCategory: 'HVAC',
|
||||||
};
|
unit: '',
|
||||||
|
unitPrice: '12abc',
|
||||||
it('accepts numeric strings and an empty unitPrice', () => {
|
keywords: '',
|
||||||
expect(lineItemRowFormSchema.safeParse(valid).success).toBe(true);
|
description: '',
|
||||||
expect(lineItemRowFormSchema.safeParse({ ...valid, unitPrice: '' }).success).toBe(true);
|
}).success
|
||||||
expect(lineItemRowFormSchema.safeParse({ ...valid, quantity: '' }).success).toBe(false);
|
).toBe(false);
|
||||||
expect(lineItemRowFormSchema.safeParse({ ...valid, totalPrice: '-1' }).success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps to the wire entry with editor-managed identity', () => {
|
|
||||||
const entry = toUpdateLineItemEntry(valid, { id: null, sortOrder: 3, source: 'Manual' });
|
|
||||||
expect(entry).toEqual({
|
|
||||||
id: null,
|
|
||||||
description: 'Install thermostat',
|
|
||||||
quantity: 2,
|
|
||||||
unit: 'each',
|
|
||||||
unitPrice: 150,
|
|
||||||
totalPrice: 300,
|
|
||||||
pricingMode: 'UnitPrice',
|
|
||||||
sortOrder: 3,
|
|
||||||
source: 'Manual',
|
|
||||||
});
|
|
||||||
const noUnitPrice = toUpdateLineItemEntry(
|
|
||||||
{ ...valid, unitPrice: '' },
|
|
||||||
{ id: 'li-1', sortOrder: 0, source: 'AI' }
|
|
||||||
);
|
|
||||||
expect(noUnitPrice.unitPrice).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,35 @@
|
||||||
// the same toasts the AdminWorkspace page shows today. Page-specific side
|
// the same toasts the AdminWorkspace page shows today. Page-specific side
|
||||||
// effects (dialog close, dirty reset, window.open, navigate) stay at the
|
// effects (dialog close, dirty reset, window.open, navigate) stay at the
|
||||||
// call site via the mutation's callbacks.
|
// call site via the mutation's callbacks.
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { adminApi } from './api';
|
import { adminApi } from './api';
|
||||||
import { lineItemsApi } from '../lineItems/api';
|
import { lineItemsApi } from '../lineItems/api';
|
||||||
import { proposalsKeys } from '../proposals/use-cases';
|
import { proposalsKeys } from '../proposals/use-cases';
|
||||||
import { lineItemsKeys } from '../lineItems/use-cases';
|
import { lineItemsKeys } from '../lineItems/use-cases';
|
||||||
import type { UpdateProposalRequest } from './types';
|
|
||||||
import type { UpdateLineItemEntry } from '../lineItems/types';
|
import type { UpdateLineItemEntry } from '../lineItems/types';
|
||||||
|
|
||||||
export const adminKeys = {
|
export const adminKeys = {
|
||||||
all: ['admin'] as const,
|
all: ['admin'] as const,
|
||||||
dashboard: () => [...adminKeys.all, 'dashboard'] as const,
|
dashboard: () => [...adminKeys.all, 'dashboard'] as const,
|
||||||
similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const,
|
similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const,
|
||||||
history: (proposalId: string) => [...adminKeys.all, 'history', proposalId] as const,
|
|
||||||
audit: (proposalId: string) => [...adminKeys.all, 'audit', proposalId] as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every state transition must refresh every view of the proposal: the
|
||||||
|
* workspace (detail + line items) AND the cached queue/list/stats/KPI
|
||||||
|
* queries — lists sit under the global 5-minute staleTime, so without the
|
||||||
|
* broad invalidation an admin returning to the queue after approving sees
|
||||||
|
* stale statuses.
|
||||||
|
*/
|
||||||
|
function invalidateProposalViews(queryClient: QueryClient, proposalId: string) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: proposalsKeys.lists() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: proposalsKeys.stats() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminKeys.dashboard() });
|
||||||
|
}
|
||||||
|
|
||||||
/** Admin dashboard KPIs (AdminDashboard). */
|
/** Admin dashboard KPIs (AdminDashboard). */
|
||||||
export function useAdminDashboard() {
|
export function useAdminDashboard() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
@ -38,22 +50,6 @@ export function useSimilarProposals(proposalId: string | undefined) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProposalHistory(proposalId: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: adminKeys.history(proposalId ?? ''),
|
|
||||||
queryFn: () => adminApi.getHistory(proposalId!),
|
|
||||||
enabled: !!proposalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProposalAudit(proposalId: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: adminKeys.audit(proposalId ?? ''),
|
|
||||||
queryFn: () => adminApi.getAudit(proposalId!),
|
|
||||||
enabled: !!proposalId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveWorkspaceVariables {
|
export interface SaveWorkspaceVariables {
|
||||||
refinedScope: string;
|
refinedScope: string;
|
||||||
lineItems: UpdateLineItemEntry[];
|
lineItems: UpdateLineItemEntry[];
|
||||||
|
|
@ -71,8 +67,7 @@ export function useSaveProposalWorkspace(proposalId: string) {
|
||||||
await lineItemsApi.bulkUpdate(proposalId, lineItems);
|
await lineItemsApi.bulkUpdate(proposalId, lineItems);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
invalidateProposalViews(queryClient, proposalId);
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
|
||||||
toast.success('Changes saved');
|
toast.success('Changes saved');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
@ -81,26 +76,12 @@ export function useSaveProposalWorkspace(proposalId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateProposal(proposalId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: UpdateProposalRequest) => adminApi.updateProposal(proposalId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(`Save failed: ${error.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApproveProposal(proposalId: string) {
|
export function useApproveProposal(proposalId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => adminApi.approveProposal(proposalId),
|
mutationFn: () => adminApi.approveProposal(proposalId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
invalidateProposalViews(queryClient, proposalId);
|
||||||
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
|
||||||
toast.success('Proposal approved');
|
toast.success('Proposal approved');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
@ -114,9 +95,11 @@ export function useSendProposal(proposalId: string) {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => adminApi.sendProposal(proposalId),
|
mutationFn: () => adminApi.sendProposal(proposalId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
invalidateProposalViews(queryClient, proposalId);
|
||||||
toast.success('Proposal marked as sent');
|
toast.success('Proposal marked as sent');
|
||||||
},
|
},
|
||||||
|
// Fix: WEB-H5 — mutation must surface failures to the user (relocated
|
||||||
|
// from AdminWorkspace during the domain-layer refactor)
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(`Send failed: ${error.message}`);
|
toast.error(`Send failed: ${error.message}`);
|
||||||
},
|
},
|
||||||
|
|
@ -128,9 +111,11 @@ export function useReviseProposal(proposalId: string) {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => adminApi.reviseProposal(proposalId),
|
mutationFn: () => adminApi.reviseProposal(proposalId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
invalidateProposalViews(queryClient, proposalId);
|
||||||
toast.success('Revision created');
|
toast.success('Revision created');
|
||||||
},
|
},
|
||||||
|
// Fix: WEB-H6 — mutation must surface failures to the user (relocated
|
||||||
|
// from AdminWorkspace during the domain-layer refactor)
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(`Revision failed: ${error.message}`);
|
toast.error(`Revision failed: ${error.message}`);
|
||||||
},
|
},
|
||||||
|
|
@ -142,8 +127,7 @@ export function useReturnToReview(proposalId: string) {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => adminApi.returnToReview(proposalId),
|
mutationFn: () => adminApi.returnToReview(proposalId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
invalidateProposalViews(queryClient, proposalId);
|
||||||
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
|
||||||
toast.success('Proposal returned to review');
|
toast.success('Proposal returned to review');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,10 @@ export type CustomerFormValues = z.infer<typeof customerFormSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map validated form values to the create/update request. The form edits the
|
* Map validated form values to the create/update request. The form edits the
|
||||||
* primary address; any additional existing addresses are preserved
|
* primary address; any additional existing addresses are preserved.
|
||||||
* (mirrors the current CustomerManagementPage submit mapping).
|
* contactEmail is always sent: the API clears the stored email on empty
|
||||||
|
* string (CustomerService normalizes '' → null), so omitting the key when the
|
||||||
|
* admin blanks the field would make the email impossible to clear.
|
||||||
*/
|
*/
|
||||||
export function toCustomerRequest(
|
export function toCustomerRequest(
|
||||||
values: CustomerFormValues,
|
values: CustomerFormValues,
|
||||||
|
|
@ -43,6 +45,6 @@ export function toCustomerRequest(
|
||||||
return {
|
return {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
addresses: [values.address.trim(), ...existingAddresses.slice(1)],
|
addresses: [values.address.trim(), ...existingAddresses.slice(1)],
|
||||||
...(values.contactEmail.trim() ? { contactEmail: values.contactEmail.trim() } : {}),
|
contactEmail: values.contactEmail.trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Customers domain — TanStack Query hooks + query keys (the only public surface).
|
// Customers domain — TanStack Query hooks + query keys (the only public surface).
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { customersApi } from './api';
|
import { customersApi } from './api';
|
||||||
import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types';
|
import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types';
|
||||||
|
|
@ -21,11 +21,15 @@ export function useCustomerSearch(query: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paginated management list (CustomerManagementPage). */
|
/** Paginated management list (CustomerManagementPage). keepPreviousData
|
||||||
|
* keeps rows/totalCount rendered while a new page/search fetches (matches
|
||||||
|
* the old usePaginatedList behavior and keeps TablePagination's count from
|
||||||
|
* collapsing to 0 mid-fetch). */
|
||||||
export function useCustomerList(params: CustomerListParams = {}) {
|
export function useCustomerList(params: CustomerListParams = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: customersKeys.list(params),
|
queryKey: customersKeys.list(params),
|
||||||
queryFn: () => customersApi.list(params),
|
queryFn: () => customersApi.list(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +38,11 @@ export function useCreateCustomer() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreateCustomerRequest) => customersApi.create(data),
|
mutationFn: (data: CreateCustomerRequest) => customersApi.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: customersKeys.lists() });
|
// Invalidate the whole domain: lists AND cached search-autocomplete
|
||||||
|
// results (['customers','search',q]), which lists() would not cover —
|
||||||
|
// otherwise a just-created customer is invisible in New Proposal's
|
||||||
|
// autocomplete for the 5-minute staleTime.
|
||||||
|
queryClient.invalidateQueries({ queryKey: customersKeys.all });
|
||||||
toast.success('Customer created');
|
toast.success('Customer created');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
@ -49,7 +57,8 @@ export function useUpdateCustomer() {
|
||||||
mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) =>
|
mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) =>
|
||||||
customersApi.update(id, data),
|
customersApi.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: customersKeys.lists() });
|
// Whole-domain invalidation — see useCreateCustomer.
|
||||||
|
queryClient.invalidateQueries({ queryKey: customersKeys.all });
|
||||||
toast.success('Customer updated');
|
toast.success('Customer updated');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
// Line items domain — contract schema re-exports + line-item row form schema.
|
// Line items domain — contract schema re-exports.
|
||||||
import { z } from 'zod';
|
// A row-level form schema will arrive with the LineItemEditor migration to
|
||||||
import { updateLineItemEntrySchema } from '@proposal-system/api-contracts/schemas';
|
// react-hook-form; until a component consumes it, none is defined here.
|
||||||
import type { UpdateLineItemEntry } from './types';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
pricingModeSchema,
|
pricingModeSchema,
|
||||||
lineItemSourceSchema,
|
lineItemSourceSchema,
|
||||||
|
|
@ -11,51 +9,3 @@ export {
|
||||||
updateLineItemEntrySchema,
|
updateLineItemEntrySchema,
|
||||||
bulkUpdateLineItemsRequestSchema,
|
bulkUpdateLineItemsRequestSchema,
|
||||||
} from '@proposal-system/api-contracts/schemas';
|
} from '@proposal-system/api-contracts/schemas';
|
||||||
|
|
||||||
/** Required numeric text input (>= 0). */
|
|
||||||
const numericString = z
|
|
||||||
.string()
|
|
||||||
.refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)) && Number(v) >= 0, {
|
|
||||||
message: 'Must be a number >= 0',
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Optional numeric text input — empty maps to null on the wire. */
|
|
||||||
const optionalNumericString = z
|
|
||||||
.string()
|
|
||||||
.refine((v) => v.trim() === '' || (!Number.isNaN(Number(v)) && Number(v) >= 0), {
|
|
||||||
message: 'Must be a number >= 0',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single editable line-item row. Derived from updateLineItemEntrySchema —
|
|
||||||
* numeric inputs are strings in form state; toUpdateLineItemEntry() converts
|
|
||||||
* to the wire type. Row identity (id/sortOrder/source) is editor-managed
|
|
||||||
* state, not user input, so it is supplied to the mapper instead.
|
|
||||||
*/
|
|
||||||
export const lineItemRowFormSchema = updateLineItemEntrySchema
|
|
||||||
.pick({ description: true, unit: true, pricingMode: true })
|
|
||||||
.extend({
|
|
||||||
quantity: numericString,
|
|
||||||
unitPrice: optionalNumericString,
|
|
||||||
totalPrice: numericString,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LineItemRowFormValues = z.infer<typeof lineItemRowFormSchema>;
|
|
||||||
|
|
||||||
/** Map a validated row to the bulk-update wire entry. */
|
|
||||||
export function toUpdateLineItemEntry(
|
|
||||||
values: LineItemRowFormValues,
|
|
||||||
meta: { id: string | null; sortOrder: number; source: UpdateLineItemEntry['source'] }
|
|
||||||
): UpdateLineItemEntry {
|
|
||||||
return {
|
|
||||||
id: meta.id,
|
|
||||||
description: values.description,
|
|
||||||
quantity: Number(values.quantity),
|
|
||||||
unit: values.unit,
|
|
||||||
unitPrice: values.unitPrice.trim() !== '' ? Number(values.unitPrice) : null,
|
|
||||||
totalPrice: Number(values.totalPrice),
|
|
||||||
pricingMode: values.pricingMode,
|
|
||||||
sortOrder: meta.sortOrder,
|
|
||||||
source: meta.source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
// Line items domain — TanStack Query hooks + query keys (the only public surface).
|
// Line items domain — TanStack Query hooks + query keys (the only public surface).
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
// Mutations on line items flow through useSaveProposalWorkspace (admin domain);
|
||||||
import { toast } from 'react-toastify';
|
// add dedicated mutation hooks here when a page actually consumes them.
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { lineItemsApi } from './api';
|
import { lineItemsApi } from './api';
|
||||||
import { proposalsKeys } from '../proposals/use-cases';
|
|
||||||
import type { CreateLineItemRequest, UpdateLineItemEntry } from './types';
|
|
||||||
|
|
||||||
export const lineItemsKeys = {
|
export const lineItemsKeys = {
|
||||||
all: ['lineItems'] as const,
|
all: ['lineItems'] as const,
|
||||||
|
|
@ -18,47 +17,3 @@ export function useLineItems(proposalId: string | undefined) {
|
||||||
enabled: !!proposalId,
|
enabled: !!proposalId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateLineItem(proposalId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateLineItemRequest) => lineItemsApi.create(proposalId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(`Failed to add line item: ${error.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace-all update of a proposal's line items. Totals change, so the
|
|
||||||
* proposal detail is invalidated alongside the line items. */
|
|
||||||
export function useBulkUpdateLineItems(proposalId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (lineItems: UpdateLineItemEntry[]) => lineItemsApi.bulkUpdate(proposalId, lineItems),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(`Save failed: ${error.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteLineItem(proposalId: string) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (itemId: string) => lineItemsApi.delete(proposalId, itemId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(`Failed to delete line item: ${error.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Pricing library domain — contract schema re-exports + item form schema.
|
// Pricing library domain — contract schema re-exports + item form schema.
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas';
|
import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas';
|
||||||
|
import { optionalNumericString, toNumberOrNull } from '../shared/formFields';
|
||||||
import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types';
|
import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
@ -20,11 +21,9 @@ export const pricingLibraryItemFormSchema = createPricingLibraryItemRequestSchem
|
||||||
.extend({
|
.extend({
|
||||||
title: z.string().trim().min(1, 'Title is required'),
|
title: z.string().trim().min(1, 'Title is required'),
|
||||||
unit: z.string(),
|
unit: z.string(),
|
||||||
unitPrice: z
|
// Shared Number-based field — rejects trailing garbage ('12abc') that
|
||||||
.string()
|
// the old parseFloat refine silently coerced to 12.
|
||||||
.refine((v) => v.trim() === '' || (!Number.isNaN(parseFloat(v)) && parseFloat(v) >= 0), {
|
unitPrice: optionalNumericString,
|
||||||
message: 'Must be a number >= 0',
|
|
||||||
}),
|
|
||||||
keywords: z.string(),
|
keywords: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
});
|
});
|
||||||
|
|
@ -43,7 +42,7 @@ export function toPricingLibraryItemRequest(
|
||||||
title: values.title.trim(),
|
title: values.title.trim(),
|
||||||
serviceCategory: values.serviceCategory,
|
serviceCategory: values.serviceCategory,
|
||||||
...(values.unit.trim() ? { unit: values.unit.trim() } : {}),
|
...(values.unit.trim() ? { unit: values.unit.trim() } : {}),
|
||||||
unitPrice: values.unitPrice.trim() !== '' ? parseFloat(values.unitPrice) : null,
|
unitPrice: toNumberOrNull(values.unitPrice),
|
||||||
...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}),
|
...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}),
|
||||||
...(values.description.trim() ? { description: values.description.trim() } : {}),
|
...(values.description.trim() ? { description: values.description.trim() } : {}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Pricing library domain — TanStack Query hooks + query keys (the only public surface).
|
// Pricing library domain — TanStack Query hooks + query keys (the only public surface).
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { pricingLibraryApi } from './api';
|
import { pricingLibraryApi } from './api';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -14,11 +14,14 @@ export const pricingLibraryKeys = {
|
||||||
list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const,
|
list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Paginated pricing library list (PricingLibraryPage). */
|
/** Paginated pricing library list (PricingLibraryPage). keepPreviousData
|
||||||
|
* keeps rows/totalCount rendered while a new page fetches (prevents
|
||||||
|
* TablePagination's count collapsing to 0 mid-fetch). */
|
||||||
export function usePricingLibraryList(params: PricingLibraryListParams = {}) {
|
export function usePricingLibraryList(params: PricingLibraryListParams = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: pricingLibraryKeys.list(params),
|
queryKey: pricingLibraryKeys.list(params),
|
||||||
queryFn: () => pricingLibraryApi.list(params),
|
queryFn: () => pricingLibraryApi.list(params),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,4 +88,21 @@ export const proposalsApi = {
|
||||||
const downloadUrl = URL.createObjectURL(blob);
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
return { downloadUrl };
|
return { downloadUrl };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT a file to a presigned S3 URL. Plain fetch by design — the presigned
|
||||||
|
* URL must NOT carry our auth headers (the axios client would attach them).
|
||||||
|
* fetch resolves on HTTP errors, so the ok check is what surfaces S3
|
||||||
|
* rejections (expired signature, size/content-type mismatch) as failures.
|
||||||
|
*/
|
||||||
|
uploadToPresignedUrl: async (uploadUrl: string, file: File): Promise<void> => {
|
||||||
|
const res = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: file,
|
||||||
|
headers: { 'Content-Type': file.type || 'application/pdf' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Vendor PDF upload failed (${res.status})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,8 @@ export function useCreateProposal() {
|
||||||
proposal.id,
|
proposal.id,
|
||||||
vendorFile.name
|
vendorFile.name
|
||||||
);
|
);
|
||||||
await fetch(uploadUrl, {
|
// Throws on S3 rejection (non-2xx) so a failed upload is never confirmed.
|
||||||
method: 'PUT',
|
await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile);
|
||||||
body: vendorFile,
|
|
||||||
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
|
|
||||||
});
|
|
||||||
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
|
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
|
||||||
} catch {
|
} catch {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
|
|
|
||||||
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';
|
} from '../../../domain/customers/schemas';
|
||||||
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
|
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
|
|
||||||
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
|
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
|
||||||
|
|
||||||
|
|
@ -52,22 +53,13 @@ export default function CustomerManagementPage() {
|
||||||
|
|
||||||
// --- List state ---
|
// --- List state ---
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
const [page, setPage] = useState(DEFAULT_PAGE);
|
const [page, setPage] = useState(DEFAULT_PAGE);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
// Debounce search input
|
// Debounced search resets to page 1 (shared hook — the previous inline
|
||||||
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
// timer leaked on unmount).
|
||||||
|
const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => setSearch(value);
|
||||||
setSearch(value);
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setDebouncedSearch(value);
|
|
||||||
setPage(1);
|
|
||||||
}, DEBOUNCE_SEARCH);
|
|
||||||
setDebounceTimer(timer);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useCustomerList({
|
const { data, isLoading, isError, refetch } = useCustomerList({
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState, Fragment } from 'react';
|
import { useMemo, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -42,6 +42,7 @@ import { useAdminDashboard } from '../../../domain/admin/use-cases';
|
||||||
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants';
|
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants';
|
||||||
import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format';
|
import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
|
|
||||||
const COL_COUNT = 11;
|
const COL_COUNT = 11;
|
||||||
const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
|
const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
|
||||||
|
|
@ -81,18 +82,11 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
const [page, setPage] = useState(DEFAULT_PAGE);
|
const [page, setPage] = useState(DEFAULT_PAGE);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
// Debounced search resets to page 1 (mirrors the old usePaginatedList behavior).
|
// Debounced search resets to page 1 (mirrors the old usePaginatedList behavior).
|
||||||
useEffect(() => {
|
const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
|
||||||
const handle = setTimeout(() => {
|
|
||||||
setDebouncedSearch(search);
|
|
||||||
setPage(1);
|
|
||||||
}, DEBOUNCE_SEARCH);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: listData,
|
data: listData,
|
||||||
|
|
@ -415,7 +409,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!loading && rows.length === 0 && (
|
{/* Empty state only when the fetch actually succeeded. */}
|
||||||
|
{!loading && !err && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -162,10 +162,13 @@ export default function AdminWorkspace() {
|
||||||
// with the previous approve mutation, which saved first when dirty).
|
// with the previous approve mutation, which saved first when dirty).
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) });
|
await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) });
|
||||||
|
// The edits are persisted regardless of whether the approve below
|
||||||
|
// succeeds — clear dirty NOW so a failed approve doesn't leave a
|
||||||
|
// false unsaved-changes prompt over already-saved work.
|
||||||
|
setDirty(false);
|
||||||
}
|
}
|
||||||
await approveMutation.mutateAsync();
|
await approveMutation.mutateAsync();
|
||||||
setApproveDialogOpen(false);
|
setApproveDialogOpen(false);
|
||||||
setDirty(false);
|
|
||||||
} catch {
|
} catch {
|
||||||
// error toasts are handled by the domain hooks
|
// error toasts are handled by the domain hooks
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
|
@ -25,7 +25,8 @@ import {
|
||||||
} from '../../../domain/proposals/schemas';
|
} from '../../../domain/proposals/schemas';
|
||||||
import { useSiteSearch } from '../../../domain/sites/use-cases';
|
import { useSiteSearch } from '../../../domain/sites/use-cases';
|
||||||
import { useCustomerSearch } from '../../../domain/customers/use-cases';
|
import { useCustomerSearch } from '../../../domain/customers/use-cases';
|
||||||
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
|
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, DEBOUNCE_AUTOCOMPLETE } from '../../../constants';
|
||||||
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
|
|
||||||
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
||||||
const ALLOWED_MIME_TYPES = ['application/pdf'];
|
const ALLOWED_MIME_TYPES = ['application/pdf'];
|
||||||
|
|
@ -54,7 +55,7 @@ export default function ProposalFormPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
||||||
const [siteQuery, setSiteQuery] = useState('');
|
const [siteSearchInput, setSiteSearchInput] = useState('');
|
||||||
const [siteInputValue, setSiteInputValue] = useState('');
|
const [siteInputValue, setSiteInputValue] = useState('');
|
||||||
const [manualEntry, setManualEntry] = useState(false);
|
const [manualEntry, setManualEntry] = useState(false);
|
||||||
const [manualSiteCode, setManualSiteCode] = useState('');
|
const [manualSiteCode, setManualSiteCode] = useState('');
|
||||||
|
|
@ -62,12 +63,15 @@ export default function ProposalFormPage() {
|
||||||
const [manualCity, setManualCity] = useState('');
|
const [manualCity, setManualCity] = useState('');
|
||||||
const [manualState, setManualState] = useState('');
|
const [manualState, setManualState] = useState('');
|
||||||
const [manualZip, setManualZip] = useState('');
|
const [manualZip, setManualZip] = useState('');
|
||||||
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// PR4: customer search state — freeSolo Autocomplete backed by the customers domain
|
// PR4: customer search state — freeSolo Autocomplete backed by the customers domain
|
||||||
const [customerQuery, setCustomerQuery] = useState('');
|
const [customerSearchInput, setCustomerSearchInput] = useState('');
|
||||||
const [customerInputValue, setCustomerInputValue] = useState('');
|
const [customerInputValue, setCustomerInputValue] = useState('');
|
||||||
const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
// Shared debounce hook (search input is only updated on typing, so
|
||||||
|
// selecting an option never fires a search request).
|
||||||
|
const siteQuery = useDebouncedValue(siteSearchInput, DEBOUNCE_AUTOCOMPLETE);
|
||||||
|
const customerQuery = useDebouncedValue(customerSearchInput, DEBOUNCE_AUTOCOMPLETE);
|
||||||
|
|
||||||
// Domain search hooks (disabled below 2 characters, matching prior behavior).
|
// Domain search hooks (disabled below 2 characters, matching prior behavior).
|
||||||
const siteSearch = useSiteSearch(siteQuery);
|
const siteSearch = useSiteSearch(siteQuery);
|
||||||
|
|
@ -78,13 +82,6 @@ export default function ProposalFormPage() {
|
||||||
const customerOptions = (customerSearch.data ?? []).map((c) => c.name);
|
const customerOptions = (customerSearch.data ?? []).map((c) => c.name);
|
||||||
const customerLoading = customerSearch.isFetching;
|
const customerLoading = customerSearch.isFetching;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
|
||||||
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manualEntry) return;
|
if (!manualEntry) return;
|
||||||
const addressParts = [
|
const addressParts = [
|
||||||
|
|
@ -124,25 +121,28 @@ export default function ProposalFormPage() {
|
||||||
const scopeTouched = scopeOfWork.length > 0;
|
const scopeTouched = scopeOfWork.length > 0;
|
||||||
const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
|
const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
|
||||||
|
|
||||||
const isValid =
|
// SINGLE source for the submit gate and the missing-fields caption —
|
||||||
workOrderNumber.trim() &&
|
// mirrors createProposalFormSchema (zodResolver re-enforces at submit).
|
||||||
customerName.trim() &&
|
// Deriving both from one list keeps the button and the caption from
|
||||||
customerAddress.trim() &&
|
// drifting (the previous split logic disabled Submit for a missing
|
||||||
scopeOfWork.trim().length >= MIN_SCOPE_LENGTH &&
|
// customer while the caption never mentioned it).
|
||||||
serviceCategory &&
|
|
||||||
(serviceCategory !== 'Other' || customCategory.trim());
|
|
||||||
|
|
||||||
const missingFields = useMemo(() => {
|
const missingFields = useMemo(() => {
|
||||||
const missing: string[] = [];
|
const checks: Array<{ label: string; ok: boolean }> = [
|
||||||
if (!workOrderNumber.trim()) missing.push('Work Order Number');
|
{ label: 'Work Order Number', ok: !!workOrderNumber.trim() },
|
||||||
if (!customerAddress.trim()) missing.push('Site address');
|
{ label: 'Customer', ok: !!customerName.trim() },
|
||||||
if (!serviceCategory) missing.push('Service Category');
|
{ label: 'Site address', ok: !!customerAddress.trim() },
|
||||||
if (serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
|
{ label: 'Service Category', ok: !!serviceCategory },
|
||||||
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
|
{ label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() },
|
||||||
if (!scopeOfWork.trim()) missing.push('Scope of Work');
|
{
|
||||||
else if (scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`);
|
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
|
||||||
return missing;
|
label: !scopeOfWork.trim() ? 'Scope of Work' : `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`,
|
||||||
}, [workOrderNumber, customerAddress, serviceCategory, scopeOfWork, customCategory]);
|
ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return checks.filter((c) => !c.ok).map((c) => c.label);
|
||||||
|
}, [workOrderNumber, customerName, customerAddress, serviceCategory, scopeOfWork, customCategory]);
|
||||||
|
|
||||||
|
const isValid = missingFields.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||||
|
|
@ -183,12 +183,11 @@ export default function ProposalFormPage() {
|
||||||
if (reason === 'input') {
|
if (reason === 'input') {
|
||||||
setCustomerInputValue(value);
|
setCustomerInputValue(value);
|
||||||
setValue('customerName', value);
|
setValue('customerName', value);
|
||||||
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
|
setCustomerSearchInput(value);
|
||||||
customerDebounceRef.current = setTimeout(() => setCustomerQuery(value), 300);
|
|
||||||
} else if (reason === 'clear') {
|
} else if (reason === 'clear') {
|
||||||
setCustomerInputValue('');
|
setCustomerInputValue('');
|
||||||
setValue('customerName', '');
|
setValue('customerName', '');
|
||||||
setCustomerQuery('');
|
setCustomerSearchInput('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
|
|
@ -337,8 +336,7 @@ export default function ProposalFormPage() {
|
||||||
onInputChange={(_, value, reason) => {
|
onInputChange={(_, value, reason) => {
|
||||||
if (reason === 'input') {
|
if (reason === 'input') {
|
||||||
setSiteInputValue(value);
|
setSiteInputValue(value);
|
||||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
setSiteSearchInput(value);
|
||||||
siteDebounceRef.current = setTimeout(() => setSiteQuery(value), 300);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||||
import { useProposals } from '../../../domain/proposals/use-cases';
|
import { useProposals } from '../../../domain/proposals/use-cases';
|
||||||
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
|
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
|
||||||
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
|
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
|
@ -30,22 +31,11 @@ export default function ProposalListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
const [page, setPage] = useState(DEFAULT_PAGE);
|
const [page, setPage] = useState(DEFAULT_PAGE);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Debounced search resets to page 1 (mirrors usePaginatedList).
|
// Debounced search resets to page 1 (mirrors usePaginatedList).
|
||||||
useEffect(() => {
|
const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
setDebouncedSearch(search);
|
|
||||||
setPage(1);
|
|
||||||
}, DEBOUNCE_SEARCH);
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
const { data, isLoading, isPlaceholderData, error } = useProposals({
|
const { data, isLoading, isPlaceholderData, error } = useProposals({
|
||||||
search: debouncedSearch,
|
search: debouncedSearch,
|
||||||
|
|
@ -153,7 +143,9 @@ export default function ProposalListPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!loading && rows.length === 0 && (
|
{/* Empty state only when the fetch actually succeeded — on error
|
||||||
|
the banner above is the whole story. */}
|
||||||
|
{!loading && !err && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||||
<Typography color="text.secondary">
|
<Typography color="text.secondary">
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@proposal-system/api-contracts": ["../shared/api-contracts/src"],
|
"@proposal-system/api-contracts": ["../shared/api-contracts/src"],
|
||||||
"@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"]
|
"@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "../shared/api-contracts/src"]
|
"include": ["src", "../shared/api-contracts/src"]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
|
@ -7,16 +6,9 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
// @proposal-system/api-contracts is a file: symlink; preserve it so its
|
// @proposal-system/api-contracts is a file: symlink; preserve it so its
|
||||||
// 'zod' import resolves from web/node_modules in CI (shared has no
|
// 'zod' import resolves from web/node_modules in CI (shared has no
|
||||||
// installed node_modules there).
|
// installed node_modules there). Subpath entrypoints (./schemas) resolve
|
||||||
|
// via the package's `exports` map.
|
||||||
preserveSymlinks: true,
|
preserveSymlinks: true,
|
||||||
alias: {
|
|
||||||
// The package has no `exports` map (main points at src/index.ts), so
|
|
||||||
// the /schemas subpath entrypoint needs an explicit alias (mirrors the
|
|
||||||
// tsconfig `paths` mapping; vitest.config.ts carries the same alias).
|
|
||||||
'@proposal-system/api-contracts/schemas': fileURLToPath(
|
|
||||||
new URL('../shared/api-contracts/src/schemas.ts', import.meta.url)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,13 @@
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
// @proposal-system/api-contracts is a file: symlink; subpath entrypoints
|
||||||
// @proposal-system/api-contracts has no `exports` map (main points at
|
// (./schemas) resolve via the package's `exports` map. preserveSymlinks
|
||||||
// src/index.ts), so the /schemas subpath entrypoint needs an explicit
|
// keeps its 'zod' import resolving from web/node_modules in CI.
|
||||||
// alias for vitest's resolver (mirrors the tsconfig `paths` mapping).
|
preserveSymlinks: true,
|
||||||
'@proposal-system/api-contracts/schemas': fileURLToPath(
|
|
||||||
new URL('../shared/api-contracts/src/schemas.ts', import.meta.url)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue