mirror of
https://github.com/siwa-net/my-finance-pal.git
synced 2024-11-10 00:51:56 +01:00
added ability to add budget
This commit is contained in:
parent
556a1567bd
commit
0eb368d2a0
15 changed files with 1170 additions and 51 deletions
946
package-lock.json
generated
946
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -11,12 +11,17 @@
|
|||
"codegen": "rm -rf ./src/generated/openapi; openapi --input ./api.yaml --output ./src/generated/openapi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.3.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@mui/material": "^5.11.15",
|
||||
"@mui/x-date-pickers": "^6.0.4",
|
||||
"@sindresorhus/is": "^5.3.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@tanstack/react-query-devtools": "^4.28.0",
|
||||
"@types/node": "18.15.5",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
|
|
|
@ -1,33 +1,71 @@
|
|||
import { Button, Grid, TextField } from '@mui/material';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
|
||||
import { resolver } from './resolver';
|
||||
import { useAddBudget } from '../../hooks/useAddBudget';
|
||||
import { NewBudgetModel } from '../../models/budget';
|
||||
import { Card } from '../_design/Card/Card';
|
||||
import { DatePicker } from '../DatePicker/DatePicker';
|
||||
|
||||
// TODO check if necessary for type safety
|
||||
// const resolver: Resolver<NewBudgetModel> = async (values: unknown) => ({
|
||||
// values: newBudgetModelSchema.parse(values),
|
||||
// errors: {},
|
||||
// });
|
||||
export const AddBudgetForm = () => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<NewBudgetModel>();
|
||||
const { mutate: addBudget } = useAddBudget();
|
||||
const onSubmit: SubmitHandler<NewBudgetModel> = (budgetItem: NewBudgetModel) => {
|
||||
console.log(budgetItem);
|
||||
addBudget(budgetItem);
|
||||
} = useForm<NewBudgetModel>({ resolver });
|
||||
const { mutate: addBudget, isLoading } = useAddBudget();
|
||||
const onSubmit: SubmitHandler<NewBudgetModel> = async (budgetItem) => {
|
||||
await addBudget(budgetItem);
|
||||
reset();
|
||||
};
|
||||
|
||||
const { ref, ...nameStuff } = register('name', { required: true });
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="text" placeholder="Budget name" {...register('name', { required: true })} />
|
||||
{errors.name && <span>This field is required</span>}
|
||||
<input type="number" defaultValue={0} {...register('limit', { min: 0, required: true })} />
|
||||
{errors.limit && <span>This field is required</span>}
|
||||
<input type="submit" />
|
||||
<Grid container alignItems="flex-end" rowGap={4} columnGap={2}>
|
||||
<Grid item xs={12} sm={6} lg={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="standard"
|
||||
type="text"
|
||||
label="Budget name"
|
||||
inputRef={ref}
|
||||
{...nameStuff}
|
||||
error={!!errors.name}
|
||||
/>
|
||||
{errors.name && <>ERROR!</>}
|
||||
</Grid>
|
||||
<Grid item lg={4} xs={12} sm={5.5}>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="standard"
|
||||
type="number"
|
||||
label="Limit"
|
||||
{...register('limit', { min: 0, required: true })}
|
||||
error={!!errors.limit}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item lg={4} sm={5} md={4} xs={12}>
|
||||
<DatePicker control={control} name="startDate" label="Start" />
|
||||
</Grid>
|
||||
<Grid item lg={4} sm={5} md={4} xs={12}>
|
||||
<DatePicker control={control} name="endDate" label="End" />
|
||||
</Grid>
|
||||
<Grid item lg sm md justifyContent={'center'}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="contained"
|
||||
disabled={isLoading || Object.keys(errors).length > 0}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
|
|
44
src/components/AddBudgetForm/resolver.ts
Normal file
44
src/components/AddBudgetForm/resolver.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { isBefore } from 'date-fns';
|
||||
import { Resolver } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { NewBudgetModel } from '../../models/budget';
|
||||
|
||||
const budgetFormSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
limit: z.string(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
})
|
||||
.transform<NewBudgetModel>((formValues) => ({
|
||||
...formValues,
|
||||
startDate: formValues.startDate,
|
||||
endDate: formValues.endDate,
|
||||
limit: parseInt(formValues.limit),
|
||||
}));
|
||||
|
||||
export const resolver: Resolver<NewBudgetModel> = async (values: unknown) => {
|
||||
const parseResult = budgetFormSchema.safeParse(values);
|
||||
if (!parseResult.success) {
|
||||
return {
|
||||
values: parseResult,
|
||||
errors: { root: { message: parseResult.error.message } },
|
||||
};
|
||||
} else {
|
||||
// check whether dates are set up correctly
|
||||
const { startDate, endDate } = parseResult.data;
|
||||
if (startDate && endDate && isBefore(endDate, startDate)) {
|
||||
return {
|
||||
values: parseResult.data,
|
||||
errors: {
|
||||
startDate: { type: 'value', message: 'Start date has to be before end date' },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
values: parseResult.data,
|
||||
errors: {},
|
||||
};
|
||||
}
|
||||
};
|
|
@ -17,7 +17,7 @@ type BudgetItemProps = { budget: BudgetModel };
|
|||
export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
|
||||
const { id, spent, name, limit, startDate, endDate } = budget;
|
||||
|
||||
const spentPercentage = Math.trunc((100 * spent) / limit);
|
||||
const remainingBudget = 100 - Math.trunc((100 * spent) / limit);
|
||||
|
||||
const isValidDateRange = is.date(startDate) && is.date(endDate) && endDate > startDate;
|
||||
|
||||
|
@ -29,7 +29,7 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
|
|||
</h3>
|
||||
|
||||
<DetailWithTitle title={'Remaining Budget'}>
|
||||
<ProgressBar percentage={spentPercentage} />
|
||||
<ProgressBar percentage={remainingBudget} />
|
||||
</DetailWithTitle>
|
||||
|
||||
{isValidDateRange && (
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faAngry } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faPlus, faMinus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC, useState } from 'react';
|
||||
import { FC } from 'react';
|
||||
|
||||
import styles from './BudgetList.module.scss';
|
||||
import { useBudgets } from '../../hooks/useBudgets';
|
||||
import { useToggle } from '../../hooks/useToggle';
|
||||
import { Button } from '../_design/Button/Button';
|
||||
import { AddBudgetForm } from '../AddBudgetForm/AddBudgetForm';
|
||||
import { BudgetItem } from '../BudgetItem/BudgetItem';
|
||||
|
@ -14,17 +15,12 @@ library.add(faAngry);
|
|||
|
||||
export const BudgetList: FC = () => {
|
||||
const { data: budgets = [] } = useBudgets();
|
||||
const [showAddBudget, setShowAddBudget] = useState(false);
|
||||
|
||||
const handleAddBudgetClick = () => {
|
||||
// TODO create logic for adding budget
|
||||
setShowAddBudget(true);
|
||||
};
|
||||
const [showAddBudget, toggleShowAddBudget] = useToggle(false);
|
||||
|
||||
return (
|
||||
<section className={styles.Container}>
|
||||
<Button onClick={handleAddBudgetClick}>
|
||||
Add a budget <FontAwesomeIcon icon={faPlus} />
|
||||
<Button onClick={toggleShowAddBudget}>
|
||||
Add a budget <FontAwesomeIcon icon={showAddBudget ? faMinus : faPlus} />
|
||||
</Button>
|
||||
{showAddBudget && <AddBudgetForm />}
|
||||
<div className={styles.ListItems}>
|
||||
|
|
17
src/components/DatePicker/DatePicker.module.scss
Normal file
17
src/components/DatePicker/DatePicker.module.scss
Normal file
|
@ -0,0 +1,17 @@
|
|||
.Wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Error {
|
||||
position: absolute;
|
||||
bottom: -16px;
|
||||
left: 2px;
|
||||
font-size: 12px;
|
||||
color: red;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.DatePicker {
|
||||
width: 100%
|
||||
}
|
34
src/components/DatePicker/DatePicker.tsx
Normal file
34
src/components/DatePicker/DatePicker.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { DatePicker as MUIDatePicker } from '@mui/x-date-pickers';
|
||||
import { Control, Controller, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
import styles from './DatePicker.module.scss';
|
||||
|
||||
const DATE_FORMAT = 'dd.MM.yyyy';
|
||||
export const DatePicker = <ControlType extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
}: {
|
||||
control: Control<ControlType>;
|
||||
name: Path<ControlType>;
|
||||
label?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { value, ...rest }, fieldState: { error } }) => (
|
||||
<div className={styles.Wrapper}>
|
||||
<MUIDatePicker
|
||||
className={styles.DatePicker}
|
||||
label={label && <span>{label}</span>}
|
||||
format={DATE_FORMAT}
|
||||
{...rest}
|
||||
value={value ? new Date(value) : null}
|
||||
/>
|
||||
{error && <span className={styles.Error}>{error.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
name={name}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -4,11 +4,11 @@
|
|||
import type { Budget } from '../models/Budget';
|
||||
import type { BudgetId } from '../models/BudgetId';
|
||||
import type { BudgetSummary } from '../models/BudgetSummary';
|
||||
import type { NewBudget } from '../models/NewBudget';
|
||||
|
||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||
import { OpenAPI } from '../core/OpenAPI';
|
||||
import { request as __request } from '../core/request';
|
||||
import {NewBudgetModel} from "../../../models/budget";
|
||||
|
||||
export class BudgetsService {
|
||||
|
||||
|
@ -19,8 +19,9 @@ export class BudgetsService {
|
|||
* @throws ApiError
|
||||
*/
|
||||
public static createBudget(
|
||||
requestBody: NewBudget,
|
||||
requestBody: NewBudgetModel,
|
||||
): CancelablePromise<Budget> {
|
||||
console.log('requestBody: ', requestBody);
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/budgets',
|
||||
|
@ -85,5 +86,4 @@ export class BudgetsService {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
import { useMutation, UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
import { Budget, BudgetsService, NewBudget } from '../generated/openapi';
|
||||
import { useInvalidateQuery } from './useBudgets';
|
||||
import { Budget, BudgetsService } from '../generated/openapi';
|
||||
import { NewBudgetModel } from '../models/budget';
|
||||
|
||||
export const useAddBudget = (): UseMutationResult<Budget, unknown, NewBudgetModel> =>
|
||||
useMutation((budget: NewBudgetModel) => {
|
||||
const mappedBudget: NewBudget = {
|
||||
...budget,
|
||||
startDate: budget.startDate?.toISOString(),
|
||||
endDate: budget.endDate?.toISOString(),
|
||||
};
|
||||
return BudgetsService.createBudget(mappedBudget);
|
||||
});
|
||||
export const useAddBudget = (): UseMutationResult<Budget, unknown, NewBudgetModel> => {
|
||||
const invalidateBudgetQuery = useInvalidateQuery();
|
||||
|
||||
return useMutation(
|
||||
(budget: NewBudgetModel) => {
|
||||
console.log('budget: ', budget);
|
||||
return BudgetsService.createBudget(budget);
|
||||
},
|
||||
{
|
||||
onSuccess: () => invalidateBudgetQuery(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { Budget, BudgetsService } from '../generated/openapi';
|
||||
import { BudgetModel, mapToBudgetModel } from '../models/budget';
|
||||
|
||||
const budgetSymbol = Symbol('getBudgets');
|
||||
const budgetsQueryKey = ['/budgets'];
|
||||
|
||||
const selectValidBudgetModelsFromBudgetDtos = (budgetDtos: Budget[]) =>
|
||||
budgetDtos.reduce<BudgetModel[]>((budgetModels, budgetDto) => {
|
||||
|
@ -12,9 +12,14 @@ const selectValidBudgetModelsFromBudgetDtos = (budgetDtos: Budget[]) =>
|
|||
return mappedBudget ? [...budgetModels, mappedBudget] : budgetModels;
|
||||
}, []);
|
||||
|
||||
export const useInvalidateQuery = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries(budgetsQueryKey);
|
||||
};
|
||||
|
||||
export const useBudgets = (): UseQueryResult<BudgetModel[]> =>
|
||||
useQuery({
|
||||
queryKey: [budgetSymbol],
|
||||
queryKey: budgetsQueryKey,
|
||||
queryFn: () => BudgetsService.getBudgets(),
|
||||
select: selectValidBudgetModelsFromBudgetDtos,
|
||||
});
|
||||
|
|
14
src/hooks/useToggle.ts
Normal file
14
src/hooks/useToggle.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook to toggle a boolean value.
|
||||
* @param initialValue
|
||||
*/
|
||||
export const useToggle = (initialValue: boolean) => {
|
||||
const [state, setState] = useState(initialValue);
|
||||
const toggleState = useCallback(() => {
|
||||
setState((oldValue) => !oldValue);
|
||||
}, []);
|
||||
|
||||
return [state, toggleState] as const;
|
||||
};
|
|
@ -11,7 +11,7 @@ export const newBudgetModelSchema = z.object({
|
|||
|
||||
export const budgetModelSchema = newBudgetModelSchema.extend({
|
||||
id: z.string(),
|
||||
spent: z.number().positive(),
|
||||
spent: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
export type BudgetModel = z.infer<typeof budgetModelSchema>;
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { config } from '@fortawesome/fontawesome-svg-core';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { Inter } from 'next/font/google';
|
||||
import React from 'react';
|
||||
|
||||
|
@ -20,12 +23,15 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWind
|
|||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<main className={inter.className}>
|
||||
<Navigation />
|
||||
<section className={appStyles.ContentWrapper}>
|
||||
<Component {...pageProps} />
|
||||
</section>
|
||||
</main>
|
||||
<ReactQueryDevtools />
|
||||
</LocalizationProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,5 +8,14 @@ export const budgetList = budgetFactory.buildList(8);
|
|||
budgetFactory.rewindSequence();
|
||||
|
||||
export default function handler(_req: NextApiRequest, res: NextApiResponse<Budget[]>) {
|
||||
res.status(200).json(budgetList);
|
||||
switch (_req.method) {
|
||||
case 'POST':
|
||||
const newBudget: Budget = { ..._req.body, id: _req.body.name, spent: 0 };
|
||||
budgetList.push(newBudget);
|
||||
return res.status(200).json(budgetList);
|
||||
case 'GET':
|
||||
default:
|
||||
console.log('budgetList: ', budgetList);
|
||||
return res.status(200).json(budgetList);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue