diff --git a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts b/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts index 0e4b04b39..6c14a688d 100644 --- a/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts +++ b/apps/backend/src/donationItems/dtos/create-donation-items.dto.ts @@ -8,8 +8,9 @@ import { IsNotEmpty, Length, IsOptional, + IsInt, } from 'class-validator'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { FoodType } from '../types'; export class CreateDonationItemDto { @@ -18,21 +19,30 @@ export class CreateDonationItemDto { @Length(1, 255) itemName!: string; - @IsNumber() - @Min(1) + @Transform(({ value }) => parseInt(value, 10)) + @IsInt({ message: 'Quantity must be an integer value' }) + @Min(1, { message: 'Quantity must be at least 1' }) quantity!: number; - @IsNumber() + @IsInt() @Min(0) reservedQuantity!: number; - @IsNumber() - @Min(0.01) + @Transform(({ value }) => parseFloat(value)) + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'Oz per item must have at most 2 decimal places' }, + ) + @Min(0.01, { message: 'Oz per item must be at least 0.01' }) @IsOptional() ozPerItem?: number; - @IsNumber() - @Min(0.01) + @Transform(({ value }) => parseFloat(value)) + @IsNumber( + { maxDecimalPlaces: 2 }, + { message: 'Estimated value must have at most 2 decimal places' }, + ) + @Min(0.01, { message: 'Estimated value must be at least 0.01' }) @IsOptional() estimatedValue?: number; diff --git a/apps/frontend/src/chakra-ui.d.ts b/apps/frontend/src/chakra-ui.d.ts index d4db3faea..642decc6e 100644 --- a/apps/frontend/src/chakra-ui.d.ts +++ b/apps/frontend/src/chakra-ui.d.ts @@ -74,6 +74,12 @@ declare module '@chakra-ui/react' { export interface NativeSelectFieldProps extends ComponentPropsLenientChildren {} + // Tooltip components + export interface TooltipTriggerProps extends ComponentPropsLenientChildren {} + export interface TooltipPositionerProps + extends ComponentPropsLenientChildren {} + export interface TooltipContentProps extends ComponentPropsLenientChildren {} + // Common components export interface ButtonProps extends ComponentPropsStrictChildren {} export interface IconButtonProps extends ComponentPropsStrictChildren {} diff --git a/apps/frontend/src/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index f4cf7df7b..8f412088b 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -14,12 +14,14 @@ import { Checkbox, Menu, NumberInput, + Tooltip, } from '@chakra-ui/react'; import { useState } from 'react'; import ApiClient from '@api/apiClient'; import { DayOfWeek, FoodType, + FoodTypes, RecurrenceEnum, RepeatOnState, } from '../../types/types'; @@ -52,6 +54,53 @@ const RECURRENCE_LABELS: Record = { [RecurrenceEnum.YEARLY]: 'Year', }; +// Ensure valid decimals and positive integers for input validation +const isValidDecimal = (val: string): boolean => + val !== '' && /^\d+(\.\d{1,2})?$/.test(val) && parseFloat(val) > 0; +const isValidPositiveInt = (val: string): boolean => + val !== '' && /^\d+$/.test(val) && parseInt(val) > 0; + +// Displays appropriate tooltip if necessary +const getFirstValidationError = ( + rows: DonationRow[], + isRecurring: boolean, + repeatEvery: string, + endsAfter: string, +): string | null => { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowLabel = rows.length > 1 ? ` (row ${i + 1})` : ''; + + if (row.foodItem.trim() === '') { + return `Food item${rowLabel} is required.`; + } + if (row.foodType === '') { + return `Food type${rowLabel} is required.`; + } + if (row.numItems === '') { + return `Quantity${rowLabel} is required.`; + } + if (!isValidPositiveInt(row.numItems)) { + return `Quantity${rowLabel} must be a positive whole number.`; + } + if (row.ozPerItem !== '' && !isValidDecimal(row.ozPerItem)) { + return `Oz. per item${rowLabel} must be a positive number with at most 2 decimal places.`; + } + if (row.valuePerItem !== '' && !isValidDecimal(row.valuePerItem)) { + return `Donation value${rowLabel} must be a positive number with at most 2 decimal places.`; + } + } + if (isRecurring) { + if (!isValidPositiveInt(repeatEvery)) { + return 'Repeat every must be a positive whole number.'; + } + if (!isValidPositiveInt(endsAfter)) { + return 'Ends after must be a positive whole number.'; + } + } + return null; +}; + const NewDonationFormModal: React.FC = ({ onDonationSuccess, isOpen, @@ -85,9 +134,6 @@ const NewDonationFormModal: React.FC = ({ }); const [endsAfter, setEndsAfter] = useState('1'); - const [totalItems, setTotalItems] = useState(0); - const [totalOz, setTotalOz] = useState(0); - const [totalValue, setTotalValue] = useState(0); const [alertState, setAlertMessage] = useAlert(); const handleChange = (id: number, field: string, value: string | boolean) => { @@ -148,15 +194,8 @@ const NewDonationFormModal: React.FC = ({ return `${selected.slice(0, 4).join(', ')} + ${selected.length - 4}`; }; - const handleSubmit = async () => { - const hasEmpty = rows.some( - (row) => !row.foodItem || !row.foodType || !row.numItems, - ); - if (hasEmpty) { - setAlertMessage('Please fill in all fields before submitting.'); - return; - } - + const validateAndSubmit = async () => { + // Recurring: weekly day selection if ( isRecurring && repeatInterval === RecurrenceEnum.WEEKLY && @@ -186,8 +225,10 @@ const NewDonationFormModal: React.FC = ({ itemName: row.foodItem, quantity: parseInt(row.numItems), reservedQuantity: 0, - ozPerItem: parseFloat(row.ozPerItem), - estimatedValue: parseFloat(row.valuePerItem), + ozPerItem: + row.ozPerItem !== '' ? parseFloat(row.ozPerItem) : undefined, + estimatedValue: + row.valuePerItem !== '' ? parseFloat(row.valuePerItem) : undefined, foodType: row.foodType as FoodType, foodRescue: row.foodRescue, })); @@ -217,6 +258,13 @@ const NewDonationFormModal: React.FC = ({ } }; + const firstValidationError = getFirstValidationError( + rows, + isRecurring, + repeatEvery, + endsAfter, + ); + const isSubmitDisabled = firstValidationError !== null; const isRepeatOnDisabled = repeatInterval !== RecurrenceEnum.WEEKLY; const placeholderStyles = { @@ -400,8 +448,14 @@ const NewDonationFormModal: React.FC = ({ handleChange(row.id, 'foodType', e.target.value) } > - {Object.values(FoodType).map((type) => ( - ))} @@ -415,8 +469,6 @@ const NewDonationFormModal: React.FC = ({ _placeholder={placeholderStyles} color="neutral.800" placeholder="Enter #" - type="number" - min={1} value={row.numItems} onChange={(e) => handleChange(row.id, 'numItems', e.target.value) @@ -429,8 +481,6 @@ const NewDonationFormModal: React.FC = ({ _placeholder={placeholderStyles} color="neutral.800" placeholder="Enter #" - type="number" - min={1} value={row.ozPerItem} onChange={(e) => handleChange(row.id, 'ozPerItem', e.target.value) @@ -443,8 +493,6 @@ const NewDonationFormModal: React.FC = ({ _placeholder={placeholderStyles} color="neutral.800" placeholder="Enter $" - type="number" - min={1} value={row.valuePerItem} onChange={(e) => handleChange( @@ -501,6 +549,14 @@ const NewDonationFormModal: React.FC = ({ setRepeatEvery(e.value) } min={1} + step={1} + onBlur={() => { + const value = Math.max( + 1, + Math.floor(Number(repeatEvery) || 1), + ); + setRepeatEvery(String(value)); + }} > @@ -516,11 +572,17 @@ const NewDonationFormModal: React.FC = ({ > {(Object.values(RecurrenceEnum) as RecurrenceEnum[]) .filter((v) => v !== RecurrenceEnum.NONE) - .map((v) => ( - - ))} + .map((v) => + repeatEvery === '1' ? ( + + ) : ( + + ), + )} @@ -616,6 +678,14 @@ const NewDonationFormModal: React.FC = ({ setEndsAfter(e.value) } min={1} + step={1} + onBlur={() => { + const value = Math.max( + 1, + Math.floor(Number(endsAfter) || 1), + ); + setEndsAfter(String(value)); + }} > @@ -628,9 +698,7 @@ const NewDonationFormModal: React.FC = ({ fontSize="sm" pointerEvents="none" > - {parseInt(endsAfter) > 1 - ? 'Occurrences' - : 'Occurrence'} + {parseInt(endsAfter) > 1 ? 'Reminders' : 'Reminder'} @@ -641,13 +709,20 @@ const NewDonationFormModal: React.FC = ({ {(repeatInterval !== RecurrenceEnum.WEEKLY || Object.values(repeatOn).some(Boolean)) && ( - Next Donation scheduled for {getNextDonationDateDisplay()} + Next donation reminder scheduled for{' '} + {getNextDonationDateDisplay()} )} )} - + - + + + + + + + + + + {firstValidationError ?? ''} + + + + diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 40a9ff216..dd218fe08 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -160,6 +160,23 @@ export enum FoodType { QUINOA = 'Quinoa', } +export const FoodTypes = [ + 'Dairy-Free Alternatives', + 'Dried Beans (Gluten-Free, Nut-Free)', + 'Gluten-Free Baking/Pancake Mixes', + 'Gluten-Free Bread', + 'Gluten-Free Tortillas', + 'Granola', + 'Masa Harina Flour', + 'Nut-Free Granola Bars', + 'Olive Oil', + 'Refrigerated Meals', + 'Rice Noodles', + 'Seed Butters (Peanut Butter Alternative)', + 'Whole-Grain Cookies', + 'Quinoa', +] as const; + export interface User { id: number; role: string;