feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites)

Additive-only: pages still use lib/api/* and constants/queryKeys.ts until
the page-migration agents run. Each domain ships api.ts (HTTP moved from
lib/api), types.ts (contract re-exports + view types), schemas.ts (contract
schema re-exports + form schemas with toRequest mappers), and use-cases.ts
(TanStack Query v5 hooks + hierarchical query keys, mirroring current page
invalidations and toast-on-error behavior).

Adds an explicit vite/vitest alias for the
@proposal-system/api-contracts/schemas subpath (package has no exports map)
plus a schema/mapper smoke test suite.
This commit is contained in:
Adam Moussa 2026-07-13 17:58:19 -04:00
parent 924bfdd8f6
commit e6e2c60c2f
No known key found for this signature in database
27 changed files with 1246 additions and 0 deletions

View file

@ -0,0 +1,157 @@
// Domain-layer form schemas: derived from the shared contract schemas, with
// string-typed numeric inputs and toXxxRequest() mappers to the wire types.
// Also serves as the resolution smoke test for the
// '@proposal-system/api-contracts/schemas' subpath entrypoint.
import { describe, it, expect } from 'vitest';
import {
createProposalFormSchema,
toCreateProposalRequest,
MIN_SCOPE_LENGTH,
} from '../proposals/schemas';
import { customerFormSchema, toCustomerRequest } from '../customers/schemas';
import {
pricingLibraryItemFormSchema,
toPricingLibraryItemRequest,
} from '../pricingLibrary/schemas';
import { lineItemRowFormSchema, toUpdateLineItemEntry } from '../lineItems/schemas';
describe('createProposalFormSchema', () => {
const valid = {
workOrderNumber: 'WO-1001',
poNumber: '',
customerName: 'Acme Corp',
customerAddress: '1 Main St, Islip, NY 11751',
scopeOfWork: 'Replace rooftop HVAC unit and rebalance airflow',
serviceCategory: 'HVAC' as const,
priority: 'Standard' as const,
notes: '',
customCategory: '',
};
it('accepts a complete form', () => {
expect(createProposalFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects scope of work shorter than the minimum', () => {
const result = createProposalFormSchema.safeParse({
...valid,
scopeOfWork: 'x'.repeat(MIN_SCOPE_LENGTH - 1),
});
expect(result.success).toBe(false);
});
it('requires customCategory when serviceCategory is Other', () => {
expect(
createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success
).toBe(false);
expect(
createProposalFormSchema.safeParse({
...valid,
serviceCategory: 'Other',
customCategory: 'Roofing',
}).success
).toBe(true);
});
it('maps to the wire request, prefixing notes with the custom category', () => {
const request = toCreateProposalRequest({
...valid,
serviceCategory: 'Other',
customCategory: 'Roofing',
notes: 'existing note',
});
expect(request.notes).toBe('[Category: Roofing]\nexisting note');
expect(request.poNumber).toBeUndefined();
expect(toCreateProposalRequest(valid).notes).toBeUndefined();
});
});
describe('customerFormSchema', () => {
const valid = { name: 'Acme Corp', address: '1 Main St', contactEmail: '' };
it('accepts a form with empty email', () => {
expect(customerFormSchema.safeParse(valid).success).toBe(true);
});
it('rejects a malformed email but accepts a valid one', () => {
expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'nope' }).success).toBe(false);
expect(
customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success
).toBe(true);
});
it('maps to addresses[] preserving extra existing addresses', () => {
const request = toCustomerRequest(
{ 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'] });
});
});
describe('pricingLibraryItemFormSchema', () => {
const valid = {
title: 'Condenser coil cleaning',
serviceCategory: 'HVAC' as const,
unit: '',
unitPrice: '',
keywords: '',
description: '',
};
it('accepts empty unitPrice and rejects negatives/non-numbers', () => {
expect(pricingLibraryItemFormSchema.safeParse(valid).success).toBe(true);
expect(
pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success
).toBe(false);
expect(
pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success
).toBe(false);
});
it('maps empty unitPrice to null and drops empty optional strings', () => {
const request = toPricingLibraryItemRequest(valid);
expect(request).toEqual({ title: 'Condenser coil cleaning', serviceCategory: 'HVAC', unitPrice: null });
const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' });
expect(priced.unitPrice).toBe(12.5);
expect(priced.unit).toBe('each');
});
});
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();
});
});

View file

@ -0,0 +1,62 @@
// Admin domain — HTTP calls only (no hooks). Moved from lib/api/admin.ts.
import apiClient from '../../lib/api/client';
import type { ProposalDetail } from '../proposals/types';
import type { DashboardStats, UpdateProposalRequest, AuditEntry, SimilarProposal } from './types';
export const adminApi = {
getDashboard: async (): Promise<DashboardStats> => {
const res = await apiClient.get('/admin/dashboard');
return res.data;
},
updateProposal: async (id: string, data: UpdateProposalRequest): Promise<void> => {
await apiClient.put(`/proposals/${id}`, data);
},
approveProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/approve`);
},
returnToReview: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/return-to-review`);
},
sendProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/send`);
},
reviseProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/revise`);
},
getHistory: async (id: string): Promise<ProposalDetail[]> => {
const res = await apiClient.get(`/proposals/${id}/history`);
return res.data;
},
getAudit: async (id: string): Promise<AuditEntry[]> => {
const res = await apiClient.get(`/proposals/${id}/audit`);
return res.data;
},
generateSuggestions: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/generate-suggestions`);
},
getSimilar: async (id: string): Promise<SimilarProposal[]> => {
const res = await apiClient.get(`/proposals/${id}/similar`);
return res.data;
},
getPdf: async (id: string, regenerate = false): Promise<{ downloadUrl: string } | null> => {
const params = regenerate ? '?regenerate=true' : '';
const res = await apiClient.get(`/proposals/${id}/pdf${params}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 202) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
};

View file

@ -0,0 +1,7 @@
// Admin domain — contract schema re-exports. No admin-specific forms yet
// (the workspace edits proposals/line items via their own domain schemas).
export {
dashboardStatsSchema,
updateProposalRequestSchema,
auditEntrySchema,
} from '@proposal-system/api-contracts/schemas';

View file

@ -0,0 +1,19 @@
// Admin domain — contract type re-exports + view-only types.
export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts';
/** View-only shape of GET /proposals/{id}/similar (RAG similarity results —
* no contract DTO yet; previously declared inline in SimilarProposalsPanel). */
export interface SimilarProposal {
proposalNumber: string;
customerName: string;
serviceCategory: string;
similarityScore: number;
totalBidAmount: number;
lineItems: Array<{
description: string;
quantity: number;
unit: string;
unitPrice: number | null;
totalPrice: number;
}>;
}

View file

@ -0,0 +1,179 @@
// Admin domain — TanStack Query hooks + query keys (the only public surface).
// State-transition mutations invalidate the proposals/lineItems domain keys
// (rule 3 — cross-domain invalidation via the sibling key objects) and carry
// the same toasts the AdminWorkspace page shows today. Page-specific side
// effects (dialog close, dirty reset, window.open, navigate) stay at the
// call site via the mutation's callbacks.
import { useMutation, useQuery, useQueryClient } 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,
};
/** Admin dashboard KPIs (AdminDashboard). */
export function useAdminDashboard() {
return useQuery({
queryKey: adminKeys.dashboard(),
queryFn: adminApi.getDashboard,
});
}
/** RAG similarity results (AdminWorkspace, SimilarProposalsPanel). */
export function useSimilarProposals(proposalId: string | undefined) {
return useQuery({
queryKey: adminKeys.similar(proposalId ?? ''),
queryFn: () => adminApi.getSimilar(proposalId!),
enabled: !!proposalId,
});
}
export 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[];
}
/**
* The workspace "Save" action: persist the refined scope, then replace the
* proposal's line items (mirrors AdminWorkspace saveMutation exactly).
*/
export function useSaveProposalWorkspace(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => {
await adminApi.updateProposal(proposalId, { refinedScope });
await lineItemsApi.bulkUpdate(proposalId, lineItems);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
toast.success('Changes saved');
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
}
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) });
toast.success('Proposal approved');
},
onError: (error: Error) => {
toast.error(`Approval failed: ${error.message}`);
},
});
}
export function useSendProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.sendProposal(proposalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
toast.success('Proposal marked as sent');
},
onError: (error: Error) => {
toast.error(`Send failed: ${error.message}`);
},
});
}
export function useReviseProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.reviseProposal(proposalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
toast.success('Revision created');
},
onError: (error: Error) => {
toast.error(`Revision failed: ${error.message}`);
},
});
}
export function useReturnToReview(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.returnToReview(proposalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
toast.success('Proposal returned to review');
},
onError: (error: Error) => {
toast.error(`Return to review failed: ${error.message}`);
},
});
}
export function useGenerateSuggestions(proposalId: string) {
return useMutation({
mutationFn: () => adminApi.generateSuggestions(proposalId),
onSuccess: () => {
toast.info('AI suggestion generation started');
},
onError: (error: Error) => {
toast.error(`Regeneration failed: ${error.message}`);
},
});
}
/**
* Fetch (or queue generation of) the branded PDF. Resolves null while
* generation is queued (202). Success handling — open the download and
* toast — is page behavior and belongs at the call site.
*/
export function useProposalPdf(proposalId: string) {
return useMutation({
mutationFn: (regenerate?: boolean) => adminApi.getPdf(proposalId, regenerate ?? false),
onError: () => {
toast.error('Failed to generate PDF');
},
});
}

View file

@ -0,0 +1,40 @@
// Customers domain — HTTP calls only (no hooks). Moved from lib/api/customers.ts.
import apiClient from '../../lib/api/client';
import type {
Customer,
CustomerListParams,
CreateCustomerRequest,
UpdateCustomerRequest,
PagedCustomerResponse,
} from './types';
export const customersApi = {
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */
search: async (query?: string): Promise<Customer[]> => {
const params = query ? `?query=${encodeURIComponent(query)}` : '';
const res = await apiClient.get(`/customers${params}`);
return res.data;
},
/** GET /api/customers/list?page=&pageSize= — paginated management list (admin/sysadmin). */
list: async (params: CustomerListParams = {}): Promise<PagedCustomerResponse> => {
const qs = new URLSearchParams();
if (params.page) qs.append('page', String(params.page));
if (params.pageSize) qs.append('pageSize', String(params.pageSize));
if (params.search) qs.append('search', params.search);
const res = await apiClient.get(`/customers/list?${qs.toString()}`);
return res.data;
},
/** POST /api/customers — create a new customer. */
create: async (data: CreateCustomerRequest): Promise<Customer> => {
const res = await apiClient.post('/customers', data);
return res.data;
},
/** PUT /api/customers/{id} — update an existing customer (admin/sysadmin). */
update: async (id: string, data: UpdateCustomerRequest): Promise<Customer> => {
const res = await apiClient.put(`/customers/${id}`, data);
return res.data;
},
};

View file

@ -0,0 +1,48 @@
// Customers domain — contract schema re-exports + customer form schema.
import { z } from 'zod';
import { createCustomerRequestSchema } from '@proposal-system/api-contracts/schemas';
import type { CreateCustomerRequest, UpdateCustomerRequest } from './types';
export {
customerSchema,
createCustomerRequestSchema,
updateCustomerRequestSchema,
} from '@proposal-system/api-contracts/schemas';
/** Simple email format check (client-side only) — mirrors CustomerManagementPage. */
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Customer create/edit form. Derived from createCustomerRequestSchema — the
* form edits a single primary address string (the wire type takes
* addresses: string[]); toCustomerRequest() maps to the request shape.
*/
export const customerFormSchema = createCustomerRequestSchema
.pick({ name: true })
.extend({
name: z.string().trim().min(1, 'Name is required'),
address: z.string().trim().min(1, 'Address is required'),
contactEmail: z
.string()
.refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), {
message: 'Enter a valid email address',
}),
});
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).
*/
export function toCustomerRequest(
values: CustomerFormValues,
existingAddresses: string[] = []
): CreateCustomerRequest & UpdateCustomerRequest {
return {
name: values.name.trim(),
addresses: [values.address.trim(), ...existingAddresses.slice(1)],
...(values.contactEmail.trim() ? { contactEmail: values.contactEmail.trim() } : {}),
};
}

View file

@ -0,0 +1,11 @@
// Customers domain — contract type re-exports + view-only types.
import type { Customer, PagedResponse } from '@proposal-system/api-contracts';
export type {
Customer,
CustomerListParams,
CreateCustomerRequest,
UpdateCustomerRequest,
} from '@proposal-system/api-contracts';
export type PagedCustomerResponse = PagedResponse<Customer>;

View file

@ -0,0 +1,59 @@
// Customers domain — TanStack Query hooks + query keys (the only public surface).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { customersApi } from './api';
import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types';
export const customersKeys = {
all: ['customers'] as const,
lists: () => [...customersKeys.all, 'list'] as const,
list: (params: CustomerListParams) => [...customersKeys.lists(), params] as const,
search: (query: string) => [...customersKeys.all, 'search', query] as const,
};
/** Name autocomplete (ProposalFormPage). Call with the debounced input;
* disabled below 2 characters, matching current page behavior. */
export function useCustomerSearch(query: string) {
return useQuery({
queryKey: customersKeys.search(query),
queryFn: () => customersApi.search(query),
enabled: query.trim().length >= 2,
});
}
/** Paginated management list (CustomerManagementPage). */
export function useCustomerList(params: CustomerListParams = {}) {
return useQuery({
queryKey: customersKeys.list(params),
queryFn: () => customersApi.list(params),
});
}
export function useCreateCustomer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCustomerRequest) => customersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: customersKeys.lists() });
toast.success('Customer created');
},
onError: (error: Error) => {
toast.error(`Failed to create customer: ${error.message}`);
},
});
}
export function useUpdateCustomer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) =>
customersApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: customersKeys.lists() });
toast.success('Customer updated');
},
onError: (error: Error) => {
toast.error(`Failed to update customer: ${error.message}`);
},
});
}

View file

@ -0,0 +1,24 @@
// Line items domain — HTTP calls only (no hooks). Moved from lib/api/lineItems.ts.
import apiClient from '../../lib/api/client';
import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from './types';
export const lineItemsApi = {
getAll: async (proposalId: string): Promise<LineItem[]> => {
const res = await apiClient.get(`/proposals/${proposalId}/line-items`);
return res.data;
},
create: async (proposalId: string, data: CreateLineItemRequest): Promise<LineItem> => {
const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data);
return res.data;
},
bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise<LineItem[]> => {
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems });
return res.data;
},
delete: async (proposalId: string, itemId: string): Promise<void> => {
await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`);
},
};

View file

@ -0,0 +1,61 @@
// 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';
export {
pricingModeSchema,
lineItemSourceSchema,
lineItemSchema,
createLineItemRequestSchema,
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,
};
}

View file

@ -0,0 +1,9 @@
// Line items domain — contract type re-exports.
export type {
PricingMode,
LineItemSource,
LineItem,
CreateLineItemRequest,
UpdateLineItemEntry,
BulkUpdateLineItemsRequest,
} from '@proposal-system/api-contracts';

View file

@ -0,0 +1,64 @@
// 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';
import { lineItemsApi } from './api';
import { proposalsKeys } from '../proposals/use-cases';
import type { CreateLineItemRequest, UpdateLineItemEntry } from './types';
export const lineItemsKeys = {
all: ['lineItems'] as const,
byProposal: (proposalId: string) => [...lineItemsKeys.all, proposalId] as const,
};
/** Line items for a proposal (AdminWorkspace editor). */
export function useLineItems(proposalId: string | undefined) {
return useQuery({
queryKey: lineItemsKeys.byProposal(proposalId ?? ''),
queryFn: () => lineItemsApi.getAll(proposalId!),
enabled: !!proposalId,
});
}
export function useCreateLineItem(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateLineItemRequest) => lineItemsApi.create(proposalId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Failed to add line item: ${error.message}`);
},
});
}
/** Replace-all update of a proposal's line items. Totals change, so the
* proposal detail is invalidated alongside the line items. */
export function useBulkUpdateLineItems(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (lineItems: UpdateLineItemEntry[]) => lineItemsApi.bulkUpdate(proposalId, lineItems),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
}
export function useDeleteLineItem(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: string) => lineItemsApi.delete(proposalId, itemId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Failed to delete line item: ${error.message}`);
},
});
}

View file

@ -0,0 +1,37 @@
// Pricing library domain — HTTP calls only (no hooks). Moved from lib/api/pricingLibrary.ts.
import apiClient from '../../lib/api/client';
import type {
PricingLibraryItem,
PricingLibraryListParams,
CreatePricingLibraryItemRequest,
UpdatePricingLibraryItemRequest,
PagedPricingLibraryResponse,
} from './types';
export const pricingLibraryApi = {
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */
list: async (params: PricingLibraryListParams = {}): Promise<PagedPricingLibraryResponse> => {
const qs = new URLSearchParams();
if (params.page) qs.append('page', String(params.page));
if (params.pageSize) qs.append('pageSize', String(params.pageSize));
const res = await apiClient.get(`/pricing-library/list?${qs.toString()}`);
return res.data;
},
/** POST /api/pricing-library — create a new pricing library item. */
create: async (data: CreatePricingLibraryItemRequest): Promise<PricingLibraryItem> => {
const res = await apiClient.post('/pricing-library', data);
return res.data;
},
/** PUT /api/pricing-library/{id} — update an existing pricing library item. */
update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise<PricingLibraryItem> => {
const res = await apiClient.put(`/pricing-library/${id}`, data);
return res.data;
},
/** DELETE /api/pricing-library/{id} — delete a pricing library item. */
remove: async (id: string): Promise<void> => {
await apiClient.delete(`/pricing-library/${id}`);
},
};

View file

@ -0,0 +1,50 @@
// Pricing library domain — contract schema re-exports + item form schema.
import { z } from 'zod';
import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas';
import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types';
export {
pricingLibraryItemSchema,
createPricingLibraryItemRequestSchema,
updatePricingLibraryItemRequestSchema,
} from '@proposal-system/api-contracts/schemas';
/**
* Pricing-library item create/edit form. Derived from
* createPricingLibraryItemRequestSchema — unitPrice is a string in form state
* (numeric text input, empty allowed); toPricingLibraryItemRequest() converts
* to the wire type. Validation mirrors the current PricingLibraryPage rules.
*/
export const pricingLibraryItemFormSchema = createPricingLibraryItemRequestSchema
.pick({ title: true, serviceCategory: true })
.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',
}),
keywords: z.string(),
description: z.string(),
});
export type PricingLibraryItemFormValues = z.infer<typeof pricingLibraryItemFormSchema>;
/**
* Map validated form values to the create/update request (mirrors the current
* PricingLibraryPage submit mapping: optional strings dropped when empty,
* empty unitPrice sent as null).
*/
export function toPricingLibraryItemRequest(
values: PricingLibraryItemFormValues
): CreatePricingLibraryItemRequest & UpdatePricingLibraryItemRequest {
return {
title: values.title.trim(),
serviceCategory: values.serviceCategory,
...(values.unit.trim() ? { unit: values.unit.trim() } : {}),
unitPrice: values.unitPrice.trim() !== '' ? parseFloat(values.unitPrice) : null,
...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}),
...(values.description.trim() ? { description: values.description.trim() } : {}),
};
}

View file

@ -0,0 +1,11 @@
// Pricing library domain — contract type re-exports + view-only types.
import type { PagedResponse, PricingLibraryItem } from '@proposal-system/api-contracts';
export type {
PricingLibraryItem,
PricingLibraryListParams,
CreatePricingLibraryItemRequest,
UpdatePricingLibraryItemRequest,
} from '@proposal-system/api-contracts';
export type PagedPricingLibraryResponse = PagedResponse<PricingLibraryItem>;

View file

@ -0,0 +1,66 @@
// Pricing library domain — TanStack Query hooks + query keys (the only public surface).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { pricingLibraryApi } from './api';
import type {
CreatePricingLibraryItemRequest,
PricingLibraryListParams,
UpdatePricingLibraryItemRequest,
} from './types';
export const pricingLibraryKeys = {
all: ['pricingLibrary'] as const,
lists: () => [...pricingLibraryKeys.all, 'list'] as const,
list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const,
};
/** Paginated pricing library list (PricingLibraryPage). */
export function usePricingLibraryList(params: PricingLibraryListParams = {}) {
return useQuery({
queryKey: pricingLibraryKeys.list(params),
queryFn: () => pricingLibraryApi.list(params),
});
}
export function useCreatePricingLibraryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() });
toast.success('Pricing item created');
},
onError: (error: Error) => {
toast.error(`Failed to create item: ${error.message}`);
},
});
}
export function useUpdatePricingLibraryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) =>
pricingLibraryApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() });
toast.success('Pricing item updated');
},
onError: (error: Error) => {
toast.error(`Failed to update item: ${error.message}`);
},
});
}
export function useDeletePricingLibraryItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => pricingLibraryApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() });
toast.success('Pricing item deleted');
},
onError: (error: Error) => {
toast.error(`Failed to delete item: ${error.message}`);
},
});
}

View file

@ -0,0 +1,91 @@
// Proposals domain — HTTP calls only (no hooks). Moved from lib/api/proposals.ts.
import apiClient from '../../lib/api/client';
import type {
CreateProposalRequest,
ProposalDetail,
ProposalListItem,
ProposalFilters,
ProposalStats,
PagedResponse,
PdfVersion,
PresignedUpload,
VendorProposal,
} from './types';
export const proposalsApi = {
create: async (data: CreateProposalRequest): Promise<ProposalDetail> => {
const res = await apiClient.post('/proposals', data);
return res.data;
},
getAll: async (filters: ProposalFilters = {}): Promise<PagedResponse<ProposalListItem>> => {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.pageSize) params.append('pageSize', String(filters.pageSize));
if (filters.status) params.append('status', filters.status);
if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory);
if (filters.priority) params.append('priority', filters.priority);
if (filters.fromDate) params.append('fromDate', filters.fromDate);
if (filters.toDate) params.append('toDate', filters.toDate);
if (filters.mine) params.append('mine', 'true');
const res = await apiClient.get(`/proposals?${params.toString()}`);
return res.data;
},
getById: async (id: string): Promise<ProposalDetail> => {
const res = await apiClient.get(`/proposals/${id}`);
return res.data;
},
uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<PresignedUpload> => {
const params = new URLSearchParams({ fileName });
if (vendorName) params.append('vendorName', vendorName);
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);
return res.data;
},
confirmUpload: async (proposalId: string, vendorProposalId: string): Promise<void> => {
await apiClient.post(`/proposals/${proposalId}/attachments/${vendorProposalId}/confirm`);
},
getStats: async (): Promise<ProposalStats> => {
const res = await apiClient.get('/proposals/stats');
return res.data;
},
/** GET /api/proposals/{id}/vendors — vendor proposals attached to a proposal
* (previously an inline apiClient call in VendorDataPanel). */
getVendors: async (proposalId: string): Promise<VendorProposal[]> => {
const res = await apiClient.get(`/proposals/${proposalId}/vendors`);
return res.data;
},
getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
getPdfVersions: async (id: string): Promise<PdfVersion[]> => {
const res = await apiClient.get(`/proposals/${id}/pdf/versions`);
return res.data;
},
getPdfRevision: async (id: string, revision: number): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf/${revision}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
};

View file

@ -0,0 +1,68 @@
// Proposals domain — contract schema re-exports + form schemas.
// Form schemas are derived from the shared contract schemas (rule 4) — form
// fields hold strings for free-text inputs; toCreateProposalRequest() maps
// validated form values to the wire type at submit.
import { z } from 'zod';
import { createProposalRequestSchema } from '@proposal-system/api-contracts/schemas';
import type { CreateProposalRequest } from './types';
export {
proposalStatusSchema,
serviceCategorySchema,
prioritySchema,
proposalListItemSchema,
proposalDetailSchema,
createProposalRequestSchema,
updateProposalRequestSchema,
proposalStatsSchema,
pdfVersionSchema,
presignedUploadSchema,
} from '@proposal-system/api-contracts/schemas';
// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions
export const MIN_SCOPE_LENGTH = 10;
/**
* Create-proposal form. Derived from createProposalRequestSchema:
* optional wire fields (poNumber, notes) are plain strings in the form, and
* `customCategory` is a form-only field required when serviceCategory=Other.
*/
export const createProposalFormSchema = createProposalRequestSchema
.extend({
workOrderNumber: z.string().trim().min(1, 'Work Order Number is required'),
poNumber: z.string(),
customerName: z.string().trim().min(1, 'Customer name is required'),
customerAddress: z.string().trim().min(1, 'Site address is required'),
scopeOfWork: z
.string()
.trim()
.min(MIN_SCOPE_LENGTH, `Scope of Work must be at least ${MIN_SCOPE_LENGTH} characters`),
notes: z.string(),
customCategory: z.string(),
})
.refine((v) => v.serviceCategory !== 'Other' || v.customCategory.trim().length > 0, {
message: 'Specify category',
path: ['customCategory'],
});
export type CreateProposalFormValues = z.infer<typeof createProposalFormSchema>;
/** Map validated form values to the wire request (mirrors the current
* ProposalFormPage submit mapping, including the `[Category: X]` notes prefix). */
export function toCreateProposalRequest(values: CreateProposalFormValues): CreateProposalRequest {
const notes =
values.serviceCategory === 'Other' && values.customCategory.trim()
? `[Category: ${values.customCategory.trim()}]\n${values.notes || ''}`.trim()
: values.notes || undefined;
return {
workOrderNumber: values.workOrderNumber,
...(values.poNumber.trim() ? { poNumber: values.poNumber.trim() } : {}),
customerName: values.customerName,
customerAddress: values.customerAddress,
scopeOfWork: values.scopeOfWork,
serviceCategory: values.serviceCategory,
priority: values.priority,
notes,
};
}

View file

@ -0,0 +1,32 @@
// Proposals domain — contract type re-exports + view-only types.
export type {
ProposalStatus,
ServiceCategory,
Priority,
CreateProposalRequest,
ProposalDetail,
ProposalListItem,
ProposalFilters,
ProposalStats,
PagedResponse,
PdfVersion,
PresignedUpload,
} from '@proposal-system/api-contracts';
/** View-only shape of GET /proposals/{id}/vendors (no contract DTO yet;
* previously declared inline in VendorDataPanel). */
export interface VendorProposal {
id: string;
vendorName: string;
fileName: string;
totalVendorCost: number;
processingStatus: string;
extractedData: {
lineItems?: Array<{
description: string;
quantity?: number;
unitPrice?: number;
total?: number;
}>;
} | null;
}

View file

@ -0,0 +1,98 @@
// Proposals domain — TanStack Query hooks + query keys (the only public surface).
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { proposalsApi } from './api';
import type { CreateProposalRequest, ProposalDetail, ProposalFilters } from './types';
export const proposalsKeys = {
all: ['proposals'] as const,
lists: () => [...proposalsKeys.all, 'list'] as const,
list: (filters: ProposalFilters) => [...proposalsKeys.lists(), filters] as const,
detail: (id: string) => [...proposalsKeys.all, id] as const,
stats: () => [...proposalsKeys.all, 'stats'] as const,
vendors: (id: string) => [...proposalsKeys.all, id, 'vendors'] as const,
};
/** Paginated/filtered proposal list (ProposalListPage, AdminDashboard queue,
* Dashboard recent). keepPreviousData mirrors usePaginatedList's
* keep-rows-while-loading behavior across page changes. */
export function useProposals(filters: ProposalFilters = {}) {
return useQuery({
queryKey: proposalsKeys.list(filters),
queryFn: () => proposalsApi.getAll(filters),
placeholderData: keepPreviousData,
});
}
export function useProposal(id: string | undefined) {
return useQuery({
queryKey: proposalsKeys.detail(id ?? ''),
queryFn: () => proposalsApi.getById(id!),
enabled: !!id,
});
}
export function useProposalStats() {
return useQuery({
queryKey: proposalsKeys.stats(),
queryFn: () => proposalsApi.getStats(),
});
}
/** Vendor proposals attached to a proposal (VendorDataPanel). */
export function useVendorProposals(proposalId: string | undefined) {
return useQuery({
queryKey: proposalsKeys.vendors(proposalId ?? ''),
queryFn: () => proposalsApi.getVendors(proposalId!),
enabled: !!proposalId,
});
}
export interface CreateProposalVariables {
request: CreateProposalRequest;
/** Optional vendor PDF uploaded alongside the proposal. */
vendorFile?: File | null;
}
/**
* Create a proposal, optionally uploading a vendor PDF via presigned URL.
* Mirrors ProposalFormPage: a failed attachment upload warns but does not
* fail the mutation; success invalidates all proposal queries and toasts.
* Page-specific side effects (navigate) belong in the call site's onSuccess.
*/
export function useCreateProposal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise<ProposalDetail> => {
const proposal = await proposalsApi.create(request);
if (vendorFile) {
try {
const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(
proposal.id,
vendorFile.name
);
await fetch(uploadUrl, {
method: 'PUT',
body: vendorFile,
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch {
toast.warning(
`Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`
);
}
}
return proposal;
},
onSuccess: (proposal) => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.all });
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to submit proposal');
},
});
}

View file

@ -0,0 +1,10 @@
// Sites domain — HTTP calls only (no hooks). Moved from lib/api/sites.ts.
import apiClient from '../../lib/api/client';
import type { Site } from './types';
export const sitesApi = {
search: async (query: string): Promise<Site[]> => {
const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`);
return res.data;
},
};

View file

@ -0,0 +1,3 @@
// Sites domain — contract schema re-exports. Sites are read-only (search
// autocomplete), so there are no form schemas here.
export { siteSchema } from '@proposal-system/api-contracts/schemas';

View file

@ -0,0 +1,2 @@
// Sites domain — contract type re-exports.
export type { Site } from '@proposal-system/api-contracts';

View file

@ -0,0 +1,18 @@
// Sites domain — TanStack Query hooks + query keys (the only public surface).
import { useQuery } from '@tanstack/react-query';
import { sitesApi } from './api';
export const sitesKeys = {
all: ['sites'] as const,
search: (query: string) => [...sitesKeys.all, 'search', query] as const,
};
/** Site autocomplete (ProposalFormPage). Call with the debounced input;
* disabled below 2 characters, matching current page behavior. */
export function useSiteSearch(query: string) {
return useQuery({
queryKey: sitesKeys.search(query),
queryFn: () => sitesApi.search(query),
enabled: query.trim().length >= 2,
});
}

View file

@ -1,3 +1,4 @@
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';
@ -8,6 +9,14 @@ export default defineConfig({
// '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).
preserveSymlinks: true, preserveSymlinks: true,
alias: {
// The package has no `exports` map (main points at src/index.ts), so
// the /schemas subpath entrypoint needs an explicit alias (mirrors the
// tsconfig `paths` mapping; vitest.config.ts carries the same alias).
'@proposal-system/api-contracts/schemas': fileURLToPath(
new URL('../shared/api-contracts/src/schemas.ts', import.meta.url)
),
},
}, },
server: { server: {
port: 5173, port: 5173,

View file

@ -1,8 +1,19 @@
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: {
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)
),
},
},
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,