refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules

- SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline
  SimilarProposal type replaced by domain/admin/types (identical shape);
  query key joins the admin hierarchical key space
- LineItemEditor type imports -> domain/lineItems/types
- Delete now-orphaned lib/api/{proposals,lineItems,customers,
  pricingLibrary,admin,sites}.ts, constants/queryKeys.ts,
  hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per
  domain README rule 5)

Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the
authed shell renders on domain hooks.
This commit is contained in:
Adam Moussa 2026-07-13 18:11:05 -04:00
parent f456006c30
commit 2e1099a560
No known key found for this signature in database
10 changed files with 3 additions and 411 deletions

View file

@ -19,7 +19,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import type { PricingMode, LineItemSource } from '../../lib/api/lineItems'; import type { PricingMode, LineItemSource } from '../../domain/lineItems/types';
export interface EditableLineItem { export interface EditableLineItem {
id: string | null; id: string | null;

View file

@ -1,4 +1,3 @@
import { useQuery } from '@tanstack/react-query';
import { import {
Box, Box,
Typography, Typography,
@ -11,25 +10,10 @@ import {
} from '@mui/material'; } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { adminApi } from '../../lib/api/admin'; import { useSimilarProposals } from '../../domain/admin/use-cases';
import { formatCurrency } from '../../lib/format'; import { formatCurrency } from '../../lib/format';
import type { EditableLineItem } from './LineItemEditor'; import type { EditableLineItem } from './LineItemEditor';
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;
}>;
}
interface SimilarProposalsPanelProps { interface SimilarProposalsPanelProps {
proposalId: string; proposalId: string;
onPullLineItem: (item: Omit<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void; onPullLineItem: (item: Omit<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void;
@ -37,11 +21,7 @@ interface SimilarProposalsPanelProps {
} }
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({ const { data: similar, isLoading } = useSimilarProposals(proposalId);
queryKey: ['similarProposals', proposalId],
queryFn: () => adminApi.getSimilar(proposalId) as Promise<SimilarProposal[]>,
enabled: !!proposalId,
});
if (isLoading) { if (isLoading) {
return ( return (

View file

@ -1,11 +0,0 @@
export const QUERY_KEYS = {
proposals: 'proposals',
proposal: 'proposal',
proposalLineItems: 'proposalLineItems',
customers: 'customers',
customerList: 'customerList',
users: 'users',
dashboard: 'dashboard',
auditTrail: 'auditTrail',
pricingLibraryList: 'pricingLibraryList',
} as const;

View file

@ -1,76 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants';
interface PaginatedResult<T> {
items: T[];
totalCount: number;
}
type FetchFn<T> = (params: Record<string, unknown>) => Promise<PaginatedResult<T>>;
export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<string, unknown> = {}) {
const [rows, setRows] = useState<T[]>([]);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const extraKey = JSON.stringify(extraParams);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, DEBOUNCE_SEARCH);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const prevExtraKey = useRef(extraKey);
useEffect(() => {
if (prevExtraKey.current !== extraKey) {
prevExtraKey.current = extraKey;
setPage(1);
}
}, [extraKey]);
const reload = useCallback(() => {
setLoading(true);
setErr('');
fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams })
.then(({ items, totalCount }) => {
setRows(items);
setTotalCount(totalCount);
})
.catch((e: Error) => setErr(e.message || 'Failed to load'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchFn, debouncedSearch, page, pageSize, extraKey]);
useEffect(() => {
reload();
}, [reload]);
return useMemo(
() => ({
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload]
);
}

View file

@ -1,63 +0,0 @@
import apiClient from './client';
// Fix: WEB-M5 — types from the shared contracts package.
import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts';
export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts';
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<unknown[]> => {
const res = await apiClient.get(`/proposals/${id}/similar`);
return res.data;
},
getPdf: async (id: string, regenerate = false): Promise<{ downloadUrl: string } | null> => {
const params = regenerate ? '?regenerate=true' : '';
const res = await apiClient.get(`/proposals/${id}/pdf${params}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 202) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
};

View file

@ -1,51 +0,0 @@
import apiClient from './client';
// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes
// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest
// sent a singular `address` field the API silently dropped.
import type {
Customer,
CustomerListParams,
CreateCustomerRequest,
UpdateCustomerRequest,
PagedResponse,
} from '@proposal-system/api-contracts';
export type {
Customer,
CustomerListParams,
CreateCustomerRequest,
UpdateCustomerRequest,
} from '@proposal-system/api-contracts';
export type PagedCustomerResponse = PagedResponse<Customer>;
export const customersApi = {
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */
search: async (query?: string): Promise<Customer[]> => {
const params = query ? `?query=${encodeURIComponent(query)}` : '';
const res = await apiClient.get(`/customers${params}`);
return res.data;
},
/** GET /api/customers/list?page=&pageSize= — paginated management list (admin/sysadmin). */
list: async (params: CustomerListParams = {}): Promise<PagedCustomerResponse> => {
const qs = new URLSearchParams();
if (params.page) qs.append('page', String(params.page));
if (params.pageSize) qs.append('pageSize', String(params.pageSize));
if (params.search) qs.append('search', params.search);
const res = await apiClient.get(`/customers/list?${qs.toString()}`);
return res.data;
},
/** POST /api/customers — create a new customer. */
create: async (data: CreateCustomerRequest): Promise<Customer> => {
const res = await apiClient.post('/customers', data);
return res.data;
},
/** PUT /api/customers/{id} — update an existing customer (admin/sysadmin). */
update: async (id: string, data: UpdateCustomerRequest): Promise<Customer> => {
const res = await apiClient.put(`/customers/${id}`, data);
return res.data;
},
};

View file

@ -1,32 +0,0 @@
import apiClient from './client';
// Fix: WEB-M5 — types from the shared contracts package.
import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts';
export type {
PricingMode,
LineItemSource,
LineItem,
CreateLineItemRequest,
UpdateLineItemEntry,
} from '@proposal-system/api-contracts';
export const lineItemsApi = {
getAll: async (proposalId: string): Promise<LineItem[]> => {
const res = await apiClient.get(`/proposals/${proposalId}/line-items`);
return res.data;
},
create: async (proposalId: string, data: CreateLineItemRequest): Promise<LineItem> => {
const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data);
return res.data;
},
bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise<LineItem[]> => {
const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems });
return res.data;
},
delete: async (proposalId: string, itemId: string): Promise<void> => {
await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`);
},
};

View file

@ -1,46 +0,0 @@
import apiClient from './client';
// Fix: WEB-M5 — types from the shared contracts package.
import type {
PricingLibraryItem,
PricingLibraryListParams,
CreatePricingLibraryItemRequest,
UpdatePricingLibraryItemRequest,
PagedResponse,
} from '@proposal-system/api-contracts';
export type {
PricingLibraryItem,
PricingLibraryListParams,
CreatePricingLibraryItemRequest,
UpdatePricingLibraryItemRequest,
} from '@proposal-system/api-contracts';
export type PagedPricingLibraryResponse = PagedResponse<PricingLibraryItem>;
export const pricingLibraryApi = {
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */
list: async (params: PricingLibraryListParams = {}): Promise<PagedPricingLibraryResponse> => {
const qs = new URLSearchParams();
if (params.page) qs.append('page', String(params.page));
if (params.pageSize) qs.append('pageSize', String(params.pageSize));
const res = await apiClient.get(`/pricing-library/list?${qs.toString()}`);
return res.data;
},
/** POST /api/pricing-library — create a new pricing library item. */
create: async (data: CreatePricingLibraryItemRequest): Promise<PricingLibraryItem> => {
const res = await apiClient.post('/pricing-library', data);
return res.data;
},
/** PUT /api/pricing-library/{id} — update an existing pricing library item. */
update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise<PricingLibraryItem> => {
const res = await apiClient.put(`/pricing-library/${id}`, data);
return res.data;
},
/** DELETE /api/pricing-library/{id} — delete a pricing library item. */
remove: async (id: string): Promise<void> => {
await apiClient.delete(`/pricing-library/${id}`);
},
};

View file

@ -1,97 +0,0 @@
import apiClient from './client';
// Fix: WEB-M5 — types now come from the shared contracts package (single source
// of truth aligned with the .NET DTOs); re-exported so page imports stay stable.
import type {
CreateProposalRequest,
ProposalDetail,
ProposalListItem,
ProposalFilters,
ProposalStats,
PagedResponse,
PdfVersion,
PresignedUpload,
} from '@proposal-system/api-contracts';
export type {
ServiceCategory,
Priority,
CreateProposalRequest,
ProposalDetail,
ProposalListItem,
ProposalFilters,
ProposalStats,
PagedResponse,
PdfVersion,
PresignedUpload,
} from '@proposal-system/api-contracts';
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;
},
getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
getPdfVersions: async (id: string): Promise<PdfVersion[]> => {
const res = await apiClient.get(`/proposals/${id}/pdf/versions`);
return res.data;
},
getPdfRevision: async (id: string, revision: number): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf/${revision}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 404) return null;
const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
},
};

View file

@ -1,12 +0,0 @@
import apiClient from './client';
// Fix: WEB-M5 — types from the shared contracts package.
import type { Site } from '@proposal-system/api-contracts';
export type { Site } from '@proposal-system/api-contracts';
export const sitesApi = {
search: async (query: string): Promise<Site[]> => {
const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`);
return res.data;
},
};