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
26 changes: 18 additions & 8 deletions apps/backend/src/donationItems/dtos/create-donation-items.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down
120 changes: 88 additions & 32 deletions apps/frontend/src/components/forms/newDonationFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import ApiClient from '@api/apiClient';
import {
DayOfWeek,
FoodType,
FoodTypes,
RecurrenceEnum,
RepeatOnState,
} from '../../types/types';
Expand Down Expand Up @@ -52,6 +53,20 @@ const RECURRENCE_LABELS: Record<RecurrenceEnum, string> = {
[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<NewDonationFormModalProps> = ({
onDonationSuccess,
isOpen,
Expand Down Expand Up @@ -85,9 +100,6 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
});
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) => {
Expand Down Expand Up @@ -148,15 +160,8 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
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 &&
Expand All @@ -166,6 +171,31 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
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,
Expand All @@ -186,8 +216,10 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
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,
}));
Expand Down Expand Up @@ -217,6 +249,7 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
}
};

const isSubmitDisabled = !isRequiredFieldsFilled(rows);
const isRepeatOnDisabled = repeatInterval !== RecurrenceEnum.WEEKLY;

const placeholderStyles = {
Expand Down Expand Up @@ -400,8 +433,14 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
handleChange(row.id, 'foodType', e.target.value)
}
>
{Object.values(FoodType).map((type) => (
<option key={type} value={type}>
{FoodTypes.map((type) => (
<option
key={type}
value={type}
style={{
color: 'var(--chakra-colors-neutral-800)',
}}
>
{type}
</option>
))}
Expand All @@ -415,8 +454,6 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
_placeholder={placeholderStyles}
color="neutral.800"
placeholder="Enter #"
type="number"
min={1}
value={row.numItems}
onChange={(e) =>
handleChange(row.id, 'numItems', e.target.value)
Expand All @@ -429,8 +466,6 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
_placeholder={placeholderStyles}
color="neutral.800"
placeholder="Enter #"
type="number"
min={1}
value={row.ozPerItem}
onChange={(e) =>
handleChange(row.id, 'ozPerItem', e.target.value)
Expand All @@ -443,8 +478,6 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
_placeholder={placeholderStyles}
color="neutral.800"
placeholder="Enter $"
type="number"
min={1}
value={row.valuePerItem}
onChange={(e) =>
handleChange(
Expand Down Expand Up @@ -501,6 +534,14 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
setRepeatEvery(e.value)
}
min={1}
step={1}
onBlur={() => {
const value = Math.max(
1,
Math.floor(Number(repeatEvery) || 1),
);
setRepeatEvery(String(value));
}}
>
<NumberInput.Input />
<NumberInput.Control />
Expand All @@ -516,11 +557,17 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
>
{(Object.values(RecurrenceEnum) as RecurrenceEnum[])
.filter((v) => v !== RecurrenceEnum.NONE)
.map((v) => (
<option key={v} value={v}>
{RECURRENCE_LABELS[v]}
</option>
))}
.map((v) =>
repeatEvery === '1' ? (
<option key={v} value={v}>
{RECURRENCE_LABELS[v]}
</option>
) : (
<option key={v} value={v}>
{RECURRENCE_LABELS[v]}s
</option>
),
)}
</NativeSelect.Field>
<NativeSelectIndicator />
</NativeSelect.Root>
Expand Down Expand Up @@ -616,6 +663,14 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
setEndsAfter(e.value)
}
min={1}
step={1}
onBlur={() => {
const value = Math.max(
1,
Math.floor(Number(endsAfter) || 1),
);
setEndsAfter(String(value));
}}
>
<Flex position="relative" align="center">
<NumberInput.Input pl={4} pr="140px" fontSize="sm" />
Expand All @@ -628,9 +683,7 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
fontSize="sm"
pointerEvents="none"
>
{parseInt(endsAfter) > 1
? 'Occurrences'
: 'Occurrence'}
{parseInt(endsAfter) > 1 ? 'Reminders' : 'Reminder'}
</Text>
<NumberInput.Control />
</Flex>
Expand All @@ -641,7 +694,8 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
{(repeatInterval !== RecurrenceEnum.WEEKLY ||
Object.values(repeatOn).some(Boolean)) && (
<Text color="neutral.700" fontStyle="italic" mt={2}>
Next Donation scheduled for {getNextDonationDateDisplay()}
Next donation reminder scheduled for{' '}
{getNextDonationDateDisplay()}
</Text>
)}
</Box>
Expand All @@ -659,9 +713,11 @@ const NewDonationFormModal: React.FC<NewDonationFormModalProps> = ({
</Button>
<Button
backgroundColor="blue.ssf"
onClick={handleSubmit}
onClick={validateAndSubmit}
size="md"
fontWeight={600}
disabled={isSubmitDisabled}
_disabled={{ opacity: 0.4, cursor: 'not-allowed' }}
>
Submit Donation
</Button>
Expand Down
17 changes: 17 additions & 0 deletions apps/frontend/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading