mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 12:48:57 +00:00
refactor(web): proposal pages on domain layer, proposal form on react-hook-form
This commit is contained in:
parent
e6e2c60c2f
commit
97fa7f2b75
3 changed files with 167 additions and 202 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -15,7 +14,7 @@ import {
|
|||
StepLabel,
|
||||
} from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
|
||||
import { useProposal } from '../../../domain/proposals/use-cases';
|
||||
import { STATUS_CHIP_STYLES } from '../../../constants';
|
||||
import { formatCurrency, formatDateTime } from '../../../lib/format';
|
||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||
|
|
@ -37,11 +36,7 @@ export default function ProposalDetailPage() {
|
|||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
|
||||
queryKey: ['proposals', id],
|
||||
queryFn: () => proposalsApi.getById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
const { data: proposal, isLoading, error } = useProposal(id);
|
||||
|
||||
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -15,44 +16,45 @@ import {
|
|||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { toast } from 'react-toastify';
|
||||
import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
|
||||
import { sitesApi, type Site } from '../../../lib/api/sites';
|
||||
import { customersApi } from '../../../lib/api/customers';
|
||||
import { useCreateProposal } from '../../../domain/proposals/use-cases';
|
||||
import {
|
||||
createProposalFormSchema,
|
||||
toCreateProposalRequest,
|
||||
MIN_SCOPE_LENGTH,
|
||||
type CreateProposalFormValues,
|
||||
} from '../../../domain/proposals/schemas';
|
||||
import { useSiteSearch } from '../../../domain/sites/use-cases';
|
||||
import { useCustomerSearch } from '../../../domain/customers/use-cases';
|
||||
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
|
||||
import { queryClient } from '../../../lib/queryClient';
|
||||
|
||||
interface ProposalFormState {
|
||||
workOrderNumber: string;
|
||||
poNumber?: string;
|
||||
customerName: string;
|
||||
customerAddress: string;
|
||||
scopeOfWork: string;
|
||||
serviceCategory: ServiceCategory | '';
|
||||
priority: Priority;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
||||
const ALLOWED_MIME_TYPES = ['application/pdf'];
|
||||
// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions
|
||||
const MIN_SCOPE_LENGTH = 10;
|
||||
|
||||
export default function ProposalFormPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [form, setForm] = useState<ProposalFormState>({
|
||||
workOrderNumber: '',
|
||||
customerName: '',
|
||||
customerAddress: '',
|
||||
scopeOfWork: '',
|
||||
serviceCategory: '',
|
||||
priority: 'Standard',
|
||||
notes: '',
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<CreateProposalFormValues>({
|
||||
resolver: zodResolver(createProposalFormSchema),
|
||||
defaultValues: {
|
||||
workOrderNumber: '',
|
||||
poNumber: '',
|
||||
customerName: '',
|
||||
customerAddress: '',
|
||||
scopeOfWork: '',
|
||||
priority: 'Standard',
|
||||
notes: '',
|
||||
customCategory: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
||||
const [siteOptions, setSiteOptions] = useState<Site[]>([]);
|
||||
const [siteLoading, setSiteLoading] = useState(false);
|
||||
const [siteQuery, setSiteQuery] = useState('');
|
||||
const [siteInputValue, setSiteInputValue] = useState('');
|
||||
const [manualEntry, setManualEntry] = useState(false);
|
||||
const [manualSiteCode, setManualSiteCode] = useState('');
|
||||
|
|
@ -60,15 +62,22 @@ export default function ProposalFormPage() {
|
|||
const [manualCity, setManualCity] = useState('');
|
||||
const [manualState, setManualState] = useState('');
|
||||
const [manualZip, setManualZip] = useState('');
|
||||
const [customCategory, setCustomCategory] = useState('');
|
||||
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// PR4: customer search state — freeSolo Autocomplete backed by customersApi.search
|
||||
const [customerOptions, setCustomerOptions] = useState<string[]>([]);
|
||||
const [customerLoading, setCustomerLoading] = useState(false);
|
||||
// PR4: customer search state — freeSolo Autocomplete backed by the customers domain
|
||||
const [customerQuery, setCustomerQuery] = useState('');
|
||||
const [customerInputValue, setCustomerInputValue] = useState('');
|
||||
const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Domain search hooks (disabled below 2 characters, matching prior behavior).
|
||||
const siteSearch = useSiteSearch(siteQuery);
|
||||
const siteOptions = siteSearch.data ?? [];
|
||||
const siteLoading = siteSearch.isFetching;
|
||||
|
||||
const customerSearch = useCustomerSearch(customerQuery);
|
||||
const customerOptions = (customerSearch.data ?? []).map((c) => c.name);
|
||||
const customerLoading = customerSearch.isFetching;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
||||
|
|
@ -88,116 +97,52 @@ export default function ProposalFormPage() {
|
|||
manualSiteCode && address
|
||||
? `${manualSiteCode} — ${address}`
|
||||
: manualSiteCode || address;
|
||||
setForm((prev) => ({ ...prev, customerAddress: display }));
|
||||
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]);
|
||||
setValue('customerAddress', display);
|
||||
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]);
|
||||
|
||||
const handleChange = (field: keyof ProposalFormState, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
const createMutation = useCreateProposal();
|
||||
|
||||
const searchSites = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setSiteOptions([]);
|
||||
return;
|
||||
}
|
||||
setSiteLoading(true);
|
||||
try {
|
||||
const results = await sitesApi.search(query);
|
||||
setSiteOptions(results);
|
||||
} catch {
|
||||
setSiteOptions([]);
|
||||
} finally {
|
||||
setSiteLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// PR4: search customers by name, debounced from the Autocomplete input
|
||||
const searchCustomers = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setCustomerOptions([]);
|
||||
return;
|
||||
}
|
||||
setCustomerLoading(true);
|
||||
try {
|
||||
const results = await customersApi.search(query);
|
||||
setCustomerOptions(results.map((c) => c.name));
|
||||
} catch {
|
||||
setCustomerOptions([]);
|
||||
} finally {
|
||||
setCustomerLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const notes = form.serviceCategory === 'Other' && customCategory.trim()
|
||||
? `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim()
|
||||
: form.notes || undefined;
|
||||
const request: CreateProposalRequest = {
|
||||
workOrderNumber: form.workOrderNumber,
|
||||
customerName: form.customerName,
|
||||
customerAddress: form.customerAddress,
|
||||
scopeOfWork: form.scopeOfWork,
|
||||
serviceCategory: form.serviceCategory as ServiceCategory,
|
||||
priority: form.priority,
|
||||
notes,
|
||||
};
|
||||
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.`);
|
||||
}
|
||||
const onSubmit = (values: CreateProposalFormValues) => {
|
||||
createMutation.mutate(
|
||||
{ request: toCreateProposalRequest(values), vendorFile },
|
||||
{
|
||||
onSuccess: (proposal) => {
|
||||
navigate(`/proposals/${proposal.id}`);
|
||||
},
|
||||
}
|
||||
|
||||
return proposal;
|
||||
},
|
||||
onSuccess: (proposal) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['proposals'] });
|
||||
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
|
||||
navigate(`/proposals/${proposal.id}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || 'Failed to submit proposal');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate();
|
||||
);
|
||||
};
|
||||
|
||||
const workOrderNumber = watch('workOrderNumber');
|
||||
const customerName = watch('customerName');
|
||||
const customerAddress = watch('customerAddress');
|
||||
const scopeOfWork = watch('scopeOfWork');
|
||||
const serviceCategory = watch('serviceCategory');
|
||||
const customCategory = watch('customCategory');
|
||||
|
||||
// Fix: WEB-M3 — enforce minimum scope length
|
||||
const scopeTouched = form.scopeOfWork.length > 0;
|
||||
const scopeTooShort = scopeTouched && form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
|
||||
const scopeTouched = scopeOfWork.length > 0;
|
||||
const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
|
||||
|
||||
const isValid =
|
||||
form.workOrderNumber.trim() &&
|
||||
form.customerName.trim() &&
|
||||
form.customerAddress.trim() &&
|
||||
form.scopeOfWork.trim().length >= MIN_SCOPE_LENGTH &&
|
||||
form.serviceCategory &&
|
||||
(form.serviceCategory !== 'Other' || customCategory.trim());
|
||||
workOrderNumber.trim() &&
|
||||
customerName.trim() &&
|
||||
customerAddress.trim() &&
|
||||
scopeOfWork.trim().length >= MIN_SCOPE_LENGTH &&
|
||||
serviceCategory &&
|
||||
(serviceCategory !== 'Other' || customCategory.trim());
|
||||
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: string[] = [];
|
||||
if (!form.workOrderNumber.trim()) missing.push('Work Order Number');
|
||||
if (!form.customerAddress.trim()) missing.push('Site address');
|
||||
if (!form.serviceCategory) missing.push('Service Category');
|
||||
if (form.serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
|
||||
if (!workOrderNumber.trim()) missing.push('Work Order Number');
|
||||
if (!customerAddress.trim()) missing.push('Site address');
|
||||
if (!serviceCategory) missing.push('Service Category');
|
||||
if (serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
|
||||
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
|
||||
if (!form.scopeOfWork.trim()) missing.push('Scope of Work');
|
||||
else if (form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`);
|
||||
if (!scopeOfWork.trim()) missing.push('Scope of Work');
|
||||
else if (scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`);
|
||||
return missing;
|
||||
}, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]);
|
||||
}, [workOrderNumber, customerAddress, serviceCategory, scopeOfWork, customCategory]);
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||
|
|
@ -205,7 +150,7 @@ export default function ProposalFormPage() {
|
|||
New Proposal Request
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
|
||||
|
|
@ -215,16 +160,14 @@ export default function ProposalFormPage() {
|
|||
label="Work Order Number"
|
||||
required
|
||||
fullWidth
|
||||
value={form.workOrderNumber}
|
||||
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
|
||||
{...register('workOrderNumber')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<TextField
|
||||
label="Purchase Order Number"
|
||||
fullWidth
|
||||
value={form.poNumber ?? ''}
|
||||
onChange={(e) => handleChange('poNumber', e.target.value)}
|
||||
{...register('poNumber')}
|
||||
/>
|
||||
</Grid>
|
||||
{/* PR4: freeSolo customer search — dispatchers can pick an existing
|
||||
|
|
@ -239,18 +182,18 @@ export default function ProposalFormPage() {
|
|||
onInputChange={(_, value, reason) => {
|
||||
if (reason === 'input') {
|
||||
setCustomerInputValue(value);
|
||||
handleChange('customerName', value);
|
||||
setValue('customerName', value);
|
||||
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
|
||||
customerDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
|
||||
customerDebounceRef.current = setTimeout(() => setCustomerQuery(value), 300);
|
||||
} else if (reason === 'clear') {
|
||||
setCustomerInputValue('');
|
||||
handleChange('customerName', '');
|
||||
setCustomerOptions([]);
|
||||
setValue('customerName', '');
|
||||
setCustomerQuery('');
|
||||
}
|
||||
}}
|
||||
onChange={(_, value) => {
|
||||
const selected = typeof value === 'string' ? value : value ?? '';
|
||||
handleChange('customerName', selected);
|
||||
setValue('customerName', selected);
|
||||
setCustomerInputValue(selected);
|
||||
}}
|
||||
noOptionsText={
|
||||
|
|
@ -283,20 +226,26 @@ export default function ProposalFormPage() {
|
|||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<TextField
|
||||
label="Priority"
|
||||
required
|
||||
fullWidth
|
||||
select
|
||||
value={form.priority}
|
||||
onChange={(e) => handleChange('priority', e.target.value)}
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{PRIORITY_LABELS[p] || p}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Controller
|
||||
name="priority"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Priority"
|
||||
required
|
||||
fullWidth
|
||||
select
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{PRIORITY_LABELS[p] || p}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
|
|
@ -367,7 +316,7 @@ export default function ProposalFormPage() {
|
|||
setManualState('');
|
||||
setManualZip('');
|
||||
setSiteInputValue('');
|
||||
handleChange('customerAddress', '');
|
||||
setValue('customerAddress', '');
|
||||
}}
|
||||
>
|
||||
Search sites instead
|
||||
|
|
@ -389,7 +338,7 @@ export default function ProposalFormPage() {
|
|||
if (reason === 'input') {
|
||||
setSiteInputValue(value);
|
||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
||||
siteDebounceRef.current = setTimeout(() => searchSites(value), 300);
|
||||
siteDebounceRef.current = setTimeout(() => setSiteQuery(value), 300);
|
||||
}
|
||||
}}
|
||||
onChange={(_, value) => {
|
||||
|
|
@ -397,10 +346,10 @@ export default function ProposalFormPage() {
|
|||
const display = value.fullAddress
|
||||
? `${value.siteCode} — ${value.fullAddress}`
|
||||
: value.siteCode;
|
||||
handleChange('customerAddress', display);
|
||||
setValue('customerAddress', display);
|
||||
setSiteInputValue(display);
|
||||
} else {
|
||||
handleChange('customerAddress', '');
|
||||
setValue('customerAddress', '');
|
||||
setSiteInputValue('');
|
||||
}
|
||||
}}
|
||||
|
|
@ -450,30 +399,35 @@ export default function ProposalFormPage() {
|
|||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
label="Service Category"
|
||||
required
|
||||
fullWidth
|
||||
select
|
||||
value={form.serviceCategory}
|
||||
onChange={(e) => handleChange('serviceCategory', e.target.value)}
|
||||
>
|
||||
{SERVICE_CATEGORIES.map((cat) => (
|
||||
<MenuItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Controller
|
||||
name="serviceCategory"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Service Category"
|
||||
required
|
||||
fullWidth
|
||||
select
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
>
|
||||
{SERVICE_CATEGORIES.map((cat) => (
|
||||
<MenuItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{form.serviceCategory === 'Other' ? (
|
||||
{serviceCategory === 'Other' ? (
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<TextField
|
||||
label="Specify Category"
|
||||
required
|
||||
fullWidth
|
||||
value={customCategory}
|
||||
onChange={(e) => setCustomCategory(e.target.value)}
|
||||
{...register('customCategory')}
|
||||
placeholder="e.g. Fire Protection"
|
||||
/>
|
||||
</Grid>
|
||||
|
|
@ -489,13 +443,12 @@ export default function ProposalFormPage() {
|
|||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
value={form.scopeOfWork}
|
||||
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
|
||||
{...register('scopeOfWork')}
|
||||
placeholder="Describe the work needed..."
|
||||
error={scopeTooShort}
|
||||
helperText={
|
||||
scopeTooShort
|
||||
? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${form.scopeOfWork.trim().length})`
|
||||
? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${scopeOfWork.trim().length})`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -507,8 +460,7 @@ export default function ProposalFormPage() {
|
|||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => handleChange('notes', e.target.value)}
|
||||
{...register('notes')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
|
|
@ -19,28 +20,45 @@ import {
|
|||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
||||
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
||||
import { STATUS_CHIP_STYLES } from '../../../constants';
|
||||
import { useProposals } from '../../../domain/proposals/use-cases';
|
||||
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
|
||||
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
|
||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||
|
||||
export default function ProposalListPage() {
|
||||
useDocumentTitle('My Proposals');
|
||||
const navigate = useNavigate();
|
||||
const mineParams = { mine: true };
|
||||
const {
|
||||
rows,
|
||||
search,
|
||||
setSearch,
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [page, setPage] = useState(DEFAULT_PAGE);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Debounced search resets to page 1 (mirrors usePaginatedList).
|
||||
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 { data, isLoading, isPlaceholderData, error } = useProposals({
|
||||
search: debouncedSearch,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
totalCount,
|
||||
loading,
|
||||
err,
|
||||
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
|
||||
mine: true,
|
||||
});
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
// Skeletons on first load and while fetching a new page/search (placeholder rows shown).
|
||||
const loading = isLoading || isPlaceholderData;
|
||||
const err = error ? error.message || 'Failed to load' : '';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -127,7 +145,7 @@ export default function ProposalListPage() {
|
|||
<TableCell align="right">
|
||||
{row.totalBidAmount > 0
|
||||
? formatBidAmount(row.totalBidAmount)
|
||||
: <Typography variant="body2" component="span" sx={{ color: '#64748B' }}>Not priced</Typography>
|
||||
: <Typography variant="body2" component="span" sx={{ color: 'var(--color-text-muted)' }}>Not priced</Typography>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue