mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 08:08:56 +00:00
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:
parent
924bfdd8f6
commit
e6e2c60c2f
27 changed files with 1246 additions and 0 deletions
157
web/src/domain/__tests__/schemas.test.ts
Normal file
157
web/src/domain/__tests__/schemas.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
web/src/domain/admin/api.ts
Normal file
62
web/src/domain/admin/api.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
7
web/src/domain/admin/schemas.ts
Normal file
7
web/src/domain/admin/schemas.ts
Normal 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';
|
||||||
19
web/src/domain/admin/types.ts
Normal file
19
web/src/domain/admin/types.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
179
web/src/domain/admin/use-cases.ts
Normal file
179
web/src/domain/admin/use-cases.ts
Normal 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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
40
web/src/domain/customers/api.ts
Normal file
40
web/src/domain/customers/api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
48
web/src/domain/customers/schemas.ts
Normal file
48
web/src/domain/customers/schemas.ts
Normal 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() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
web/src/domain/customers/types.ts
Normal file
11
web/src/domain/customers/types.ts
Normal 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>;
|
||||||
59
web/src/domain/customers/use-cases.ts
Normal file
59
web/src/domain/customers/use-cases.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
24
web/src/domain/lineItems/api.ts
Normal file
24
web/src/domain/lineItems/api.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
61
web/src/domain/lineItems/schemas.ts
Normal file
61
web/src/domain/lineItems/schemas.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
web/src/domain/lineItems/types.ts
Normal file
9
web/src/domain/lineItems/types.ts
Normal 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';
|
||||||
64
web/src/domain/lineItems/use-cases.ts
Normal file
64
web/src/domain/lineItems/use-cases.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
37
web/src/domain/pricingLibrary/api.ts
Normal file
37
web/src/domain/pricingLibrary/api.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
50
web/src/domain/pricingLibrary/schemas.ts
Normal file
50
web/src/domain/pricingLibrary/schemas.ts
Normal 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() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
web/src/domain/pricingLibrary/types.ts
Normal file
11
web/src/domain/pricingLibrary/types.ts
Normal 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>;
|
||||||
66
web/src/domain/pricingLibrary/use-cases.ts
Normal file
66
web/src/domain/pricingLibrary/use-cases.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
91
web/src/domain/proposals/api.ts
Normal file
91
web/src/domain/proposals/api.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
68
web/src/domain/proposals/schemas.ts
Normal file
68
web/src/domain/proposals/schemas.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
web/src/domain/proposals/types.ts
Normal file
32
web/src/domain/proposals/types.ts
Normal 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;
|
||||||
|
}
|
||||||
98
web/src/domain/proposals/use-cases.ts
Normal file
98
web/src/domain/proposals/use-cases.ts
Normal 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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
10
web/src/domain/sites/api.ts
Normal file
10
web/src/domain/sites/api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
3
web/src/domain/sites/schemas.ts
Normal file
3
web/src/domain/sites/schemas.ts
Normal 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';
|
||||||
2
web/src/domain/sites/types.ts
Normal file
2
web/src/domain/sites/types.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Sites domain — contract type re-exports.
|
||||||
|
export type { Site } from '@proposal-system/api-contracts';
|
||||||
18
web/src/domain/sites/use-cases.ts
Normal file
18
web/src/domain/sites/use-cases.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue