diff --git a/README.md b/README.md index 7efaefe..d1d4c4b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +# ToDo +* Popis lokacija: + * omogućiti collapse za pojedine mjesece + # Authentication Authentication consists of the following parts: * `next-auth` boilerplate diff --git a/app/attachment/[id]/route.tsx b/app/attachment/[id]/route.tsx index 258957c..4bb82d2 100644 --- a/app/attachment/[id]/route.tsx +++ b/app/attachment/[id]/route.tsx @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'; export async function GET(request: Request, { params:{ id } }: { params: { id:string } }) { const [locationID, billID] = id.split('-'); - const bill = await fetchBillById(locationID, billID); + const [location, bill] = await fetchBillById(locationID, billID) ? []; if(!bill?.attachment) { notFound(); diff --git a/app/bill/[id]/add/not-found.tsx b/app/bill/[id]/add/not-found.tsx new file mode 100644 index 0000000..d9d84b1 --- /dev/null +++ b/app/bill/[id]/add/not-found.tsx @@ -0,0 +1,6 @@ +import { NotFoundPage } from '@/app/ui/NotFoundPage'; + +const BillNotFound = () => +; + +export default BillNotFound; diff --git a/app/bill/[id]/add/page.tsx b/app/bill/[id]/add/page.tsx index 5549d24..bae6db8 100644 --- a/app/bill/[id]/add/page.tsx +++ b/app/bill/[id]/add/page.tsx @@ -1,11 +1,19 @@ +import { fetchLocationById } from '@/app/lib/actions/locationActions'; import { BillEditForm } from '@/app/ui/BillEditForm'; import { Main } from '@/app/ui/Main'; +import { notFound } from 'next/navigation'; export default async function Page({ params:{ id:locationID } }: { params: { id:string } }) { + const location = await fetchLocationById(locationID); + + if (!location) { + return(notFound()); + } + return (
- +
); } \ No newline at end of file diff --git a/app/bill/[id]/delete/page.tsx b/app/bill/[id]/delete/page.tsx index 3d41f02..6b9d211 100644 --- a/app/bill/[id]/delete/page.tsx +++ b/app/bill/[id]/delete/page.tsx @@ -1,14 +1,17 @@ -import { deleteBillById } from '@/app/lib/actions/billActions'; -import { revalidatePath } from 'next/cache'; -import { notFound, redirect } from 'next/navigation'; +import { notFound } from 'next/navigation'; +import { fetchLocationById } from '@/app/lib/actions/locationActions'; +import { LocationDeleteForm } from '@/app/ui/LocationDeleteForm'; +import { BillDeleteForm } from '@/app/ui/BillDeleteForm'; +import { fetchBillById } from '@/app/lib/actions/billActions'; export default async function Page({ params:{ id } }: { params: { id:string } }) { const [locationID, billID] = id.split('-'); - if(await deleteBillById(locationID, billID) === 0) { + const [location, bill] = await fetchBillById(locationID, billID) ?? []; + + if (!location || !bill) { return(notFound()); } - revalidatePath('/'); - redirect(`/`); + return (); } \ No newline at end of file diff --git a/app/bill/[id]/edit/page.tsx b/app/bill/[id]/edit/page.tsx index c1184b6..eda8bea 100644 --- a/app/bill/[id]/edit/page.tsx +++ b/app/bill/[id]/edit/page.tsx @@ -7,14 +7,14 @@ export default async function Page({ params:{ id } }: { params: { id:string } }) const [locationID, billID] = id.split('-'); - const bill = await fetchBillById(locationID, billID); + const [location, bill] = await fetchBillById(locationID, billID) ?? []; - if (!bill) { + if (!bill || !location) { return(notFound()); } return (
- +
); } \ No newline at end of file diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index 18dd7fd..cb84856 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -1,13 +1,12 @@ 'use server'; import { z } from 'zod'; -import { revalidatePath } from 'next/cache'; -import { redirect } from 'next/navigation'; -import clientPromise, { getDbClient } from '../dbClient'; -import { BillAttachment, BillingLocation } from '../db-types'; +import { getDbClient } from '../dbClient'; +import { Bill, BillAttachment, BillingLocation, YearMonth } from '../db-types'; import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; +import { gotoHome } from './navigationActions'; export type State = { errors?: { @@ -110,7 +109,7 @@ const serializeAttachment = async (billAttachment: File | null) => { * @param formData form data * @returns */ -export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationId: string, billId:string|undefined, prevState:State, formData: FormData) => { +export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationId: string, billId:string|undefined, billYear:number|undefined, prevState:State, formData: FormData) => { const { id: userId } = user; @@ -192,18 +191,9 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI } }); } - - // clear the cache for the path - revalidatePath('/'); - // go to the bill list - redirect('/'); + await gotoHome(billYear ? `/?year=${billYear}` : undefined); }) -export async function gotoHome() { - revalidatePath('/'); - redirect('/'); -} - export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string) => { const { id: userId } = user; @@ -226,10 +216,10 @@ export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID: return(null); } - return(bill); + return([billLocation, bill] as [BillingLocation, Bill]); }) -export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string) => { +export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number) => { const { id: userId } = user; @@ -250,5 +240,6 @@ export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID } }); + await gotoHome(`/?year=${year}`); return(post.modifiedCount); }); \ No newline at end of file diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 91c2dbb..a928f1e 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -1,14 +1,12 @@ 'use server'; import { z } from 'zod'; -import { revalidatePath } from 'next/cache'; -import { redirect } from 'next/navigation'; -import clientPromise, { getDbClient } from '../dbClient'; +import { getDbClient } from '../dbClient'; import { BillingLocation, YearMonth } from '../db-types'; import { ObjectId } from 'mongodb'; -import { auth, withUser } from '@/app/lib/auth'; +import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; -import { NormalizedRouteManifest } from 'next/dist/server/base-server'; +import { gotoHome } from './navigationActions'; export type State = { errors?: { @@ -82,10 +80,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat }); } - // clear the cache for the path - revalidatePath('/'); - // go to the bill list - redirect('/'); + await gotoHome(yearMonth ? `/?year=${yearMonth?.year}` : undefined) }); @@ -128,7 +123,7 @@ export const fetchLocationById = withUser(async (user:AuthenticatedUser, locatio return(billLocation); }) -export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string) => { +export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string, yearMonth:YearMonth) => { const dbClient = await getDbClient(); @@ -137,5 +132,5 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati // find a location with the given locationID const post = await dbClient.collection("lokacije").deleteOne({ _id: locationID, userId }); - return(post.deletedCount); + await gotoHome(`/?year=${yearMonth?.year}`) }) \ No newline at end of file diff --git a/app/lib/actions/monthActions.ts b/app/lib/actions/monthActions.ts index 1a234a0..3e7948a 100644 --- a/app/lib/actions/monthActions.ts +++ b/app/lib/actions/monthActions.ts @@ -1,10 +1,8 @@ 'use server'; -import { revalidatePath } from 'next/cache'; -import { redirect } from 'next/navigation'; -import clientPromise, { getDbClient } from '../dbClient'; +import { getDbClient } from '../dbClient'; import { ObjectId } from 'mongodb'; -import { BillingLocation, YearMonth } from '../db-types'; +import { Bill, BillingLocation, YearMonth } from '../db-types'; import { AuthenticatedUser } from '../types/next-auth'; import { withUser } from '../auth'; @@ -22,7 +20,6 @@ export const addMonth = withUser(async (user:AuthenticatedUser, { year, month }: // update the bill in the mongodb const dbClient = await getDbClient(); - const prevYear = month === 1 ? year - 1 : year; const prevMonth = month === 1 ? 12 : month - 1; @@ -52,24 +49,16 @@ export const addMonth = withUser(async (user:AuthenticatedUser, { year, month }: paid: false, attachment: null, notes: null, - } + payedAmount: null + } as Bill }) } as BillingLocation); }); const newMonthLocations = await newMonthLocationsCursor.toArray() await dbClient.collection("lokacije").insertMany(newMonthLocations); - - // clear the cache for the path - revalidatePath('/'); - // go to the bill list - redirect('/'); }); -export async function gotoHome() { - redirect('/'); -} - export const fetchAvailableYears = withUser(async (user:AuthenticatedUser) => { const { id: userId } = user; diff --git a/app/lib/actions/navigationActions.ts b/app/lib/actions/navigationActions.ts new file mode 100644 index 0000000..444c801 --- /dev/null +++ b/app/lib/actions/navigationActions.ts @@ -0,0 +1,9 @@ +'use server'; + +import { revalidatePath } from "next/cache"; +import { redirect } from 'next/navigation'; + +export async function gotoHome(path: string = '/') { + revalidatePath(path, "page"); + redirect(path); +} diff --git a/app/location/[id]/delete/page.tsx b/app/location/[id]/delete/page.tsx index 687570b..f4d8d44 100644 --- a/app/location/[id]/delete/page.tsx +++ b/app/location/[id]/delete/page.tsx @@ -1,15 +1,14 @@ -import { deleteBillById } from '@/app/lib/actions/billActions'; -import { deleteLocationById } from '@/app/lib/actions/locationActions'; -import { revalidatePath } from 'next/cache'; -import { notFound, redirect } from 'next/navigation'; +import { notFound } from 'next/navigation'; +import { fetchLocationById } from '@/app/lib/actions/locationActions'; +import { LocationDeleteForm } from '@/app/ui/LocationDeleteForm'; export default async function Page({ params:{ id } }: { params: { id:string } }) { - const locationID = id; - if(await deleteLocationById(locationID) === 0) { + const location = await fetchLocationById(id); + + if (!location) { return(notFound()); } - revalidatePath('/'); - redirect(`/`); + return (); } \ No newline at end of file diff --git a/app/location/[id]/edit/page.tsx b/app/location/[id]/edit/page.tsx index b991b72..b28cd30 100644 --- a/app/location/[id]/edit/page.tsx +++ b/app/location/[id]/edit/page.tsx @@ -9,5 +9,5 @@ export default async function Page({ params:{ id } }: { params: { id:string } }) if (!location) { return(notFound()); } - return (); + return (); } \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 5568288..79d745c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,10 +8,11 @@ import { formatCurrency } from './lib/formatStrings'; import { fetchAvailableYears } from './lib/actions/monthActions'; import { YearMonth } from './lib/db-types'; import { formatYearMonth } from './lib/format'; -import { FC } from 'react'; +import { FC, Fragment } from 'react'; import Pagination from './ui/Pagination'; import { PageHeader } from './ui/PageHeader'; import { Main } from './ui/Main'; +import { MontlyExpensesCard } from './ui/MonthlyExpensesCard'; const getNextYearMonth = (yearMonth:YearMonth) => { const {year, month} = yearMonth; @@ -86,32 +87,26 @@ const Page:FC = async ({ searchParams }) => { monthlyExpense = 0; } - monthlyExpense += location.bills.reduce((acc, bill) => acc + (bill.payedAmount ?? 0), 0); + monthlyExpense += location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); return ( - <> + { // show month title above the first LocationCard in the month isFirstLocationInMonth ? - : null + : null } - + { // show AddLocationButton as a last item in the first month isLastLocationOfLatestMonth && isLatestYear ? - : null + : null } { - isLastLocationInMonth && monthlyExpense>0 ? -
- -

- Total monthly expenditure: { formatCurrency(monthlyExpense) } -

-
-
: null + isLastLocationInMonth ? + : null } - +
) }) } diff --git a/app/ui/BillBadge.tsx b/app/ui/BillBadge.tsx index fb1f093..f0e3eda 100644 --- a/app/ui/BillBadge.tsx +++ b/app/ui/BillBadge.tsx @@ -7,7 +7,7 @@ export interface BillBadgeProps { bill: Bill }; -export const BillBadge:FC = ({ locationId, bill: { _id: billId, name, paid }}) => - +export const BillBadge:FC = ({ locationId, bill: { _id: billId, name, paid, attachment }}) => + {name} ; \ No newline at end of file diff --git a/app/ui/BillDeleteForm.tsx b/app/ui/BillDeleteForm.tsx new file mode 100644 index 0000000..1427625 --- /dev/null +++ b/app/ui/BillDeleteForm.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { FC } from "react"; +import { Bill, BillingLocation } from "../lib/db-types"; +import { deleteLocationById } from "../lib/actions/locationActions"; +import { useFormState } from "react-dom"; +import { Main } from "./Main"; +import { gotoHome } from "../lib/actions/navigationActions"; +import { deleteBillById } from "../lib/actions/billActions"; + +export interface BillDeleteFormProps { + bill: Bill, + location: BillingLocation +} + +export const BillDeleteForm:FC = ({ bill, location }) => +{ + const handleAction = deleteBillById.bind(null, location._id, bill._id, location.yearMonth.year); + const [ state, dispatch ] = useFormState(handleAction, null); + + const handleCancel = () => { + gotoHome(`/?year=${location.yearMonth.year}`); + }; + + return( +
+
+
+
+

+ Please confirm deletion of bill “{bill.name}” at “{location.name}”. +

+
+ + +
+
+
+
+
+ ) +} diff --git a/app/ui/BillEditForm.tsx b/app/ui/BillEditForm.tsx index e3ddf50..a7739ac 100644 --- a/app/ui/BillEditForm.tsx +++ b/app/ui/BillEditForm.tsx @@ -1,40 +1,43 @@ "use client"; import { DocumentIcon, TrashIcon } from "@heroicons/react/24/outline"; -import { Bill } from "../lib/db-types"; +import { Bill, BillingLocation } from "../lib/db-types"; import React, { FC } from "react"; import { useFormState } from "react-dom"; -import { gotoHome, updateOrAddBill } from "../lib/actions/billActions"; +import { updateOrAddBill } from "../lib/actions/billActions"; import Link from "next/link"; +import { gotoHome } from "../lib/actions/navigationActions"; +import { formatYearMonth } from "../lib/format"; // Next.js does not encode an utf-8 file name correctly when sending a form with a file attachment // This is a workaround for that -const updateOrAddBillMiddleware = (locationId: string, billId:string|undefined, prevState:any, formData: FormData) => { +const updateOrAddBillMiddleware = (locationId: string, billId:string|undefined, billYear:number|undefined, prevState:any, formData: FormData) => { // URL encode the file name of the attachment so it is correctly sent to the server const billAttachment = formData.get('billAttachment') as File; formData.set('billAttachment', billAttachment, encodeURIComponent(billAttachment.name)); - return updateOrAddBill(locationId, billId, prevState, formData); + return updateOrAddBill(locationId, billId, billYear, prevState, formData); } export interface BillEditFormProps { - locationID: string, - bill?: Bill + location: BillingLocation, + bill?: Bill, } -export const BillEditForm:FC = ({ locationID, bill }) => { +export const BillEditForm:FC = ({ location, bill }) => { const { _id: billID, name, paid, attachment, notes, payedAmount } = bill ?? { _id:undefined, name:"", paid:false, notes:"" }; + const { yearMonth:{year: billYear}, _id: locationID } = location; + const initialState = { message: null, errors: {} }; - const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID); + const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear); const [ state, dispatch ] = useFormState(handleAction, initialState); const [ isPaid, setIsPaid ] = React.useState(paid); // redirect to the main page const handleCancel = () => { - console.log('handleCancel'); - gotoHome(); + gotoHome(billYear ? `/?year=${billYear}` : undefined); }; const billPaid_handleChange = (event: React.ChangeEvent) => { @@ -44,6 +47,7 @@ export const BillEditForm:FC = ({ locationID, bill }) => { return(
+

{`${formatYearMonth(location.yearMonth)} ${location.name}`}

{ // don't show the delete button if we are adding a new bill @@ -66,13 +70,13 @@ export const BillEditForm:FC = ({ locationID, bill }) => { // attachment ? - + {decodeURIComponent(attachment.fileName)} : null } - +
{state.errors?.billAttachment && state.errors.billAttachment.map((error: string) => ( @@ -110,7 +114,7 @@ export const BillEditForm:FC = ({ locationID, bill }) => { } - +
{state.errors?.billNotes && state.errors.billNotes.map((error: string) => ( diff --git a/app/ui/LocationCard.tsx b/app/ui/LocationCard.tsx index 1fec078..68a207c 100644 --- a/app/ui/LocationCard.tsx +++ b/app/ui/LocationCard.tsx @@ -16,9 +16,9 @@ export const LocationCard:FC = ({location: { _id, name, yearM // sum all the billAmounts const monthlyExpense = bills.reduce((acc, bill) => acc + (bill.payedAmount ?? 0), 0); - + return( -
+
@@ -26,7 +26,7 @@ export const LocationCard:FC = ({location: { _id, name, yearM

{formatYearMonth(yearMonth)} {name}

{ - bills.map(bill => ) + bills.map(bill => ) } diff --git a/app/ui/LocationDeleteForm.tsx b/app/ui/LocationDeleteForm.tsx new file mode 100644 index 0000000..47737d9 --- /dev/null +++ b/app/ui/LocationDeleteForm.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { FC } from "react"; +import { BillingLocation } from "../lib/db-types"; +import { deleteLocationById } from "../lib/actions/locationActions"; +import { useFormState } from "react-dom"; +import { Main } from "./Main"; +import { gotoHome } from "../lib/actions/navigationActions"; + +export interface LocationDeleteFormProps { + /** location which should be deleted */ + location: BillingLocation +} + +export const LocationDeleteForm:FC = ({ location }) => +{ + const handleAction = deleteLocationById.bind(null, location._id, location.yearMonth); + const [ state, dispatch ] = useFormState(handleAction, null); + + const handleCancel = () => { + gotoHome(`/location/${location._id}/edit/`); + }; + + return( +
+
+
+ +

+ Please confirm deletion of location “{location.name}”. +

+
+ + +
+ +
+
+
+ ) +} diff --git a/app/ui/LocationEditForm.tsx b/app/ui/LocationEditForm.tsx index da81eb1..1f236cd 100644 --- a/app/ui/LocationEditForm.tsx +++ b/app/ui/LocationEditForm.tsx @@ -5,9 +5,9 @@ import { FC } from "react"; import { BillingLocation, YearMonth } from "../lib/db-types"; import { updateOrAddLocation } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; -import { gotoHome } from "../lib/actions/billActions"; import { Main } from "./Main"; import Link from "next/link"; +import { gotoHome } from "../lib/actions/navigationActions"; export interface LocationEditFormProps { /** location which should be edited */ @@ -25,7 +25,7 @@ export const LocationEditForm:FC = ({ location, yearMonth // redirect to the main page const handleCancel = () => { console.log('handleCancel'); - gotoHome(); + gotoHome(location ? `/?year=${location?.yearMonth?.year}` : undefined); }; return( diff --git a/app/ui/MonthlyExpensesCard.tsx b/app/ui/MonthlyExpensesCard.tsx new file mode 100644 index 0000000..40db12f --- /dev/null +++ b/app/ui/MonthlyExpensesCard.tsx @@ -0,0 +1,12 @@ +import { FC } from "react"; +import { formatCurrency } from "../lib/formatStrings"; + +export const MontlyExpensesCard:FC<{monthlyExpense:number}> = ({ monthlyExpense }) => + monthlyExpense>0 ? +
+ +

+ Total monthly expenditure: { formatCurrency(monthlyExpense) } +

+
+
: null \ No newline at end of file diff --git a/app/ui/Pagination.tsx b/app/ui/Pagination.tsx index 66b0e06..8316aa2 100644 --- a/app/ui/Pagination.tsx +++ b/app/ui/Pagination.tsx @@ -83,7 +83,7 @@ export default function Pagination({ availableYears } : { availableYears: number return (