diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx index c21e06e633..5d2c63265b 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx @@ -86,6 +86,8 @@ interface ReimbursementRequestFormViewProps { isLeadershipApproved?: boolean; onSubmitToFinance?: (data: ReimbursementRequestFormInput) => void; isSubmitting?: boolean; + applySplitShippingToProducts: (totalShipping?: number) => void; + applyProportionalShippingToProducts: (totalShipping?: number) => void; } const ReimbursementRequestFormView: React.FC = ({ @@ -112,7 +114,9 @@ const ReimbursementRequestFormView: React.FC isEditing = false, isLeadershipApproved = false, onSubmitToFinance, - isSubmitting = false + isSubmitting = false, + applySplitShippingToProducts, + applyProportionalShippingToProducts }) => { const [datePickerOpen, setDatePickerOpen] = useState(false); const [showAddRefundSourceModal, setShowAddRefundSourceModal] = useState(false); @@ -140,14 +144,26 @@ const ReimbursementRequestFormView: React.FC const theme = useTheme(); const products = watch('reimbursementProducts') as ReimbursementProductFormArgs[]; const accountCodeId = watch('accountCodeId'); + const splitShippingValue = watch('splitShipping'); const selectedAccountCode = allAccountCodes.find((accountCode) => accountCode.accountCodeId === accountCodeId); - const indexCodes: IndexCode[] = useMemo(() => selectedAccountCode?.indexCodes ?? [], [selectedAccountCode?.indexCodes]); + const indexCodes: IndexCode[] = useMemo(() => selectedAccountCode?.indexCodes ?? [], [selectedAccountCode]); const firstRefundSourceId = watch('indexCodeId'); const secondRefundSourceId = watch('secondaryAccount'); const hasPreFilledData = useRef(true); + const shippableProducts = products?.filter((product) => !!product.materialId) ?? []; + + const allShippableProductsHaveCosts = + shippableProducts.length > 0 && + shippableProducts.every((product) => { + const baseCost = Number((product as any).__baseCost ?? product.cost ?? 0); + return baseCost > 0; + }); + + const canApplyProportionalSplit = Number(splitShippingValue) > 0 && allShippableProductsHaveCosts; + useEffect(() => { if (!hasPreFilledData.current) return; @@ -233,7 +249,7 @@ const ReimbursementRequestFormView: React.FC const remainingRefundSources = indexCodes.filter((code) => code.indexCodeId !== firstRefundSourceId); const calculatedTotalCost = products - .reduce((acc: number, product: ReimbursementProductFormArgs) => acc + Number(product.cost), 0) + .reduce((acc: number, product: ReimbursementProductFormArgs) => acc + Number(product.cost || 0), 0) .toFixed(2); const { isLoading, isError, error, data: financeDelegates } = useGetFinanceDelegates(); @@ -888,6 +904,71 @@ 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 +994,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..eb18facef0 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, @@ -175,12 +177,19 @@ const ReimbursementProductTable: React.FC = ({ const [pendingMaterialIndices, setPendingMaterialIndices] = useState>(new Set()); const onCostBlurHandler = (value: number, index: number) => { - setValue(`reimbursementProducts.${index}.cost`, parseFloat(value.toFixed(2))); + const roundedValue = parseFloat((value || 0).toFixed(2)); + const shippingCost = Number((watch(`reimbursementProducts.${index}` as const) as any)?.__shippingCost ?? 0); + const totalRowCost = roundedValue + shippingCost; + + setValue(`reimbursementProducts.${index}.cost`, roundedValue); if (firstRefundSourceIndexCode) { - setValue(`reimbursementProducts.${index}.refundSources`, [{ indexCode: firstRefundSourceIndexCode, amount: value }]); + setValue(`reimbursementProducts.${index}.refundSources`, [ + { indexCode: firstRefundSourceIndexCode, amount: totalRowCost } + ]); } }; + const totalShipping = watch('splitShipping'); const userTheme = useTheme(); const hoverColor = userTheme.palette.action.hover; @@ -215,6 +224,7 @@ const ReimbursementProductTable: React.FC = ({ if (hasMultipleRefundSources) { const firstSourceAmount = Number(watch(`reimbursementProducts.${index}.refundSources.${0}.amount`)) || 0; const secondSourceAmount = Number(watch(`reimbursementProducts.${index}.refundSources.${1}.amount`)) || 0; + const shippingCost = Number((watch(`reimbursementProducts.${index}` as const) as any)?.__shippingCost ?? 0); if (firstRefundSourceIndexCode !== undefined) { setValue(`reimbursementProducts.${index}.refundSources.${0}.indexCode`, firstRefundSourceIndexCode); @@ -224,7 +234,7 @@ const ReimbursementProductTable: React.FC = ({ setValue(`reimbursementProducts.${index}.refundSources.${1}.indexCode`, secondRefundSourceIndexCode); } - setValue(`reimbursementProducts.${index}.cost`, firstSourceAmount + secondSourceAmount); + setValue(`reimbursementProducts.${index}.cost`, firstSourceAmount + secondSourceAmount - shippingCost); } }; @@ -409,6 +419,7 @@ const ReimbursementProductTable: React.FC = ({ cost: 0, refundSources: [] }); + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); } }} value={null} @@ -434,6 +445,7 @@ const ReimbursementProductTable: React.FC = ({ cost: 0, refundSources: [] }); + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); } }} value={null} @@ -657,18 +669,40 @@ const ReimbursementProductTable: React.FC = ({ ( - onCostBlurHandler(parseFloat(e.target.value), product.index)} - error={!!errors.reimbursementProducts?.[product.index]?.cost} - /> - )} + render={({ field }) => { + const shippingCost = Number( + (watch(`reimbursementProducts.${product.index}` as const) as any) + ?.__shippingCost ?? 0 + ); + const rowTotal = Number(field.value || 0) + shippingCost; + + return ( + <> + onCostBlurHandler(parseFloat(e.target.value), product.index)} + error={!!errors.reimbursementProducts?.[product.index]?.cost} + /> + {shippingCost > 0 && ( + + )} + + ); + }} /> {errors.reimbursementProducts?.[product.index]?.cost?.message} @@ -786,6 +820,26 @@ const ReimbursementProductTable: React.FC = ({ )} + + {Number( + (watch(`reimbursementProducts.${product.index}` as const) as any)?.__shippingCost ?? 0 + ) > 0 && ( + + + + )} + = ({ backgroundColor: hoverColor } }} - onClick={() => removeProduct(product.index)} + onClick={() => { + removeProduct(product.index); + setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0); + }} > @@ -840,6 +897,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..ea4aaf9f3f 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,148 @@ const ReimbursementRequestForm: React.FC = ({ const { fields: reimbursementProducts, prepend: reimbursementProductPrepend, - remove: reimbursementProductRemove + remove: reimbursementProductRemove, + replace: reimbursementProductReplace } = useFieldArray({ control, name: 'reimbursementProducts' }); + const getGroupKey = (product: ReimbursementProductFormArgs) => + 'otherProductReasonId' in product.reason + ? `other-${product.reason.otherProductReasonId}` + : `wbs-${product.reason.carNumber}-${product.reason.projectNumber}`; + + const getBaseCost = (product: ReimbursementProductFormArgs) => Number((product as any).__baseCost ?? product.cost ?? 0); + + const getShippingCost = (product: ReimbursementProductFormArgs) => Number((product as any).__shippingCost ?? 0); + + const resetProductCosts = (product: ReimbursementProductFormArgs): ReimbursementProductFormArgs => + ({ + ...product, + __baseCost: getBaseCost(product), + __shippingCost: 0, + cost: getBaseCost(product) + }) as ReimbursementProductFormArgs; + + const allocateEvenly = (totalCents: number, count: number): number[] => { + if (count <= 0) return []; + const base = Math.floor(totalCents / count); + const remainder = totalCents % count; + return Array.from({ length: count }, (_, index) => base + (index < remainder ? 1 : 0)); + }; + + const allocateProportionally = (totalCents: number, weights: number[]): number[] => { + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); + + if (totalCents <= 0 || weights.length === 0) return weights.map(() => 0); + if (totalWeight <= 0) return allocateEvenly(totalCents, weights.length); + + const exactShares = weights.map((weight) => (weight / totalWeight) * totalCents); + const flooredShares = exactShares.map((share) => Math.floor(share)); + let remainingCents = totalCents - flooredShares.reduce((sum, cents) => sum + cents, 0); + + const remainders = exactShares + .map((share, index) => ({ + index, + remainder: share - Math.floor(share) + })) + .sort((a, b) => b.remainder - a.remainder); + + remainders.forEach(({ index }) => { + if (remainingCents > 0) { + flooredShares[index] += 1; + remainingCents -= 1; + } + }); + + return flooredShares; + }; + + const applySplitShippingToProducts = (totalShipping?: number) => { + const currentProducts = watch('reimbursementProducts') ?? []; + const resetProducts = currentProducts.map(resetProductCosts); + + const shippableProducts = resetProducts.filter((product) => !!product.materialId); + + if (!totalShipping || totalShipping <= 0 || shippableProducts.length === 0) { + reimbursementProductReplace(resetProducts); + return; + } + + const groupedProducts = new Map(); + + shippableProducts.forEach((product) => { + const key = getGroupKey(product); + 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 projectShippingAllocations = allocateEvenly(totalShippingCents, groupedEntries.length); + + groupedEntries.forEach((productsInGroup, groupIndex) => { + const materialShippingAllocations = allocateEvenly(projectShippingAllocations[groupIndex], productsInGroup.length); + + productsInGroup.forEach((product, productIndex) => { + const baseCostCents = Math.round(getBaseCost(product) * 100); + product.cost = (baseCostCents + materialShippingAllocations[productIndex]) / 100; + }); + }); + + reimbursementProductReplace(resetProducts); + }; + + const applyProportionalShippingToProducts = (totalShipping?: number) => { + const currentProducts = watch('reimbursementProducts') ?? []; + const resetProducts = currentProducts.map(resetProductCosts); + + const shippableProducts = resetProducts.filter((product) => !!product.materialId); + + if (!totalShipping || totalShipping <= 0 || shippableProducts.length === 0) { + reimbursementProductReplace(resetProducts); + return; + } + + const groupedProducts = new Map(); + + shippableProducts.forEach((product) => { + const key = getGroupKey(product); + 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 projectBaseCosts = groupedEntries.map((productsInGroup) => + productsInGroup.reduce((sum, product) => sum + Math.round(getBaseCost(product) * 100), 0) + ); + + const projectShippingAllocations = allocateProportionally(totalShippingCents, projectBaseCosts); + + groupedEntries.forEach((productsInGroup, groupIndex) => { + const materialBaseCosts = productsInGroup.map((product) => Math.round(getBaseCost(product) * 100)); + const materialShippingAllocations = allocateProportionally(projectShippingAllocations[groupIndex], materialBaseCosts); + + productsInGroup.forEach((product, productIndex) => { + const shippingAmount = materialShippingAllocations[productIndex] / 100; + const baseCost = getBaseCost(product); + + (product as any).__shippingCost = shippingAmount; + product.cost = baseCost + shippingAmount; + }); + }); + + reimbursementProductReplace(resetProducts); + }; + const { isLoading: allVendorsIsLoading, isError: allVendorsIsError, @@ -230,19 +375,22 @@ 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); const formattedRefundSources = anyNonZero ? product.refundSources : []; + const { __baseCost, __shippingCost, ...rest } = product as ReimbursementProductFormArgs & { + __baseCost?: number; + __shippingCost?: number; + }; + return { - ...product, + ...rest, cost: Math.round(product.cost * 100), refundSources: formattedRefundSources.map((rs) => ({ ...rs, @@ -299,8 +447,13 @@ const ReimbursementRequestForm: React.FC = ({ const reimbursementProducts = data.reimbursementProducts.map((product: ReimbursementProductFormArgs) => { const anyNonZero = product.refundSources.some((rs) => Number(rs.amount) > 0); const formattedRefundSources = anyNonZero ? product.refundSources : []; + const { __baseCost, __shippingCost, ...rest } = product as ReimbursementProductFormArgs & { + __baseCost?: number; + __shippingCost?: number; + }; + return { - ...product, + ...rest, cost: Math.round(product.cost * 100), refundSources: formattedRefundSources.map((rs) => ({ ...rs, @@ -371,6 +524,8 @@ const ReimbursementRequestForm: React.FC = ({ isLeadershipApproved={isLeadershipApproved} onSubmitToFinance={onSubmitToFinanceWrapper} isSubmitting={isSubmitting} + applySplitShippingToProducts={applySplitShippingToProducts} + applyProportionalShippingToProducts={applyProportionalShippingToProducts} /> ); };