Skip to content

Commit d87db92

Browse files
authored
Merge pull request #3977 from Northeastern-Electric-Racing/#3887-Material-Autocomplete
#3887 Material Autocomplete
2 parents b346137 + d4e470f commit d87db92

3 files changed

Lines changed: 172 additions & 59 deletions

File tree

src/frontend/src/pages/FinancePage/EditReimbursementRequest/EditReimbursementRequestRenderedDefaultValues.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const EditReimbursementRequestRenderedDefaultValues: React.FC<{
4747
? (product.reimbursementProductReason as WBSElementData).wbsNum
4848
: (product.reimbursementProductReason as OtherProductReason),
4949
name: product.name,
50+
materialId: product.materialId,
5051
refundSources: product.refundSources,
5152
cost: Number(centsToDollar(product.cost))
5253
})),

src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx

Lines changed: 168 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ import {
2727
ReimbursementProductFormArgs,
2828
IndexCode,
2929
CreateRefundSourceArgs,
30+
Material,
3031
ProjectPreview
3132
} from 'shared';
3233
import { RemoveCircleOutline, AddCircleOutline } from '@mui/icons-material';
3334
import { Control, Controller, FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
3435
import { ReimbursementRequestFormInput } from './ReimbursementRequestForm';
3536
import { useTheme } from '@mui/system';
36-
import { useEffect, useState, useRef } from 'react';
37+
import { useEffect, useState, useRef, useMemo } from 'react';
3738
import { useGetAllOtherProductReason } from '../../../hooks/finance.hooks';
38-
import { useGetAssembliesForWbsElement } from '../../../hooks/bom.hooks';
39+
import { useGetMaterialsForWbsElement, useGetAssembliesForWbsElement } from '../../../hooks/bom.hooks';
3940
import LoadingIndicator from '../../../components/LoadingIndicator';
4041
import ErrorPage from '../../ErrorPage';
4142
import { formatReasonName } from '../../../utils/reimbursement-request.utils';
@@ -66,6 +67,81 @@ const ListItem = styled('li')(({ theme }) => ({
6667
margin: theme.spacing(0.5)
6768
}));
6869

70+
const MaterialAutocomplete: React.FC<{
71+
wbsNum: WbsNumber;
72+
onSelect: (material: Material | null) => void;
73+
initialValue?: string;
74+
}> = ({ wbsNum, onSelect, initialValue }) => {
75+
const { data: materials, isLoading, isError, error } = useGetMaterialsForWbsElement(wbsNum);
76+
const [materialSelected, setMaterialSelected] = useState<{ id: string; label: string } | null>(null);
77+
78+
const materialOptions = useMemo(
79+
() =>
80+
(materials ?? []).map((material) => ({
81+
id: material.materialId,
82+
label: `${material.name} (${material.materialTypeName}): ${material.manufacturerName ?? 'N/A'}, ${material.manufacturerPartNumber ?? 'N/A'}`
83+
})),
84+
[materials]
85+
);
86+
87+
useEffect(() => {
88+
if (!materials || !initialValue) return;
89+
90+
// Fetch pre-existing label
91+
let match = materialOptions.find((o) => o.label === initialValue) ?? null;
92+
93+
// Otherwise fetch new material by name
94+
if (!match) {
95+
const materialByName = materials.find((m) => m.name === initialValue);
96+
if (materialByName) match = materialOptions.find((o) => o.id === materialByName.materialId) ?? null;
97+
}
98+
99+
if (match && match.id !== materialSelected?.id) {
100+
setMaterialSelected(match);
101+
// Update the form value to the formatted label
102+
const fullMaterial = materials.find((m) => m.materialId === match!.id) ?? null;
103+
onSelect(fullMaterial);
104+
}
105+
// eslint-disable-next-line react-hooks/exhaustive-deps
106+
}, [materials, initialValue]);
107+
108+
if (isLoading || !materials) {
109+
return <LoadingIndicator />;
110+
}
111+
112+
if (isError) {
113+
return (
114+
<TextField
115+
variant="outlined"
116+
placeholder="Select Material"
117+
fullWidth
118+
size="small"
119+
error
120+
disabled
121+
helperText={error?.message || 'Failed to load materials'}
122+
/>
123+
);
124+
}
125+
126+
return (
127+
<Autocomplete
128+
sx={{ flex: 1 }}
129+
options={materialOptions}
130+
getOptionLabel={(option) => option.label}
131+
isOptionEqualToValue={(option, value) => option.id === value.id}
132+
onChange={(_, value) => {
133+
setMaterialSelected(value);
134+
const selectedMaterial = value ? (materials.find((m) => m.materialId === value.id) ?? null) : null;
135+
onSelect(selectedMaterial);
136+
}}
137+
value={materialSelected}
138+
blurOnSelect={true}
139+
size={'small'}
140+
renderInput={(params) => <TextField {...params} variant="outlined" placeholder="Select Material" fullWidth />}
141+
/>
142+
);
143+
};
144+
69145
const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
70146
reimbursementProducts,
71147
removeProduct,
@@ -96,6 +172,7 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
96172
const [showCreateMaterialModal, setShowCreateMaterialModal] = useState(false);
97173
const [currentProductIndex, setCurrentProductIndex] = useState<number | null>(null);
98174
const [currentProject, setCurrentProject] = useState<ProjectPreview | null>(null);
175+
const [pendingMaterialIndices, setPendingMaterialIndices] = useState<Set<number>>(new Set());
99176

100177
const {
101178
data: assemblies,
@@ -180,6 +257,7 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
180257
const handleMaterialCreated = (materialName: string) => {
181258
if (currentProductIndex !== null) {
182259
setValue(`reimbursementProducts.${currentProductIndex}.name`, materialName);
260+
setPendingMaterialIndices((prev) => new Set(prev).add(currentProductIndex));
183261
}
184262
handleCloseCreateMaterial();
185263
};
@@ -245,6 +323,7 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
245323
setShowSecondSourceFields(false);
246324
}
247325
}, [secondRefundSourceName]);
326+
248327
const {
249328
data: otherReasons,
250329
isLoading: otherReasonsIsLoading,
@@ -485,66 +564,97 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
485564
width: { xs: '100%', md: 'auto' }
486565
}}
487566
>
488-
<FormControl fullWidth margin="dense" variant="outlined" size="small">
489-
{hasWbsNum ? (
490-
// Project-based product: Show field + "Create New Material" button
491-
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
492-
<Box sx={{ flex: 1 }}>
493-
<Controller
494-
name={`reimbursementProducts.${product.index}.name`}
495-
control={control}
496-
render={({ field }) => (
497-
<TextField
498-
{...field}
499-
variant="outlined"
500-
placeholder={'Product Name/Description'}
501-
autoComplete="off"
502-
fullWidth
503-
error={!!errors.reimbursementProducts?.[product.index]?.name}
504-
/>
505-
)}
506-
/>
507-
<FormHelperText error>
508-
{errors.reimbursementProducts?.[product.index]?.name?.message}
509-
</FormHelperText>
567+
{hasWbsNum ? (
568+
<FormControl fullWidth margin="dense" variant="outlined" size="small">
569+
{watch(`reimbursementProducts.${product.index}.materialId`) ||
570+
!watch(`reimbursementProducts.${product.index}.name`) ||
571+
pendingMaterialIndices.has(product.index) ? (
572+
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
573+
<Box sx={{ flex: 1 }}>
574+
<MaterialAutocomplete
575+
wbsNum={product.reason as WbsNumber}
576+
initialValue={watch(`reimbursementProducts.${product.index}.name`)}
577+
onSelect={(material) => {
578+
if (material) {
579+
const label = `${material.name} (${material.materialTypeName}): ${material.manufacturerName ?? 'N/A'}, ${material.manufacturerPartNumber ?? 'N/A'}`;
580+
setValue(`reimbursementProducts.${product.index}.name`, label, {
581+
shouldValidate: true,
582+
shouldDirty: true
583+
});
584+
setValue(
585+
`reimbursementProducts.${product.index}.materialId`,
586+
material.materialId,
587+
{
588+
shouldValidate: true,
589+
shouldDirty: true
590+
}
591+
);
592+
setPendingMaterialIndices((prev) => {
593+
const next = new Set(prev);
594+
next.delete(product.index);
595+
return next;
596+
});
597+
} else {
598+
setValue(`reimbursementProducts.${product.index}.name`, '', {
599+
shouldValidate: true,
600+
shouldDirty: true
601+
});
602+
setValue(`reimbursementProducts.${product.index}.materialId`, undefined, {
603+
shouldValidate: true,
604+
shouldDirty: true
605+
});
606+
}
607+
}}
608+
/>
609+
</Box>
610+
<Typography fontWeight="bold" sx={{ whiteSpace: 'nowrap' }}>
611+
OR
612+
</Typography>
613+
<Button
614+
variant="outlined"
615+
size="small"
616+
onClick={() =>
617+
handleOpenCreateMaterial(product.index, product.reason as WbsNumber)
618+
}
619+
sx={{ whiteSpace: 'nowrap' }}
620+
>
621+
Create New Material
622+
</Button>
510623
</Box>
511-
<Typography fontWeight="bold" sx={{ whiteSpace: 'nowrap', lineHeight: 1 }}>
512-
OR
513-
</Typography>
514-
<Button
624+
) : (
625+
<TextField
515626
variant="outlined"
627+
value={watch(`reimbursementProducts.${product.index}.name`)}
628+
fullWidth
516629
size="small"
517-
onClick={() =>
518-
handleOpenCreateMaterial(product.index, product.reason as WbsNumber)
519-
}
520-
sx={{ whiteSpace: 'nowrap' }}
521-
>
522-
Create New Material
523-
</Button>
524-
</Box>
525-
) : (
526-
// Other Category: Show only text field
527-
<>
528-
<Controller
529-
name={`reimbursementProducts.${product.index}.name`}
530-
control={control}
531-
render={({ field }) => (
532-
<TextField
533-
{...field}
534-
variant="outlined"
535-
placeholder={'Product Name/Description'}
536-
autoComplete="off"
537-
fullWidth
538-
error={!!errors.reimbursementProducts?.[product.index]?.name}
539-
/>
540-
)}
630+
disabled
541631
/>
542-
<FormHelperText error>
543-
{errors.reimbursementProducts?.[product.index]?.name?.message}
544-
</FormHelperText>
545-
</>
546-
)}
547-
</FormControl>
632+
)}
633+
<FormHelperText error>
634+
{errors.reimbursementProducts?.[product.index]?.name?.message}
635+
</FormHelperText>
636+
</FormControl>
637+
) : (
638+
<FormControl fullWidth margin="dense" variant="outlined" size="small">
639+
<Controller
640+
name={`reimbursementProducts.${product.index}.name`}
641+
control={control}
642+
render={({ field }) => (
643+
<TextField
644+
{...field}
645+
variant="outlined"
646+
placeholder={'Product Name/Description'}
647+
autoComplete="off"
648+
fullWidth
649+
error={!!errors.reimbursementProducts?.[product.index]?.name}
650+
/>
651+
)}
652+
/>
653+
<FormHelperText error>
654+
{errors.reimbursementProducts?.[product.index]?.name?.message}
655+
</FormHelperText>
656+
</FormControl>
657+
)}
548658
</Box>
549659
{!hasMultipleRefundSources && (
550660
<Box

src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const schema = yup.object().shape({
101101
.array()
102102
.of(
103103
yup.object().shape({
104-
name: yup.string().required('Description is required'),
104+
name: yup.string().required('Material / Description is required'),
105105
reason: yup.mixed().required(),
106106
refundSources: yup.array().of(
107107
yup.object({
@@ -264,6 +264,7 @@ const ReimbursementRequestForm: React.FC<ReimbursementRequestFormProps> = ({
264264
reason: product.reason as WbsNumber,
265265
cost: product.cost,
266266
name: product.name,
267+
materialId: product.materialId,
267268
refundSources: product.refundSources
268269
});
269270
}
@@ -321,6 +322,7 @@ const ReimbursementRequestForm: React.FC<ReimbursementRequestFormProps> = ({
321322
reason: product.reason as WbsNumber,
322323
cost: product.cost,
323324
name: product.name,
325+
materialId: product.materialId,
324326
refundSources: product.refundSources
325327
});
326328
}

0 commit comments

Comments
 (0)