diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx index c21e06e633..d146e83d59 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); @@ -888,6 +890,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} + @@ -913,6 +963,7 @@ const ReimbursementRequestFormView: React.FC 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 6e73428f5d..c3010bd754 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,15 @@ const ReimbursementProductTable: React.FC = ({ backgroundColor: hoverColor } }} - onClick={() => removeProduct(product.index)} + onClick={() => { + const isShippingProduct = product.name === 'Split Shipping'; + + removeProduct(product.index); + + if (!isShippingProduct) { + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); + } + }} > @@ -840,6 +853,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 95c8c82e0a..d498ad6a3b 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,62 @@ const ReimbursementRequestForm: React.FC = ({ const { fields: reimbursementProducts, prepend: reimbursementProductPrepend, - 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 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 / groupedEntries.length); + const remainderCents = totalShippingCents % groupedEntries.length; + + const updatedProducts: ReimbursementProductFormArgs[] = []; + + groupedEntries.forEach((productsInGroup, index) => { + productsInGroup.forEach((product) => updatedProducts.push(product)); + + const shippingCents = baseShippingCents + (index < remainderCents ? 1 : 0); + + updatedProducts.push({ + name: 'Split Shipping', + reason: productsInGroup[0].reason, + cost: shippingCents / 100, + refundSources: [] + }); + }); + + reimbursementProductReplace(updatedProducts); + }; + const { isLoading: allVendorsIsLoading, isError: allVendorsIsError, @@ -230,13 +289,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 +428,7 @@ const ReimbursementRequestForm: React.FC = ({ isLeadershipApproved={isLeadershipApproved} onSubmitToFinance={onSubmitToFinanceWrapper} isSubmitting={isSubmitting} + applySplitShippingToProducts={applySplitShippingToProducts} /> ); };