From 600f5e8101a857c8682212bf5a9d99c3bf150aa1 Mon Sep 17 00:00:00 2001 From: "sayegh.st@northeastern.edu" Date: Wed, 25 Mar 2026 18:34:44 -0400 Subject: [PATCH 1/4] 4027-split-shipping --- .../ReimbursementFormView.tsx | 54 ++++++++++++++++++- .../ReimbursementProductTable.tsx | 13 ++++- .../ReimbursementRequestForm.tsx | 51 ++++++++++++++++-- 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx index 0abf5f997a..9aa0a27ada 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx @@ -86,6 +86,7 @@ interface ReimbursementRequestFormViewProps { isLeadershipApproved?: boolean; onSubmitToFinance?: (data: ReimbursementRequestFormInput) => void; isSubmitting?: boolean; + applySplitShippingToProducts: (totalShipping?: number) => void; } const ReimbursementRequestFormView: React.FC = ({ @@ -112,7 +113,8 @@ const ReimbursementRequestFormView: React.FC isEditing = false, isLeadershipApproved = false, onSubmitToFinance, - isSubmitting = false + isSubmitting = false, + applySplitShippingToProducts }) => { const [datePickerOpen, setDatePickerOpen] = useState(false); const [showAddRefundSourceModal, setShowAddRefundSourceModal] = useState(false); @@ -682,6 +684,55 @@ const ReimbursementRequestFormView: React.FC {errors.accountCodeId?.message} + {/* Total Shipping */} + + + Total Shipping + + + ( + { + onChange(e); + }} + onBlur={() => applySplitShippingToProducts(Number(value))} + placeholder="Enter total shipping cost" + type="number" + inputProps={{ min: 0, step: 0.01 }} + size="small" + fullWidth + sx={{ + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + margin: 0 + }, + '& input[type=number]': { + MozAppearance: 'textfield' + } + }} + /> + )} + /> + + {errors.splitShipping?.message} + + {/* Upload Receipts */} firstRefundSourceName={firstRefundSource.name} secondRefundSourceName={secondRefundSource.name} allProjects={allProjects} + applySplitShippingToProducts={applySplitShippingToProducts} /> {errors.reimbursementProducts?.message} diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx index 0a65ff2ca0..165733e19e 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx @@ -61,6 +61,7 @@ interface ReimbursementProductTableProps { firstRefundSourceName?: string; secondRefundSourceName?: string; allProjects: ProjectPreview[]; + applySplitShippingToProducts: (totalShipping?: number) => void; } const ListItem = styled('li')(({ theme }) => ({ @@ -156,7 +157,8 @@ const ReimbursementProductTable: React.FC = ({ firstRefundSourceName, secondRefundSourceName, watch, - allProjects + allProjects, + applySplitShippingToProducts }) => { const uniqueWbsElementsWithProducts = new Map< string, @@ -181,6 +183,7 @@ const ReimbursementProductTable: React.FC = ({ setValue(`reimbursementProducts.${index}.refundSources`, [{ indexCode: firstRefundSourceIndexCode, amount: value }]); } }; + const totalShipping = watch('splitShipping'); const userTheme = useTheme(); const hoverColor = userTheme.palette.action.hover; @@ -409,6 +412,7 @@ const ReimbursementProductTable: React.FC = ({ cost: 0, refundSources: [] }); + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); } }} value={null} @@ -434,6 +438,7 @@ const ReimbursementProductTable: React.FC = ({ cost: 0, refundSources: [] }); + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); } }} value={null} @@ -805,7 +810,10 @@ const ReimbursementProductTable: React.FC = ({ backgroundColor: hoverColor } }} - onClick={() => removeProduct(product.index)} + onClick={() => { + removeProduct(product.index); + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); + }} > @@ -840,6 +848,7 @@ const ReimbursementProductTable: React.FC = ({ cost: 0, refundSources: [] }); + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); } e.currentTarget.blur(); }} diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx index f6e0dfa229..251958d535 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx @@ -35,6 +35,7 @@ export interface ReimbursementRequestInformation { } export interface ReimbursementRequestFormInput extends ReimbursementRequestInformation { reimbursementProducts: ReimbursementProductFormArgs[]; + splitShipping?: number; } export interface ReimbursementRequestDataSubmission extends ReimbursementRequestInformation { @@ -57,6 +58,13 @@ const RECEIPTS_REQUIRED = import.meta.env.VITE_RR_RECEIPT_REQUIREMENT || 'disabl const schema = yup.object().shape({ vendorId: yup.string().required('Vendor is required'), + splitShipping: yup + .number() + .transform((value, originalValue) => { + return originalValue === '' || originalValue === undefined ? undefined : value; + }) + .optional() + .min(0.01, 'Split shipping must be greater than 0'), indexCodeId: yup.string().required('Refund source is required'), secondaryAccount: yup.string().test('required-if-split', 'Second refund source is required', function (value) { if (!this.parent.$hasConfirmedFinance) return true; @@ -163,7 +171,8 @@ const ReimbursementRequestForm: React.FC = ({ accountCodeId: defaultValues?.accountCodeId ?? '', description: defaultValues?.description?.trim() || '', reimbursementProducts: defaultValues?.reimbursementProducts ?? ([] as ReimbursementProductFormArgs[]), - receiptFiles: defaultValues?.receiptFiles ?? ([] as ReimbursementReceiptUploadArgs[]) + receiptFiles: defaultValues?.receiptFiles ?? ([] as ReimbursementReceiptUploadArgs[]), + splitShipping: defaultValues?.splitShipping ?? undefined } }); @@ -181,12 +190,45 @@ const ReimbursementRequestForm: React.FC = ({ const { fields: reimbursementProducts, append: reimbursementProductAppend, - remove: reimbursementProductRemove + remove: reimbursementProductRemove, + replace: reimbursementProductReplace } = useFieldArray({ control, name: 'reimbursementProducts' }); + const applySplitShippingToProducts = (totalShipping?: number) => { + const currentProducts = watch('reimbursementProducts') ?? []; + + const nonShippingProducts = currentProducts.filter((product) => product.name !== 'Split Shipping'); + + if (!totalShipping || totalShipping <= 0 || nonShippingProducts.length === 0) { + reimbursementProductReplace(nonShippingProducts); + return; + } + + const totalShippingCents = Math.round(totalShipping * 100); + const baseShippingCents = Math.floor(totalShippingCents / nonShippingProducts.length); + const remainderCents = totalShippingCents % nonShippingProducts.length; + + const updatedProducts: ReimbursementProductFormArgs[] = []; + + nonShippingProducts.forEach((product, index) => { + updatedProducts.push(product); + + const shippingCents = baseShippingCents + (index < remainderCents ? 1 : 0); + + updatedProducts.push({ + name: 'Split Shipping', + reason: product.reason, + cost: shippingCents / 100, + refundSources: [] + }); + }); + + reimbursementProductReplace(updatedProducts); + }; + const { isLoading: allVendorsIsLoading, isError: allVendorsIsError, @@ -230,13 +272,11 @@ const ReimbursementRequestForm: React.FC = ({ checkSecureSettingsIsLoading ) return ; + const onSubmitWrapper = async (data: ReimbursementRequestFormInput) => { try { //total cost, firstSourceAmount and secondSourceAmount is tracked in cents const totalCost = Math.round(data.reimbursementProducts.reduce((acc, curr) => acc + curr.cost, 0) * 100); - // For each product, if multiple refund sources are enabled, the `cost` field represents - // the total amount from the first refund source amount (firstSourceAmount) and second refund source (secondSourceAmount) of that product. - // If only one refund source is present, the `cost` reflects the refund source amount for that product, and firstSourceAmount and secondSourceAmount are left as 0 since they will not needed for this scenario. const reimbursementProducts = data.reimbursementProducts.map((product: ReimbursementProductFormArgs) => { const anyNonZero = product.refundSources.some((rs) => Number(rs.amount) > 0); @@ -371,6 +411,7 @@ const ReimbursementRequestForm: React.FC = ({ isLeadershipApproved={isLeadershipApproved} onSubmitToFinance={onSubmitToFinanceWrapper} isSubmitting={isSubmitting} + applySplitShippingToProducts={applySplitShippingToProducts} /> ); }; From 22c517c103b1b96d89b1453a334f0aa2614aaba4 Mon Sep 17 00:00:00 2001 From: "sayegh.st@northeastern.edu" Date: Wed, 25 Mar 2026 19:13:56 -0400 Subject: [PATCH 2/4] #4027 merge conflict fix --- .../ReimbursementRequestForm/ReimbursementRequestForm.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx index 4b343c1919..9d697ea863 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx @@ -189,10 +189,9 @@ const ReimbursementRequestForm: React.FC = ({ }); const { fields: reimbursementProducts, - append: reimbursementProductAppend, + prepend: reimbursementProductPrepend, remove: reimbursementProductRemove, - replace: reimbursementProductReplace, - prepend: reimbursementProductPrepend + replace: reimbursementProductReplace } = useFieldArray({ control, name: 'reimbursementProducts' From fb24670c5f477c615054984efb8172927c1a11b0 Mon Sep 17 00:00:00 2001 From: "sayegh.st@northeastern.edu" Date: Wed, 25 Mar 2026 22:45:35 -0400 Subject: [PATCH 3/4] #4027 making changes --- .../ReimbursementFormView.tsx | 97 ++++++++++--------- .../ReimbursementProductTable.tsx | 7 +- .../ReimbursementRequestForm.tsx | 28 +++++- 3 files changed, 78 insertions(+), 54 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx index 5be2fe43b8..e3f685e866 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx @@ -684,54 +684,7 @@ const ReimbursementRequestFormView: React.FC {errors.accountCodeId?.message} - {/* Total Shipping */} - - - Total Shipping - - - ( - { - onChange(e); - }} - onBlur={() => applySplitShippingToProducts(Number(value))} - placeholder="Enter total shipping cost" - type="number" - inputProps={{ min: 0, step: 0.01 }} - size="small" - fullWidth - sx={{ - '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { - WebkitAppearance: 'none', - margin: 0 - }, - '& input[type=number]': { - MozAppearance: 'textfield' - } - }} - /> - )} - /> - - {errors.splitShipping?.message} - + {/* Upload Receipts */} @@ -939,6 +892,54 @@ const ReimbursementRequestFormView: React.FC )} /> + {/* Total Shipping */} + + + Total Shipping + + + ( + { + onChange(e); + }} + onBlur={() => applySplitShippingToProducts(Number(value))} + placeholder="Enter total shipping cost" + type="number" + inputProps={{ min: 0, step: 0.01 }} + size="small" + fullWidth + sx={{ + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + margin: 0 + }, + '& input[type=number]': { + MozAppearance: 'textfield' + } + }} + /> + )} + /> + + {errors.splitShipping?.message} + diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx index 51ade99ece..c3010bd754 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementProductTable.tsx @@ -811,8 +811,13 @@ const ReimbursementProductTable: React.FC = ({ } }} onClick={() => { + const isShippingProduct = product.name === 'Split Shipping'; + removeProduct(product.index); - setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); + + if (!isShippingProduct) { + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); + } }} > diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx index 9d697ea863..9e99d4763b 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementRequestForm.tsx @@ -207,20 +207,37 @@ const ReimbursementRequestForm: React.FC = ({ return; } + const groupedProducts = new Map(); + + nonShippingProducts.forEach((product) => { + const key = + 'otherProductReasonId' in product.reason + ? `other-${product.reason.otherProductReasonId}` + : `wbs-${product.reason.carNumber}-${product.reason.projectNumber}`; + + if (!groupedProducts.has(key)) { + groupedProducts.set(key, []); + } + + groupedProducts.get(key)!.push(product); + }); + + const groupedEntries = Array.from(groupedProducts.values()); + const totalShippingCents = Math.round(totalShipping * 100); - const baseShippingCents = Math.floor(totalShippingCents / nonShippingProducts.length); - const remainderCents = totalShippingCents % nonShippingProducts.length; + const baseShippingCents = Math.floor(totalShippingCents / groupedEntries.length); + const remainderCents = totalShippingCents % groupedEntries.length; const updatedProducts: ReimbursementProductFormArgs[] = []; - nonShippingProducts.forEach((product, index) => { - updatedProducts.push(product); + groupedEntries.forEach((productsInGroup, index) => { + productsInGroup.forEach((product) => updatedProducts.push(product)); const shippingCents = baseShippingCents + (index < remainderCents ? 1 : 0); updatedProducts.push({ name: 'Split Shipping', - reason: product.reason, + reason: productsInGroup[0].reason, cost: shippingCents / 100, refundSources: [] }); @@ -229,6 +246,7 @@ const ReimbursementRequestForm: React.FC = ({ reimbursementProductReplace(updatedProducts); }; + const { isLoading: allVendorsIsLoading, isError: allVendorsIsError, From 48ab7a26e95331b937b013ae3fce703ecd58d1e9 Mon Sep 17 00:00:00 2001 From: "sayegh.st@northeastern.edu" Date: Wed, 25 Mar 2026 22:54:07 -0400 Subject: [PATCH 4/4] #4027 prettier fix --- .../ReimbursementRequestForm/ReimbursementFormView.tsx | 2 -- .../ReimbursementRequestForm/ReimbursementRequestForm.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx index e3f685e866..d146e83d59 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx @@ -684,8 +684,6 @@ const ReimbursementRequestFormView: React.FC {errors.accountCodeId?.message} - - {/* Upload Receipts */} = ({ reimbursementProductReplace(updatedProducts); }; - const { isLoading: allVendorsIsLoading, isError: allVendorsIsError,