diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index cb84856..d5ed282 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -109,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, billYear:number|undefined, prevState:State, formData: FormData) => { +export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationId: string, billId:string|undefined, billYear:number|undefined, billMonth:number|undefined, prevState:State, formData: FormData) => { const { id: userId } = user; @@ -191,7 +191,9 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI } }); } - await gotoHome(billYear ? `/?year=${billYear}` : undefined); + if(billYear && billMonth ) { + await gotoHome({ year: billYear, month: billMonth }); + } }) export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string) => { @@ -219,7 +221,7 @@ export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID: return([billLocation, bill] as [BillingLocation, Bill]); }) -export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number) => { +export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number, month:number) => { const { id: userId } = user; @@ -240,6 +242,6 @@ export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID } }); - await gotoHome(`/?year=${year}`); + await gotoHome({year, month}); return(post.modifiedCount); }); \ No newline at end of file diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 36fadb9..b8d7a14 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -138,5 +138,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 }); - await gotoHome(`/?year=${yearMonth?.year}`) + await gotoHome(yearMonth) }) \ No newline at end of file diff --git a/app/lib/actions/navigationActions.ts b/app/lib/actions/navigationActions.ts index 444c801..258a9df 100644 --- a/app/lib/actions/navigationActions.ts +++ b/app/lib/actions/navigationActions.ts @@ -2,8 +2,15 @@ import { revalidatePath } from "next/cache"; import { redirect } from 'next/navigation'; +import { YearMonth } from "../db-types"; -export async function gotoHome(path: string = '/') { +export async function gotoHome({year, month}: YearMonth) { + const path = `/?year=${year}&month=${month}`; + await gotoUrl(path); +} + +export async function gotoUrl(path: string) { + console.log(path) revalidatePath(path, "page"); redirect(path); } diff --git a/app/page.tsx b/app/page.tsx index 79d745c..00f77ba 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,14 @@ import { LocationCard } from './ui/LocationCard'; -import { MonthTitle } from './ui/MonthTitle'; import { AddMonthButton } from './ui/AddMonthButton'; import { AddLocationButton } from './ui/AddLocationButton'; import { PageFooter } from './ui/PageFooter'; import { fetchAllLocations } from './lib/actions/locationActions'; -import { formatCurrency } from './lib/formatStrings'; import { fetchAvailableYears } from './lib/actions/monthActions'; -import { YearMonth } from './lib/db-types'; -import { formatYearMonth } from './lib/format'; -import { FC, Fragment } from 'react'; +import { BillingLocation, YearMonth } from './lib/db-types'; +import { FC } from 'react'; import Pagination from './ui/Pagination'; -import { PageHeader } from './ui/PageHeader'; import { Main } from './ui/Main'; -import { MontlyExpensesCard } from './ui/MonthlyExpensesCard'; +import { MonthCard } from './ui/MonthCard'; const getNextYearMonth = (yearMonth:YearMonth) => { const {year, month} = yearMonth; @@ -25,6 +21,7 @@ const getNextYearMonth = (yearMonth:YearMonth) => { export interface PageProps { searchParams?: { year?: string; + month?: string; }; } @@ -51,64 +48,66 @@ const Page:FC = async ({ searchParams }) => { return (
- - + + +
); } - const [ latestYear ] = availableYears; const currentYear = Number(searchParams?.year) || availableYears[0]; + const currentMonth = Number(searchParams?.month); + const locations = await fetchAllLocations(currentYear); - let monthlyExpense = 0; + // group locations by month + const months = locations.reduce((acc, location) => { + const {year, month} = location.yearMonth; + const key = `${year}-${month}`; + + const locationsInMonth = acc[key]; + + if(locationsInMonth) { + return({ + ...acc, + [key]: { + yearMonth: location.yearMonth, + locations: [...locationsInMonth.locations, location], + monthlyExpense: locationsInMonth.monthlyExpense + location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) + } + }) + } + + return({ + ...acc, + [key]: { + yearMonth: location.yearMonth, + locations: [location], + monthlyExpense: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) + } + }); + }, {} as {[key:string]:{ + yearMonth: YearMonth, + locations: BillingLocation[], + monthlyExpense: number + } }); return (
+ { - // if this is the latest year, show the add month button - currentYear === latestYear && - - } - { - locations.map((location, ix, array) => { - - const { year, month } = location.yearMonth - const { year: prevYear, month: prevMonth } = array[ix-1]?.yearMonth ?? { year: undefined, month: undefined }; - const { year: nextYear, month: nextMonth } = array[ix+1]?.yearMonth ?? { year: undefined, month: undefined }; - - const isLatestYear = year === latestYear; - const isFirstLocationInMonth = ix === 0 || year !== prevYear || month !== prevMonth; - const isLastLocationInMonth = year !== nextYear || month !== nextMonth; - const isLastLocationOfLatestMonth = isLastLocationInMonth && year === array[0].yearMonth.year && month === array[0].yearMonth.month - - if(isFirstLocationInMonth) { - monthlyExpense = 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 - } - - { - // show AddLocationButton as a last item in the first month - isLastLocationOfLatestMonth && isLatestYear ? - : null - } - { - isLastLocationInMonth ? - : null - } - - ) - }) + Object.entries(months).map(([monthKey, { yearMonth, locations, monthlyExpense }], monthIx) => + + { + locations.map((location, ix) => ) + } + { + // show AddLocationButton as a last item in the first month + monthIx === 0 ? : null + } + + ) }
diff --git a/app/ui/BillDeleteForm.tsx b/app/ui/BillDeleteForm.tsx index 1427625..df76fa0 100644 --- a/app/ui/BillDeleteForm.tsx +++ b/app/ui/BillDeleteForm.tsx @@ -2,7 +2,6 @@ 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"; @@ -15,11 +14,11 @@ export interface BillDeleteFormProps { export const BillDeleteForm:FC = ({ bill, location }) => { - const handleAction = deleteBillById.bind(null, location._id, bill._id, location.yearMonth.year); + const handleAction = deleteBillById.bind(null, location._id, bill._id, location.yearMonth.year, location.yearMonth.month); const [ state, dispatch ] = useFormState(handleAction, null); const handleCancel = () => { - gotoHome(`/?year=${location.yearMonth.year}`); + gotoHome(location.yearMonth); }; return( diff --git a/app/ui/BillEditForm.tsx b/app/ui/BillEditForm.tsx index 21ce677..443eb02 100644 --- a/app/ui/BillEditForm.tsx +++ b/app/ui/BillEditForm.tsx @@ -11,11 +11,11 @@ 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, billYear:number|undefined, prevState:any, formData: FormData) => { +const updateOrAddBillMiddleware = (locationId: string, billId:string|undefined, billYear:number|undefined, billMonth: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, billYear, prevState, formData); + return updateOrAddBill(locationId, billId, billYear, billMonth, prevState, formData); } export interface BillEditFormProps { @@ -27,17 +27,17 @@ 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 { yearMonth:{year: billYear, month: billMonth}, _id: locationID } = location; const initialState = { message: null, errors: {} }; - const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear); + const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth); const [ state, dispatch ] = useFormState(handleAction, initialState); const [ isPaid, setIsPaid ] = React.useState(paid); // redirect to the main page const handleCancel = () => { - gotoHome(billYear ? `/?year=${billYear}` : undefined); + gotoHome(location.yearMonth); }; const billPaid_handleChange = (event: React.ChangeEvent) => { diff --git a/app/ui/LocationDeleteForm.tsx b/app/ui/LocationDeleteForm.tsx index 47737d9..30fe546 100644 --- a/app/ui/LocationDeleteForm.tsx +++ b/app/ui/LocationDeleteForm.tsx @@ -5,7 +5,7 @@ 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"; +import { gotoUrl } from "../lib/actions/navigationActions"; export interface LocationDeleteFormProps { /** location which should be deleted */ @@ -18,7 +18,7 @@ export const LocationDeleteForm:FC = ({ location }) => const [ state, dispatch ] = useFormState(handleAction, null); const handleCancel = () => { - gotoHome(`/location/${location._id}/edit/`); + gotoUrl(`/location/${location._id}/edit/`); }; return( diff --git a/app/ui/LocationEditForm.tsx b/app/ui/LocationEditForm.tsx index 1f236cd..7c3b8b9 100644 --- a/app/ui/LocationEditForm.tsx +++ b/app/ui/LocationEditForm.tsx @@ -24,8 +24,7 @@ export const LocationEditForm:FC = ({ location, yearMonth // redirect to the main page const handleCancel = () => { - console.log('handleCancel'); - gotoHome(location ? `/?year=${location?.yearMonth?.year}` : undefined); + if(location) gotoHome(location?.yearMonth); }; return( diff --git a/app/ui/MonthCard.tsx b/app/ui/MonthCard.tsx new file mode 100644 index 0000000..b66db90 --- /dev/null +++ b/app/ui/MonthCard.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { FC, useEffect, useRef } from "react"; +import { formatYearMonth } from "../lib/format"; +import { YearMonth } from "../lib/db-types"; +import { formatCurrency } from "../lib/formatStrings"; +import { useRouter } from "next/navigation"; + +export interface MonthCardProps { + yearMonth: YearMonth, + children?: React.ReactNode, + monthlyExpense:number, + expanded?:boolean +} + +export const MonthCard:FC = ({ yearMonth, children, monthlyExpense, expanded }) => { + + const router = useRouter(); + const elRef = useRef(null); + + // setting the `month` will activate the accordion belonging to that month + const handleChange = (event:any) => router.push(`/?year=${yearMonth.year}&month=${yearMonth.month}`); + + useEffect(() => { + if(expanded && elRef.current) { + // if the element i selected > scroll it into view + elRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, []); + + return( +
+ +
+ {`${formatYearMonth(yearMonth)}`} + { + monthlyExpense>0 ? +

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

: null + } +
+
+ {children} +
+
+ ) +}; diff --git a/app/ui/MonthTitle.tsx b/app/ui/MonthTitle.tsx deleted file mode 100644 index ee46344..0000000 --- a/app/ui/MonthTitle.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { FC } from "react"; -import { formatYearMonth } from "../lib/format"; -import { YearMonth } from "../lib/db-types"; - -export interface MonthTitleProps { - yearMonth: YearMonth -} - -export const MonthTitle:FC = ({ yearMonth }) => -
{`${formatYearMonth(yearMonth)}`}
\ No newline at end of file diff --git a/app/ui/MonthlyExpensesCard.tsx b/app/ui/MonthlyExpensesCard.tsx deleted file mode 100644 index 40db12f..0000000 --- a/app/ui/MonthlyExpensesCard.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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/year-month/[id]/add/page.tsx b/app/year-month/[id]/add/page.tsx index 4725ee7..7a35d08 100644 --- a/app/year-month/[id]/add/page.tsx +++ b/app/year-month/[id]/add/page.tsx @@ -7,7 +7,7 @@ export default async function Page({ params:{ id } }: { params: { id:string } }) const { year, month } = parseYearMonth(id); await addMonth({ year, month }); - await gotoHome(`/?year=${year}`); + await gotoHome({ year, month }); return null; // if we don't return anything, the client-side will not re-validate cache } \ No newline at end of file