Skip to content

Commit

Permalink
Merge pull request #27 from dcorroyer/mb-45-budget-duplication
Browse files Browse the repository at this point in the history
MB-45 budget duplication
  • Loading branch information
dcorroyer authored Oct 21, 2024
2 parents 6768ca5 + e95221e commit 1377759
Show file tree
Hide file tree
Showing 20 changed files with 486 additions and 45 deletions.
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.4.0
4 changes: 2 additions & 2 deletions app/assets/components/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ const NotFound: React.FC = () => {
<div className={classes.content}>
<Title className={classes.title}>Nothing to see here</Title>
<Text c='dimmed' size='lg' ta='center' className={classes.description}>
Page you are trying to open does not exist. The page is in construction.
Page you are trying to open does not exist.
</Text>
<Group justify='center'>
<Button size='md' component={Link} to={'/budgets'}>
Take me back to budget page
Take me back to budget&apos;s list page
</Button>
</Group>
</div>
Expand Down
20 changes: 20 additions & 0 deletions app/assets/features/budgets/api/budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,23 @@ export async function updateBudgetId(

return await response.json()
}

export async function postDuplicateBudget(): Promise<Response | ApiErrorResponse> {
const response = await client('/api/budgets/duplicate', {
method: 'POST',
})

if (!response.ok) return Promise.reject('Failed to duplicate budget')

return await response.json()
}

export async function postDuplicateBudgetId(id: string): Promise<Response | ApiErrorResponse> {
const response = await client(`/api/budgets/duplicate/${id}`, {
method: 'POST',
})

if (!response.ok) return Promise.reject('Failed to duplicate budget')

return await response.json()
}
25 changes: 19 additions & 6 deletions app/assets/features/budgets/components/budget-items.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { ActionIcon, Badge, Card, Group, SimpleGrid, Text, rem } from '@mantine/core'
import { IconCopy, IconEye, IconTrash } from '@tabler/icons-react'
import React from 'react'
import { Link } from 'react-router-dom'

import { ActionIcon, Badge, Card, Group, SimpleGrid, Text, rem } from '@mantine/core'
import { IconEye, IconTrash } from '@tabler/icons-react'

import { CenteredLoader as Loader } from '@/components/centered-loader'
import { useBudget } from '../hooks/useBudget'

import classes from './budget-items.module.css'

export const BudgetItems = ({
selectedYear,
openModal,
openDeleteModal,
openDuplicateModal,
setBudgetIdToDelete,
setBudgetIdToDuplicate,
}: {
selectedYear: number
openModal: () => void
openDeleteModal: () => void
openDuplicateModal: () => void
setBudgetIdToDelete: (id: string | null) => void
setBudgetIdToDuplicate: (id: string | null) => void
}) => {
const { useBudgetList } = useBudget()

Expand All @@ -31,6 +34,16 @@ export const BudgetItems = ({
<Group justify='space-between'>
<Text fw={500}>{budget.name}</Text>
<div>
<ActionIcon
onClick={() => {
setBudgetIdToDuplicate(budget.id.toString())
openDuplicateModal()
}}
variant='subtle'
color='blue'
>
<IconCopy style={{ width: rem(20), height: rem(20) }} stroke={1.5} />
</ActionIcon>
<ActionIcon
component={Link}
to={`/budgets/${budget.id}`}
Expand All @@ -42,7 +55,7 @@ export const BudgetItems = ({
<ActionIcon
onClick={() => {
setBudgetIdToDelete(budget.id.toString())
openModal()
openDeleteModal()
}}
variant='subtle'
color='red'
Expand Down
66 changes: 65 additions & 1 deletion app/assets/features/budgets/hooks/useBudget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
getBudgetDetail,
getBudgetList,
postBudget,
postDuplicateBudget,
postDuplicateBudgetId,
updateBudgetId,
} from '@/features/budgets/api/budgets'

Expand Down Expand Up @@ -125,15 +127,77 @@ export const useBudget = () => {
},
})

const duplicateBudget = useCallback((id: string) => {
duplicateBudgetMutation.mutate(id)
}, [])

const duplicateBudgetMutation = useMutation({
mutationFn: postDuplicateBudgetId,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budgets'] })
navigate('/budgets')
notifications.show({
withBorder: true,
radius: 'md',
color: 'blue',
title: 'Successful Duplication',
message: 'You have successfully duplicated a budget',
})
},
onError: (error: Error) => {
console.log('error:', error)
notifications.show({
withBorder: true,
radius: 'md',
color: 'red',
title: 'Error',
message: 'There was an error during the budget duplication process',
})
},
})

const duplicateLatestBudget = useCallback(() => {
duplicateLatestBudgetMutation.mutate()
}, [])

const duplicateLatestBudgetMutation = useMutation({
mutationFn: postDuplicateBudget,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budgets'] })
navigate('/budgets')
notifications.show({
withBorder: true,
radius: 'md',
color: 'blue',
title: 'Successful Duplication',
message: 'You have successfully duplicated a budget',
})
},
onError: (error: Error) => {
console.log('error:', error)
notifications.show({
withBorder: true,
radius: 'md',
color: 'red',
title: 'Error',
message: 'There was an error during the budget duplication process',
})
},
})

return {
useBudgetList,
useBudgetDetail,
createBudget,
updateBudget,
deleteBudget,
duplicateBudget,
duplicateLatestBudget,
isLoading:
createBudgetMutation.isPending ||
updateBudgetMutation.isPending ||
deleteBudgetMutation.isPending,
deleteBudgetMutation.isPending ||
duplicateBudgetMutation.isPending ||
duplicateLatestBudgetMutation.isPending,
}
}
6 changes: 2 additions & 4 deletions app/assets/features/budgets/pages/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { groupExpensesByCategory } from '../helpers/budgetDataTransformer'
import { useBudget } from '../hooks/useBudget'

import classes from './detail.module.css'
import NotFound from '@/components/not-found'

const BudgetDetail: React.FC = () => {
const { id } = useParams()
Expand All @@ -28,10 +29,7 @@ const BudgetDetail: React.FC = () => {
const [editMode, setEditMode] = useState(false)

if (isFetching) return <Loader />

if (!budget) {
return <Text fw={500}>Budget not found.</Text>
}
if (!budget) return <NotFound />

const formattedExpenses = groupExpensesByCategory(budget.data.expenses)
const budgetData = { ...budget?.data, expenses: formattedExpenses }
Expand Down
13 changes: 8 additions & 5 deletions app/assets/features/budgets/pages/list.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
font-weight: 500;
margin-top: var(--mantine-spacing-xs);
background-color: var(--mantine-color-blue-light);
width: rem(80px);
width: auto;
height: auto;
padding: 0.2rem 0.5rem;
transform: translateY(0.33rem);

@mixin hover {
Expand All @@ -23,7 +25,7 @@
padding: rem(2.5);
}

.deleteItem {
.modalItem {
display: flex;
align-items: center;
text-decoration: none;
Expand All @@ -33,8 +35,9 @@
font-weight: 500;
margin-top: var(--mantine-spacing-xs);
background-color: var(--mantine-color-red-light);
width: rem(80px);
height: rem(35px);
width: auto;
height: auto;
padding: 0.33rem 0.75rem 0.33rem 0.5rem;

@mixin hover {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6));
Expand All @@ -46,7 +49,7 @@
}
}

.deleteIcon {
.modalIcon {
color: var(--mantine-color-red-light-color);
padding: rem(5px);
width: rem(30px);
Expand Down
51 changes: 41 additions & 10 deletions app/assets/features/budgets/pages/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { useDisclosure } from '@mantine/hooks'
import {
IconChevronLeft,
IconChevronRight,
IconSquareRoundedPlus2,
IconCopy,
IconSquarePlus2,
IconTrash,
} from '@tabler/icons-react'

Expand All @@ -16,18 +17,27 @@ import { useBudget } from '../hooks/useBudget'
import classes from './list.module.css'

const BudgetList: React.FC = () => {
const { deleteBudget } = useBudget()
const { deleteBudget, duplicateBudget } = useBudget()

const currentYear = new Date().getFullYear()
const [selectedYear, setSelectedYear] = useState(currentYear)

const [opened, { open, close }] = useDisclosure(false)
const [openedDelete, { open: openDelete, close: closeDelete }] = useDisclosure(false)
const [openedDuplicate, { open: openDuplicate, close: closeDuplicate }] = useDisclosure(false)
const [budgetIdToDelete, setBudgetIdToDelete] = useState<string | null>(null)
const [budgetIdToDuplicate, setBudgetIdToDuplicate] = useState<string | null>(null)

const handleDelete = () => {
if (budgetIdToDelete) {
deleteBudget(budgetIdToDelete)
close()
closeDelete()
}
}

const handleDuplicate = () => {
if (budgetIdToDuplicate) {
duplicateBudget(budgetIdToDuplicate)
closeDuplicate()
}
}

Expand All @@ -42,26 +52,45 @@ const BudgetList: React.FC = () => {
component={Link}
to={'/budgets/create'}
>
<IconSquareRoundedPlus2 className={classes.linkIcon} stroke={1.5} />
<IconSquarePlus2 className={classes.linkIcon} stroke={1.5} />
<span style={{ padding: rem(2.5) }}>Create</span>
</ActionIcon>
</Text>
<Container>
{/* Delete Confirmation Modal */}
<Modal
opened={opened}
onClose={close}
opened={openedDelete}
onClose={closeDelete}
radius={12.5}
size='sm'
title='Are you sure you want to delete this budget?'
centered
>
<Center>
<Link className={classes.deleteItem} onClick={handleDelete} to={''}>
<IconTrash className={classes.deleteIcon} stroke={1.5} />
<Link className={classes.modalItem} onClick={handleDelete} to={''}>
<IconTrash className={classes.modalIcon} stroke={1.5} />
<span>Delete</span>
</Link>
</Center>
</Modal>

{/* Duplicate Confirmation Modal */}
<Modal
opened={openedDuplicate}
onClose={closeDuplicate}
radius={12.5}
size='sm'
title='Are you sure you want to duplicate this budget?'
centered
>
<Center>
<Link className={classes.modalItem} onClick={handleDuplicate} to={''}>
<IconCopy className={classes.modalIcon} stroke={1.5} />
<span>Duplicate</span>
</Link>
</Center>
</Modal>

<Group justify='center' gap='xl' mb='xl'>
<ActionIcon
variant='transparent'
Expand All @@ -83,8 +112,10 @@ const BudgetList: React.FC = () => {
</Group>
<BudgetItems
selectedYear={selectedYear}
openModal={open}
openDeleteModal={openDelete}
openDuplicateModal={openDuplicate}
setBudgetIdToDelete={setBudgetIdToDelete}
setBudgetIdToDuplicate={setBudgetIdToDuplicate}
/>
</Container>
</>
Expand Down
45 changes: 45 additions & 0 deletions app/src/Controller/Budget/DuplicateBudgetController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Controller\Budget;

use App\Entity\Budget;
use App\Serializable\SerializationGroups;
use App\Service\BudgetService;
use My\RestBundle\Attribute\MyOpenApi\MyOpenApi;
use My\RestBundle\Attribute\MyOpenApi\Response\SuccessResponse;
use My\RestBundle\Controller\BaseRestController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/budgets')]
#[OA\Tag(name: 'Budgets')]
class DuplicateBudgetController extends BaseRestController
{
#[MyOpenApi(
httpMethod: Request::METHOD_POST,
operationId: 'duplicate_budget',
summary: 'duplicate budget',
responses: [
new SuccessResponse(
responseClassFqcn: Budget::class,
groups: [SerializationGroups::BUDGET_CREATE],
responseCode: Response::HTTP_CREATED,
description: 'Budget duplication',
),
],
)]
#[Route('/duplicate/{id}', name: 'api_budgets_duplicate', methods: Request::METHOD_POST)]
public function __invoke(BudgetService $budgetService, ?int $id = null): JsonResponse
{
return $this->successResponse(
data: $budgetService->duplicate($id),
groups: [SerializationGroups::BUDGET_CREATE],
status: Response::HTTP_CREATED,
);
}
}
Loading

0 comments on commit 1377759

Please sign in to comment.