Merge branch 'release/1.4.0'

This commit is contained in:
2024-02-01 15:08:58 +01:00
13 changed files with 131 additions and 99 deletions

View File

@@ -109,7 +109,7 @@ const serializeAttachment = async (billAttachment: File | null) => {
* @param formData form data * @param formData form data
* @returns * @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; 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) => { 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]); 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; 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); return(post.modifiedCount);
}); });

View File

@@ -138,5 +138,5 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati
// find a location with the given locationID // find a location with the given locationID
const post = await dbClient.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId }); const post = await dbClient.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
await gotoHome(`/?year=${yearMonth?.year}`) await gotoHome(yearMonth)
}) })

View File

@@ -2,8 +2,15 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from 'next/navigation'; 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"); revalidatePath(path, "page");
redirect(path); redirect(path);
} }

View File

@@ -1,18 +1,14 @@
import { LocationCard } from './ui/LocationCard'; import { LocationCard } from './ui/LocationCard';
import { MonthTitle } from './ui/MonthTitle';
import { AddMonthButton } from './ui/AddMonthButton'; import { AddMonthButton } from './ui/AddMonthButton';
import { AddLocationButton } from './ui/AddLocationButton'; import { AddLocationButton } from './ui/AddLocationButton';
import { PageFooter } from './ui/PageFooter'; import { PageFooter } from './ui/PageFooter';
import { fetchAllLocations } from './lib/actions/locationActions'; import { fetchAllLocations } from './lib/actions/locationActions';
import { formatCurrency } from './lib/formatStrings';
import { fetchAvailableYears } from './lib/actions/monthActions'; import { fetchAvailableYears } from './lib/actions/monthActions';
import { YearMonth } from './lib/db-types'; import { BillingLocation, 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 Pagination from './ui/Pagination';
import { PageHeader } from './ui/PageHeader';
import { Main } from './ui/Main'; import { Main } from './ui/Main';
import { MontlyExpensesCard } from './ui/MonthlyExpensesCard'; import { MonthCard } from './ui/MonthCard';
const getNextYearMonth = (yearMonth:YearMonth) => { const getNextYearMonth = (yearMonth:YearMonth) => {
const {year, month} = yearMonth; const {year, month} = yearMonth;
@@ -25,6 +21,7 @@ const getNextYearMonth = (yearMonth:YearMonth) => {
export interface PageProps { export interface PageProps {
searchParams?: { searchParams?: {
year?: string; year?: string;
month?: string;
}; };
} }
@@ -51,64 +48,66 @@ const Page:FC<PageProps> = async ({ searchParams }) => {
return ( return (
<main className="flex min-h-screen flex-col p-6 bg-base-300"> <main className="flex min-h-screen flex-col p-6 bg-base-300">
<MonthTitle yearMonth={currentYearMonth} /> <MonthCard yearMonth={currentYearMonth} key={`month-${currentYearMonth}`} monthlyExpense={0}>
<AddLocationButton yearMonth={currentYearMonth} /> <AddLocationButton yearMonth={currentYearMonth} />
</MonthCard>
<PageFooter /> <PageFooter />
</main> </main>
); );
} }
const [ latestYear ] = availableYears;
const currentYear = Number(searchParams?.year) || availableYears[0]; const currentYear = Number(searchParams?.year) || availableYears[0];
const currentMonth = Number(searchParams?.month);
const locations = await fetchAllLocations(currentYear); 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 ( return (
<Main> <Main>
{
// if this is the latest year, show the add month button
currentYear === latestYear &&
<AddMonthButton yearMonth={getNextYearMonth(locations[0].yearMonth)} /> <AddMonthButton yearMonth={getNextYearMonth(locations[0].yearMonth)} />
}
{ {
locations.map((location, ix, array) => { Object.entries(months).map(([monthKey, { yearMonth, locations, monthlyExpense }], monthIx) =>
<MonthCard yearMonth={yearMonth} key={`month-${monthKey}`} monthlyExpense={monthlyExpense} expanded={ yearMonth.month === currentMonth } >
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 (
<Fragment key={`location-${location._id}`}>
{ {
// show month title above the first LocationCard in the month locations.map((location, ix) => <LocationCard key={`location-${location._id}`} location={location} />)
isFirstLocationInMonth ?
<MonthTitle yearMonth={location.yearMonth} /> : null
} }
<LocationCard location={location} />
{ {
// show AddLocationButton as a last item in the first month // show AddLocationButton as a last item in the first month
isLastLocationOfLatestMonth && isLatestYear ? monthIx === 0 ? <AddLocationButton yearMonth={yearMonth} /> : null
<AddLocationButton yearMonth={location.yearMonth} /> : null
} }
{ </MonthCard>
isLastLocationInMonth ?
<MontlyExpensesCard monthlyExpense={monthlyExpense} /> : null
}
</Fragment>
) )
})
} }
<div className="mt-5 flex w-full justify-center"> <div className="mt-5 flex w-full justify-center">
<Pagination availableYears={availableYears} /> <Pagination availableYears={availableYears} />

View File

@@ -2,7 +2,6 @@
import { FC } from "react"; import { FC } from "react";
import { Bill, BillingLocation } from "../lib/db-types"; import { Bill, BillingLocation } from "../lib/db-types";
import { deleteLocationById } from "../lib/actions/locationActions";
import { useFormState } from "react-dom"; import { useFormState } from "react-dom";
import { Main } from "./Main"; import { Main } from "./Main";
import { gotoHome } from "../lib/actions/navigationActions"; import { gotoHome } from "../lib/actions/navigationActions";
@@ -15,11 +14,11 @@ export interface BillDeleteFormProps {
export const BillDeleteForm:FC<BillDeleteFormProps> = ({ bill, location }) => export const BillDeleteForm:FC<BillDeleteFormProps> = ({ 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 [ state, dispatch ] = useFormState(handleAction, null);
const handleCancel = () => { const handleCancel = () => {
gotoHome(`/?year=${location.yearMonth.year}`); gotoHome(location.yearMonth);
}; };
return( return(

View File

@@ -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 // 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 // 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 // URL encode the file name of the attachment so it is correctly sent to the server
const billAttachment = formData.get('billAttachment') as File; const billAttachment = formData.get('billAttachment') as File;
formData.set('billAttachment', billAttachment, encodeURIComponent(billAttachment.name)); 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 { export interface BillEditFormProps {
@@ -27,17 +27,17 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
const { _id: billID, name, paid, attachment, notes, payedAmount } = bill ?? { _id:undefined, name:"", paid:false, notes:"" }; 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 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 [ state, dispatch ] = useFormState(handleAction, initialState);
const [ isPaid, setIsPaid ] = React.useState<boolean>(paid); const [ isPaid, setIsPaid ] = React.useState<boolean>(paid);
// redirect to the main page // redirect to the main page
const handleCancel = () => { const handleCancel = () => {
gotoHome(billYear ? `/?year=${billYear}` : undefined); gotoHome(location.yearMonth);
}; };
const billPaid_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const billPaid_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -45,7 +45,7 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
} }
return( return(
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s"> <div className="card card-compact card-bordered bg-base-100 shadow-s">
<div className="card-body"> <div className="card-body">
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2> <h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
<form action={ dispatch }> <form action={ dispatch }>
@@ -70,7 +70,7 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
// <textarea className="textarea textarea-bordered my-1 w-full max-w-sm block" placeholder="Opis" value="Pričuva, Voda, Smeće"></textarea> // <textarea className="textarea textarea-bordered my-1 w-full max-w-sm block" placeholder="Opis" value="Pričuva, Voda, Smeće"></textarea>
attachment ? attachment ?
<Link href={`/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[24em] text-nowrap truncate inline-block mt-4'> <Link href={`/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-4'>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" /> <DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{decodeURIComponent(attachment.fileName)} {decodeURIComponent(attachment.fileName)}
</Link> </Link>
@@ -98,9 +98,9 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
{ {
isPaid && <> isPaid && <>
<div className="form-control p-1"> <div className="form-control p-1">
<label className="cursor-pointer label p-0"> <label className="cursor-pointer label p-0 flex">
<span className="label-text flex-none w-[6.4em]">Amount</span> <span className="label-text flex-none w-[6.4em]">Amount</span>
<input type="text" id="payedAmount" name="payedAmount" className="input input-bordered text-right" placeholder="0.00" defaultValue={payedAmount === null || payedAmount === undefined ? undefined : payedAmount / 100}/> <input type="text" id="payedAmount" name="payedAmount" className="input input-bordered text-right w-full" placeholder="0.00" defaultValue={payedAmount === null || payedAmount === undefined ? undefined : payedAmount / 100}/>
</label> </label>
</div> </div>
<div id="status-error" aria-live="polite" aria-atomic="true"> <div id="status-error" aria-live="polite" aria-atomic="true">

View File

@@ -5,7 +5,7 @@ import { BillingLocation } from "../lib/db-types";
import { deleteLocationById } from "../lib/actions/locationActions"; import { deleteLocationById } from "../lib/actions/locationActions";
import { useFormState } from "react-dom"; import { useFormState } from "react-dom";
import { Main } from "./Main"; import { Main } from "./Main";
import { gotoHome } from "../lib/actions/navigationActions"; import { gotoUrl } from "../lib/actions/navigationActions";
export interface LocationDeleteFormProps { export interface LocationDeleteFormProps {
/** location which should be deleted */ /** location which should be deleted */
@@ -18,7 +18,7 @@ export const LocationDeleteForm:FC<LocationDeleteFormProps> = ({ location }) =>
const [ state, dispatch ] = useFormState(handleAction, null); const [ state, dispatch ] = useFormState(handleAction, null);
const handleCancel = () => { const handleCancel = () => {
gotoHome(`/location/${location._id}/edit/`); gotoUrl(`/location/${location._id}/edit/`);
}; };
return( return(

View File

@@ -24,8 +24,7 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth
// redirect to the main page // redirect to the main page
const handleCancel = () => { const handleCancel = () => {
console.log('handleCancel'); if(location) gotoHome(location?.yearMonth);
gotoHome(location ? `/?year=${location?.yearMonth?.year}` : undefined);
}; };
return( return(

48
app/ui/MonthCard.tsx Normal file
View File

@@ -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<MonthCardProps> = ({ yearMonth, children, monthlyExpense, expanded }) => {
const router = useRouter();
const elRef = useRef<HTMLDivElement>(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(
<div className="collapse collapse-plus bg-base-200 my-1" ref={elRef}>
<input type="radio" name="my-accordion-3" checked={expanded} onChange={handleChange} />
<div className="collapse-title text-xl font-medium">
{`${formatYearMonth(yearMonth)}`}
{
monthlyExpense>0 ?
<p className="text-xs font-medium">
Total monthly expenditure: <strong>{ formatCurrency(monthlyExpense) }</strong>
</p> : null
}
</div>
<div className="collapse-content">
{children}
</div>
</div>
)
};

View File

@@ -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<MonthTitleProps> = ({ yearMonth }) =>
<div className="divider text-2xl">{`${formatYearMonth(yearMonth)}`}</div>

View File

@@ -1,12 +0,0 @@
import { FC } from "react";
import { formatCurrency } from "../lib/formatStrings";
export const MontlyExpensesCard:FC<{monthlyExpense:number}> = ({ monthlyExpense }) =>
monthlyExpense>0 ?
<div className="card card-compact card-bordered max-w-[36em] bg-base-100 shadow-s my-1">
<span className="card-body self-center">
<p>
Total monthly expenditure: <strong>{ formatCurrency(monthlyExpense) }</strong>
</p>
</span>
</div> : null

View File

@@ -7,7 +7,7 @@ export default async function Page({ params:{ id } }: { params: { id:string } })
const { year, month } = parseYearMonth(id); const { year, month } = parseYearMonth(id);
await addMonth({ year, month }); 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 return null; // if we don't return anything, the client-side will not re-validate cache
} }

View File

@@ -9,7 +9,7 @@ networks:
services: services:
web-app: web-app:
image: utility-bills-tracker:1.3.2 image: utility-bills-tracker:1.4.0
networks: networks:
- traefik-network - traefik-network
- mongo-network - mongo-network