From e6e2c60c2f726c3061a8ca90b6d866db885e9315 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 17:58:19 -0400 Subject: [PATCH] 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. --- web/src/domain/__tests__/schemas.test.ts | 157 ++++++++++++++++++ web/src/domain/admin/api.ts | 62 +++++++ web/src/domain/admin/schemas.ts | 7 + web/src/domain/admin/types.ts | 19 +++ web/src/domain/admin/use-cases.ts | 179 +++++++++++++++++++++ web/src/domain/customers/api.ts | 40 +++++ web/src/domain/customers/schemas.ts | 48 ++++++ web/src/domain/customers/types.ts | 11 ++ web/src/domain/customers/use-cases.ts | 59 +++++++ web/src/domain/lineItems/api.ts | 24 +++ web/src/domain/lineItems/schemas.ts | 61 +++++++ web/src/domain/lineItems/types.ts | 9 ++ web/src/domain/lineItems/use-cases.ts | 64 ++++++++ web/src/domain/pricingLibrary/api.ts | 37 +++++ web/src/domain/pricingLibrary/schemas.ts | 50 ++++++ web/src/domain/pricingLibrary/types.ts | 11 ++ web/src/domain/pricingLibrary/use-cases.ts | 66 ++++++++ web/src/domain/proposals/api.ts | 91 +++++++++++ web/src/domain/proposals/schemas.ts | 68 ++++++++ web/src/domain/proposals/types.ts | 32 ++++ web/src/domain/proposals/use-cases.ts | 98 +++++++++++ web/src/domain/sites/api.ts | 10 ++ web/src/domain/sites/schemas.ts | 3 + web/src/domain/sites/types.ts | 2 + web/src/domain/sites/use-cases.ts | 18 +++ web/vite.config.ts | 9 ++ web/vitest.config.ts | 11 ++ 27 files changed, 1246 insertions(+) create mode 100644 web/src/domain/__tests__/schemas.test.ts create mode 100644 web/src/domain/admin/api.ts create mode 100644 web/src/domain/admin/schemas.ts create mode 100644 web/src/domain/admin/types.ts create mode 100644 web/src/domain/admin/use-cases.ts create mode 100644 web/src/domain/customers/api.ts create mode 100644 web/src/domain/customers/schemas.ts create mode 100644 web/src/domain/customers/types.ts create mode 100644 web/src/domain/customers/use-cases.ts create mode 100644 web/src/domain/lineItems/api.ts create mode 100644 web/src/domain/lineItems/schemas.ts create mode 100644 web/src/domain/lineItems/types.ts create mode 100644 web/src/domain/lineItems/use-cases.ts create mode 100644 web/src/domain/pricingLibrary/api.ts create mode 100644 web/src/domain/pricingLibrary/schemas.ts create mode 100644 web/src/domain/pricingLibrary/types.ts create mode 100644 web/src/domain/pricingLibrary/use-cases.ts create mode 100644 web/src/domain/proposals/api.ts create mode 100644 web/src/domain/proposals/schemas.ts create mode 100644 web/src/domain/proposals/types.ts create mode 100644 web/src/domain/proposals/use-cases.ts create mode 100644 web/src/domain/sites/api.ts create mode 100644 web/src/domain/sites/schemas.ts create mode 100644 web/src/domain/sites/types.ts create mode 100644 web/src/domain/sites/use-cases.ts diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts new file mode 100644 index 0000000..ff344ba --- /dev/null +++ b/web/src/domain/__tests__/schemas.test.ts @@ -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(); + }); +}); diff --git a/web/src/domain/admin/api.ts b/web/src/domain/admin/api.ts new file mode 100644 index 0000000..472f339 --- /dev/null +++ b/web/src/domain/admin/api.ts @@ -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 => { + const res = await apiClient.get('/admin/dashboard'); + return res.data; + }, + + updateProposal: async (id: string, data: UpdateProposalRequest): Promise => { + await apiClient.put(`/proposals/${id}`, data); + }, + + approveProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/approve`); + }, + + returnToReview: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/return-to-review`); + }, + + sendProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/send`); + }, + + reviseProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/revise`); + }, + + getHistory: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/history`); + return res.data; + }, + + getAudit: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/audit`); + return res.data; + }, + + generateSuggestions: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/generate-suggestions`); + }, + + getSimilar: async (id: string): Promise => { + 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 }; + }, +}; diff --git a/web/src/domain/admin/schemas.ts b/web/src/domain/admin/schemas.ts new file mode 100644 index 0000000..e789fa1 --- /dev/null +++ b/web/src/domain/admin/schemas.ts @@ -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'; diff --git a/web/src/domain/admin/types.ts b/web/src/domain/admin/types.ts new file mode 100644 index 0000000..5fa57ae --- /dev/null +++ b/web/src/domain/admin/types.ts @@ -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; + }>; +} diff --git a/web/src/domain/admin/use-cases.ts b/web/src/domain/admin/use-cases.ts new file mode 100644 index 0000000..dadadb6 --- /dev/null +++ b/web/src/domain/admin/use-cases.ts @@ -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'); + }, + }); +} diff --git a/web/src/domain/customers/api.ts b/web/src/domain/customers/api.ts new file mode 100644 index 0000000..695872b --- /dev/null +++ b/web/src/domain/customers/api.ts @@ -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= — search autocomplete (unchanged). */ + search: async (query?: string): Promise => { + 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 => { + 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 => { + 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 => { + const res = await apiClient.put(`/customers/${id}`, data); + return res.data; + }, +}; diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts new file mode 100644 index 0000000..fe5f233 --- /dev/null +++ b/web/src/domain/customers/schemas.ts @@ -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; + +/** + * 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() } : {}), + }; +} diff --git a/web/src/domain/customers/types.ts b/web/src/domain/customers/types.ts new file mode 100644 index 0000000..2deb9a0 --- /dev/null +++ b/web/src/domain/customers/types.ts @@ -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; diff --git a/web/src/domain/customers/use-cases.ts b/web/src/domain/customers/use-cases.ts new file mode 100644 index 0000000..ab37d6b --- /dev/null +++ b/web/src/domain/customers/use-cases.ts @@ -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}`); + }, + }); +} diff --git a/web/src/domain/lineItems/api.ts b/web/src/domain/lineItems/api.ts new file mode 100644 index 0000000..6d18f1d --- /dev/null +++ b/web/src/domain/lineItems/api.ts @@ -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 => { + const res = await apiClient.get(`/proposals/${proposalId}/line-items`); + return res.data; + }, + + create: async (proposalId: string, data: CreateLineItemRequest): Promise => { + const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data); + return res.data; + }, + + bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise => { + const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems }); + return res.data; + }, + + delete: async (proposalId: string, itemId: string): Promise => { + await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`); + }, +}; diff --git a/web/src/domain/lineItems/schemas.ts b/web/src/domain/lineItems/schemas.ts new file mode 100644 index 0000000..f573810 --- /dev/null +++ b/web/src/domain/lineItems/schemas.ts @@ -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; + +/** Map a validated row to the bulk-update wire entry. */ +export function toUpdateLineItemEntry( + values: LineItemRowFormValues, + meta: { id: string | null; sortOrder: number; source: UpdateLineItemEntry['source'] } +): UpdateLineItemEntry { + return { + id: meta.id, + description: values.description, + quantity: Number(values.quantity), + unit: values.unit, + unitPrice: values.unitPrice.trim() !== '' ? Number(values.unitPrice) : null, + totalPrice: Number(values.totalPrice), + pricingMode: values.pricingMode, + sortOrder: meta.sortOrder, + source: meta.source, + }; +} diff --git a/web/src/domain/lineItems/types.ts b/web/src/domain/lineItems/types.ts new file mode 100644 index 0000000..202c3b5 --- /dev/null +++ b/web/src/domain/lineItems/types.ts @@ -0,0 +1,9 @@ +// Line items domain — contract type re-exports. +export type { + PricingMode, + LineItemSource, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, + BulkUpdateLineItemsRequest, +} from '@proposal-system/api-contracts'; diff --git a/web/src/domain/lineItems/use-cases.ts b/web/src/domain/lineItems/use-cases.ts new file mode 100644 index 0000000..01c02a6 --- /dev/null +++ b/web/src/domain/lineItems/use-cases.ts @@ -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}`); + }, + }); +} diff --git a/web/src/domain/pricingLibrary/api.ts b/web/src/domain/pricingLibrary/api.ts new file mode 100644 index 0000000..c9f7974 --- /dev/null +++ b/web/src/domain/pricingLibrary/api.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + await apiClient.delete(`/pricing-library/${id}`); + }, +}; diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts new file mode 100644 index 0000000..434ce75 --- /dev/null +++ b/web/src/domain/pricingLibrary/schemas.ts @@ -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; + +/** + * 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() } : {}), + }; +} diff --git a/web/src/domain/pricingLibrary/types.ts b/web/src/domain/pricingLibrary/types.ts new file mode 100644 index 0000000..2fc8209 --- /dev/null +++ b/web/src/domain/pricingLibrary/types.ts @@ -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; diff --git a/web/src/domain/pricingLibrary/use-cases.ts b/web/src/domain/pricingLibrary/use-cases.ts new file mode 100644 index 0000000..e237877 --- /dev/null +++ b/web/src/domain/pricingLibrary/use-cases.ts @@ -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}`); + }, + }); +} diff --git a/web/src/domain/proposals/api.ts b/web/src/domain/proposals/api.ts new file mode 100644 index 0000000..61567cf --- /dev/null +++ b/web/src/domain/proposals/api.ts @@ -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 => { + const res = await apiClient.post('/proposals', data); + return res.data; + }, + + getAll: async (filters: ProposalFilters = {}): Promise> => { + 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 => { + const res = await apiClient.get(`/proposals/${id}`); + return res.data; + }, + + uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise => { + 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 => { + await apiClient.post(`/proposals/${proposalId}/attachments/${vendorProposalId}/confirm`); + }, + + getStats: async (): Promise => { + 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 => { + 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 => { + 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 }; + }, +}; diff --git a/web/src/domain/proposals/schemas.ts b/web/src/domain/proposals/schemas.ts new file mode 100644 index 0000000..ca7f63a --- /dev/null +++ b/web/src/domain/proposals/schemas.ts @@ -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; + +/** 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, + }; +} diff --git a/web/src/domain/proposals/types.ts b/web/src/domain/proposals/types.ts new file mode 100644 index 0000000..5b8c74f --- /dev/null +++ b/web/src/domain/proposals/types.ts @@ -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; +} diff --git a/web/src/domain/proposals/use-cases.ts b/web/src/domain/proposals/use-cases.ts new file mode 100644 index 0000000..ba2ac5d --- /dev/null +++ b/web/src/domain/proposals/use-cases.ts @@ -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 => { + 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'); + }, + }); +} diff --git a/web/src/domain/sites/api.ts b/web/src/domain/sites/api.ts new file mode 100644 index 0000000..2e45cfb --- /dev/null +++ b/web/src/domain/sites/api.ts @@ -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 => { + const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`); + return res.data; + }, +}; diff --git a/web/src/domain/sites/schemas.ts b/web/src/domain/sites/schemas.ts new file mode 100644 index 0000000..6c29cdb --- /dev/null +++ b/web/src/domain/sites/schemas.ts @@ -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'; diff --git a/web/src/domain/sites/types.ts b/web/src/domain/sites/types.ts new file mode 100644 index 0000000..ab6a9ce --- /dev/null +++ b/web/src/domain/sites/types.ts @@ -0,0 +1,2 @@ +// Sites domain — contract type re-exports. +export type { Site } from '@proposal-system/api-contracts'; diff --git a/web/src/domain/sites/use-cases.ts b/web/src/domain/sites/use-cases.ts new file mode 100644 index 0000000..1e82af7 --- /dev/null +++ b/web/src/domain/sites/use-cases.ts @@ -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, + }); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 782b661..5de237b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; 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 // installed node_modules there). preserveSymlinks: true, + alias: { + // The package has no `exports` map (main points at src/index.ts), so + // the /schemas subpath entrypoint needs an explicit alias (mirrors the + // tsconfig `paths` mapping; vitest.config.ts carries the same alias). + '@proposal-system/api-contracts/schemas': fileURLToPath( + new URL('../shared/api-contracts/src/schemas.ts', import.meta.url) + ), + }, }, server: { port: 5173, diff --git a/web/vitest.config.ts b/web/vitest.config.ts index e47388f..e14846c 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,8 +1,19 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + // @proposal-system/api-contracts has no `exports` map (main points at + // src/index.ts), so the /schemas subpath entrypoint needs an explicit + // alias for vitest's resolver (mirrors the tsconfig `paths` mapping). + '@proposal-system/api-contracts/schemas': fileURLToPath( + new URL('../shared/api-contracts/src/schemas.ts', import.meta.url) + ), + }, + }, test: { environment: 'jsdom', globals: true,