mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 12:48:57 +00:00
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:
parent
f456006c30
commit
2e1099a560
10 changed files with 3 additions and 411 deletions
|
|
@ -19,7 +19,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
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 {
|
||||
id: string | null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -11,25 +10,10 @@ import {
|
|||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
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 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 {
|
||||
proposalId: string;
|
||||
onPullLineItem: (item: Omit<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void;
|
||||
|
|
@ -37,11 +21,7 @@ interface SimilarProposalsPanelProps {
|
|||
}
|
||||
|
||||
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
|
||||
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({
|
||||
queryKey: ['similarProposals', proposalId],
|
||||
queryFn: () => adminApi.getSimilar(proposalId) as Promise<SimilarProposal[]>,
|
||||
enabled: !!proposalId,
|
||||
});
|
||||
const { data: similar, isLoading } = useSimilarProposals(proposalId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue