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
* @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);
});

View File

@@ -138,5 +138,5 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati
// find a location with the given locationID
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 { 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);
}

View File

@@ -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<PageProps> = async ({ searchParams }) => {
return (
<main className="flex min-h-screen flex-col p-6 bg-base-300">
<MonthTitle yearMonth={currentYearMonth} />
<AddLocationButton yearMonth={currentYearMonth} />
<MonthCard yearMonth={currentYearMonth} key={`month-${currentYearMonth}`} monthlyExpense={0}>
<AddLocationButton yearMonth={currentYearMonth} />
</MonthCard>
<PageFooter />
</main>
);
}
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 (
<Main>
<AddMonthButton yearMonth={getNextYearMonth(locations[0].yearMonth)} />
{
// if this is the latest year, show the add month button
currentYear === latestYear &&
<AddMonthButton yearMonth={getNextYearMonth(locations[0].yearMonth)} />
}
{
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 (
<Fragment key={`location-${location._id}`}>
{
// show month title above the first LocationCard in the month
isFirstLocationInMonth ?
<MonthTitle yearMonth={location.yearMonth} /> : null
}
<LocationCard location={location} />
{
// show AddLocationButton as a last item in the first month
isLastLocationOfLatestMonth && isLatestYear ?
<AddLocationButton yearMonth={location.yearMonth} /> : null
}
{
isLastLocationInMonth ?
<MontlyExpensesCard monthlyExpense={monthlyExpense} /> : null
}
</Fragment>
)
})
Object.entries(months).map(([monthKey, { yearMonth, locations, monthlyExpense }], monthIx) =>
<MonthCard yearMonth={yearMonth} key={`month-${monthKey}`} monthlyExpense={monthlyExpense} expanded={ yearMonth.month === currentMonth } >
{
locations.map((location, ix) => <LocationCard key={`location-${location._id}`} location={location} />)
}
{
// show AddLocationButton as a last item in the first month
monthIx === 0 ? <AddLocationButton yearMonth={yearMonth} /> : null
}
</MonthCard>
)
}
<div className="mt-5 flex w-full justify-center">
<Pagination availableYears={availableYears} />

View File

@@ -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<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 handleCancel = () => {
gotoHome(`/?year=${location.yearMonth.year}`);
gotoHome(location.yearMonth);
};
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
// 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<BillEditFormProps> = ({ 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<boolean>(paid);
// redirect to the main page
const handleCancel = () => {
gotoHome(billYear ? `/?year=${billYear}` : undefined);
gotoHome(location.yearMonth);
};
const billPaid_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -45,7 +45,7 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
}
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">
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
<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>
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" />
{decodeURIComponent(attachment.fileName)}
</Link>
@@ -98,9 +98,9 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
{
isPaid && <>
<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>
<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>
</div>
<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 { 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<LocationDeleteFormProps> = ({ location }) =>
const [ state, dispatch ] = useFormState(handleAction, null);
const handleCancel = () => {
gotoHome(`/location/${location._id}/edit/`);
gotoUrl(`/location/${location._id}/edit/`);
};
return(

View File

@@ -24,8 +24,7 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ 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(

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);
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
}

View File

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