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/components/forms/newDonationFormModal.tsx b/apps/frontend/src/components/forms/newDonationFormModal.tsx index f4cf7df7b..d2b2fc97c 100644 --- a/apps/frontend/src/components/forms/newDonationFormModal.tsx +++ b/apps/frontend/src/components/forms/newDonationFormModal.tsx @@ -20,6 +20,7 @@ import ApiClient from '@api/apiClient'; import { DayOfWeek, FoodType, + FoodTypes, RecurrenceEnum, RepeatOnState, } from '../../types/types'; @@ -52,6 +53,20 @@ const RECURRENCE_LABELS: Record = { [RecurrenceEnum.YEARLY]: 'Year', }; +// Max 2 decimal places, positive +const isValidDecimal = (val: string): boolean => + val !== '' && /^\d+(\.\d{1,2})?$/.test(val) && parseFloat(val) > 0; + +// Positive integer only +const isValidPositiveInt = (val: string): boolean => + val !== '' && /^\d+$/.test(val) && parseInt(val) > 0; + +const isRequiredFieldsFilled = (rows: DonationRow[]): boolean => + rows.every( + (row) => + row.foodItem.trim() !== '' && row.foodType !== '' && row.numItems !== '', + ); + const NewDonationFormModal: React.FC = ({ onDonationSuccess, isOpen, @@ -85,9 +100,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 +160,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 && @@ -166,6 +171,31 @@ const NewDonationFormModal: React.FC = ({ return; } + // Per-row field validation + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowLabel = rows.length > 1 ? ` (row ${i + 1})` : ''; + + if (!isValidPositiveInt(row.numItems)) { + setAlertMessage(`Quantity${rowLabel} must be a positive whole number.`); + return; + } + + if (row.ozPerItem !== '' && !isValidDecimal(row.ozPerItem)) { + setAlertMessage( + `Oz. per item${rowLabel} must be a positive number with at most 2 decimal places.`, + ); + return; + } + + if (row.valuePerItem !== '' && !isValidDecimal(row.valuePerItem)) { + setAlertMessage( + `Donation value${rowLabel} must be a positive number with at most 2 decimal places.`, + ); + return; + } + } + const donation_body = { foodManufacturerId: 1, recurrenceFreq: isRecurring ? parseInt(repeatEvery) : null, @@ -186,8 +216,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 +249,7 @@ const NewDonationFormModal: React.FC = ({ } }; + const isSubmitDisabled = !isRequiredFieldsFilled(rows); const isRepeatOnDisabled = repeatInterval !== RecurrenceEnum.WEEKLY; const placeholderStyles = { @@ -400,8 +433,14 @@ const NewDonationFormModal: React.FC = ({ handleChange(row.id, 'foodType', e.target.value) } > - {Object.values(FoodType).map((type) => ( - ))} @@ -415,8 +454,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 +466,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 +478,6 @@ const NewDonationFormModal: React.FC = ({ _placeholder={placeholderStyles} color="neutral.800" placeholder="Enter $" - type="number" - min={1} value={row.valuePerItem} onChange={(e) => handleChange( @@ -501,6 +534,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 +557,17 @@ const NewDonationFormModal: React.FC = ({ > {(Object.values(RecurrenceEnum) as RecurrenceEnum[]) .filter((v) => v !== RecurrenceEnum.NONE) - .map((v) => ( - - ))} + .map((v) => + repeatEvery === '1' ? ( + + ) : ( + + ), + )} @@ -616,6 +663,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 +683,7 @@ const NewDonationFormModal: React.FC = ({ fontSize="sm" pointerEvents="none" > - {parseInt(endsAfter) > 1 - ? 'Occurrences' - : 'Occurrence'} + {parseInt(endsAfter) > 1 ? 'Reminders' : 'Reminder'} @@ -641,7 +694,8 @@ const NewDonationFormModal: React.FC = ({ {(repeatInterval !== RecurrenceEnum.WEEKLY || Object.values(repeatOn).some(Boolean)) && ( - Next Donation scheduled for {getNextDonationDateDisplay()} + Next donation reminder scheduled for{' '} + {getNextDonationDateDisplay()} )} @@ -659,9 +713,11 @@ const NewDonationFormModal: React.FC = ({ 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;