Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReimbursementRequestFormViewProps> = ({
Expand All @@ -112,7 +114,9 @@ const ReimbursementRequestFormView: React.FC<ReimbursementRequestFormViewProps>
isEditing = false,
isLeadershipApproved = false,
onSubmitToFinance,
isSubmitting = false
isSubmitting = false,
applySplitShippingToProducts,
applyProportionalShippingToProducts
}) => {
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [showAddRefundSourceModal, setShowAddRefundSourceModal] = useState(false);
Expand Down Expand Up @@ -140,14 +144,26 @@ const ReimbursementRequestFormView: React.FC<ReimbursementRequestFormViewProps>
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 nonShippingProducts = products?.filter((product) => product.name !== 'Split Shipping') ?? [];

const allNonShippingProductsHaveCosts =
nonShippingProducts.length > 0 &&
nonShippingProducts.every(
(product) =>
product.cost !== undefined && product.cost !== null && String(product.cost) !== '' && Number(product.cost) > 0
);

const canApplyProportionalSplit = Number(splitShippingValue) > 0 && allNonShippingProductsHaveCosts;

useEffect(() => {
if (!hasPreFilledData.current) return;

Expand Down Expand Up @@ -888,6 +904,71 @@ const ReimbursementRequestFormView: React.FC<ReimbursementRequestFormViewProps>
)}
/>
</FormControl>
{/* Total Shipping */}
<FormControl sx={{ borderRadius: '25px', width: '100%' }}>
<FormLabel
sx={{
color: '#dd524c',
textShadow: '1.5px 0 #dd524c',
letterSpacing: '0.5px',
textDecoration: 'underline',
textUnderlineOffset: '3.5px',
textDecorationThickness: '0.6px',
paddingBottom: '2px',
fontSize: 'x-large',
fontWeight: 'bold'
}}
>
Total Shipping
</FormLabel>

<Controller
name="splitShipping"
control={control}
render={({ field: { onChange, value } }) => (
<>
<TextField
value={value ?? ''}
onChange={(e) => {
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'
}
}}
/>

<Button
variant="outlined"
size="small"
sx={{
mt: 1,
alignSelf: 'flex-start',
width: 'fit-content',
textTransform: 'none'
}}
disabled={!canApplyProportionalSplit}
onClick={() => applyProportionalShippingToProducts(Number(value))}
>
Split proportional to cost
</Button>
</>
)}
/>

<FormHelperText error>{errors.splitShipping?.message}</FormHelperText>
</FormControl>
</Stack>
</Grid>
</Grid>
Expand All @@ -913,6 +994,7 @@ const ReimbursementRequestFormView: React.FC<ReimbursementRequestFormViewProps>
firstRefundSourceName={firstRefundSource.name}
secondRefundSourceName={secondRefundSource.name}
allProjects={allProjects}
applySplitShippingToProducts={applySplitShippingToProducts}
/>
<FormHelperText error>{errors.reimbursementProducts?.message}</FormHelperText>
</FormControl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ interface ReimbursementProductTableProps {
firstRefundSourceName?: string;
secondRefundSourceName?: string;
allProjects: ProjectPreview[];
applySplitShippingToProducts: (totalShipping?: number) => void;
}

const ListItem = styled('li')(({ theme }) => ({
Expand Down Expand Up @@ -156,7 +157,8 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
firstRefundSourceName,
secondRefundSourceName,
watch,
allProjects
allProjects,
applySplitShippingToProducts
}) => {
const uniqueWbsElementsWithProducts = new Map<
string,
Expand All @@ -181,6 +183,7 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
setValue(`reimbursementProducts.${index}.refundSources`, [{ indexCode: firstRefundSourceIndexCode, amount: value }]);
}
};
const totalShipping = watch('splitShipping');

const userTheme = useTheme();
const hoverColor = userTheme.palette.action.hover;
Expand Down Expand Up @@ -409,6 +412,7 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
cost: 0,
refundSources: []
});
setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0);
}
}}
value={null}
Expand All @@ -434,6 +438,7 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
cost: 0,
refundSources: []
});
setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0);
}
}}
value={null}
Expand Down Expand Up @@ -805,7 +810,15 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
backgroundColor: hoverColor
}
}}
onClick={() => removeProduct(product.index)}
onClick={() => {
const isShippingProduct = product.name === 'Split Shipping';

removeProduct(product.index);

if (!isShippingProduct) {
setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0);
}
}}
>
<RemoveCircleOutline />
</IconButton>
Expand Down Expand Up @@ -840,6 +853,7 @@ const ReimbursementProductTable: React.FC<ReimbursementProductTableProps> = ({
cost: 0,
refundSources: []
});
setTimeout(() => applySplitShippingToProducts(Number(totalShipping)), 0);
}
e.currentTarget.blur();
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface ReimbursementRequestInformation {
}
export interface ReimbursementRequestFormInput extends ReimbursementRequestInformation {
reimbursementProducts: ReimbursementProductFormArgs[];
splitShipping?: number;
}

export interface ReimbursementRequestDataSubmission extends ReimbursementRequestInformation {
Expand All @@ -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;
Expand Down Expand Up @@ -163,7 +171,8 @@ const ReimbursementRequestForm: React.FC<ReimbursementRequestFormProps> = ({
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
}
});

Expand All @@ -181,12 +190,136 @@ const ReimbursementRequestForm: React.FC<ReimbursementRequestFormProps> = ({
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<string, ReimbursementProductFormArgs[]>();

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({
Comment thread
Steph375 marked this conversation as resolved.
name: 'Split Shipping',
reason: productsInGroup[0].reason,
cost: shippingCents / 100,
refundSources: []
});
});

reimbursementProductReplace(updatedProducts);
};

const applyProportionalShippingToProducts = (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<string, ReimbursementProductFormArgs[]>();

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 groupCosts = groupedEntries.map((productsInGroup) =>
productsInGroup.reduce((sum, product) => sum + Math.round(Number(product.cost || 0) * 100), 0)
);

const totalCostCents = groupCosts.reduce((sum, cost) => sum + cost, 0);

if (totalCostCents <= 0) {
reimbursementProductReplace(nonShippingProducts);
return;
}

const exactShares = groupCosts.map((groupCost) => (groupCost / totalCostCents) * totalShippingCents);
const flooredShares = exactShares.map((share) => Math.floor(share));
let remainingCents = totalShippingCents - flooredShares.reduce((sum, cents) => sum + cents, 0);

const remainders = exactShares.map((share, index) => ({
index,
remainder: share - Math.floor(share)
}));

remainders.sort((a, b) => b.remainder - a.remainder);

remainders.forEach(({ index }) => {
if (remainingCents > 0) {
flooredShares[index] += 1;
remainingCents -= 1;
}
});

const updatedProducts: ReimbursementProductFormArgs[] = [];

groupedEntries.forEach((productsInGroup, index) => {
productsInGroup.forEach((product) => updatedProducts.push(product));

updatedProducts.push({
name: 'Split Shipping',
reason: productsInGroup[0].reason,
cost: flooredShares[index] / 100,
refundSources: []
});
});

reimbursementProductReplace(updatedProducts);
};

const {
isLoading: allVendorsIsLoading,
isError: allVendorsIsError,
Expand Down Expand Up @@ -230,13 +363,11 @@ const ReimbursementRequestForm: React.FC<ReimbursementRequestFormProps> = ({
checkSecureSettingsIsLoading
)
return <LoadingIndicator />;

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);
Expand Down Expand Up @@ -371,6 +502,8 @@ const ReimbursementRequestForm: React.FC<ReimbursementRequestFormProps> = ({
isLeadershipApproved={isLeadershipApproved}
onSubmitToFinance={onSubmitToFinanceWrapper}
isSubmitting={isSubmitting}
applySplitShippingToProducts={applySplitShippingToProducts}
applyProportionalShippingToProducts={applyProportionalShippingToProducts}
/>
);
};
Expand Down
Loading