diff --git a/app/api/locations/available-years/route.ts b/app/api/locations/available-years/route.ts new file mode 100644 index 0000000..310dfed --- /dev/null +++ b/app/api/locations/available-years/route.ts @@ -0,0 +1,9 @@ +import { fetchAvailableYears } from '@/app/lib/actions/monthActions'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + + const availableYears = await fetchAvailableYears(); + + return NextResponse.json({ availableYears }); +} \ No newline at end of file diff --git a/app/api/locations/by-id/route.ts b/app/api/locations/by-id/route.ts new file mode 100644 index 0000000..605b5d6 --- /dev/null +++ b/app/api/locations/by-id/route.ts @@ -0,0 +1,13 @@ +import { fetchLocationById } from '@/app/lib/actions/locationActions'; +import type { NextApiRequest } from 'next' +import { NextResponse } from 'next/server'; + +export const GET = async ( + req: NextApiRequest, +) => { + const url = new URL(req.url as string); + const locationId = url.searchParams.get('id'); + const location = await fetchLocationById(locationId as string); + + return NextResponse.json({ location }); +} diff --git a/app/api/locations/in-year/route.ts b/app/api/locations/in-year/route.ts new file mode 100644 index 0000000..21179d9 --- /dev/null +++ b/app/api/locations/in-year/route.ts @@ -0,0 +1,14 @@ +import { fetchAllLocations } from '@/app/lib/actions/locationActions'; +import type { NextApiRequest } from 'next' +import { NextResponse } from 'next/server'; + +export const GET = async ( + req: NextApiRequest, +) => { + // get year from query params + const url = new URL(req.url as string); + const year = parseInt(url.searchParams.get('year') as string, 10); + const locations = await fetchAllLocations(year); + + return NextResponse.json({ locations }); +} diff --git a/app/lib/actions/monthActions.ts b/app/lib/actions/monthActions.ts index 7b3d843..03dcea8 100644 --- a/app/lib/actions/monthActions.ts +++ b/app/lib/actions/monthActions.ts @@ -70,7 +70,7 @@ export const fetchAvailableYears = withUser(async (user:AuthenticatedUser) => { const dbClient = await getDbClient(); // query mnogodb for all `yearMonth` values - const years = await dbClient.collection("lokacije") + const years:number[] = await dbClient.collection("lokacije") .distinct("yearMonth.year", { userId }) // sort the years in descending order diff --git a/app/location/[id]/add/LocationAddPage.tsx b/app/location/[id]/add/LocationAddPage.tsx index ebef49f..a099506 100644 --- a/app/location/[id]/add/LocationAddPage.tsx +++ b/app/location/[id]/add/LocationAddPage.tsx @@ -1,8 +1,7 @@ -import { notFound } from 'next/navigation'; +"use client"; + import { LocationEditForm } from '@/app/ui/LocationEditForm'; -import { fetchLocationById } from '@/app/lib/actions/locationActions'; import { YearMonth } from '@/app/lib/db-types'; -import { Main } from '@/app/ui/Main'; export default async function LocationAddPage({ yearMonth }: { yearMonth:YearMonth }) { return (); diff --git a/app/location/[id]/add/page.tsx b/app/location/[id]/add/page.tsx index fed90e0..87d0ddf 100644 --- a/app/location/[id]/add/page.tsx +++ b/app/location/[id]/add/page.tsx @@ -1,15 +1,16 @@ import { parseYearMonth } from '@/app/lib/format'; -import { LocationEditFormSkeleton } from '@/app/ui/LocationEditForm'; -import LocationAddPage from './LocationAddPage'; import { Main } from '@/app/ui/Main'; -import { Suspense } from 'react'; +import dynamic from 'next/dynamic' + +const LocationAddPage = dynamic( + () => import('./LocationAddPage'), + { ssr: false } + ) export default async function Page({ params:{ id } }: { params: { id:string } }) { return (
- }> - - +
); } \ No newline at end of file diff --git a/app/location/[id]/delete/LocationDeletePage.tsx b/app/location/[id]/delete/LocationDeletePage.tsx index 7377733..ec8663c 100644 --- a/app/location/[id]/delete/LocationDeletePage.tsx +++ b/app/location/[id]/delete/LocationDeletePage.tsx @@ -1,14 +1,54 @@ +"use client"; + import { notFound } from 'next/navigation'; -import { fetchLocationById } from '@/app/lib/actions/locationActions'; -import { LocationDeleteForm } from '@/app/ui/LocationDeleteForm'; +import { LocationDeleteForm, LocationDeleteFormSkeleton } from '@/app/ui/LocationDeleteForm'; +import { WithId } from 'mongodb'; +import { BillingLocation } from '@/app/lib/db-types'; +import { useEffect, useState } from 'react'; -export const LocationDeletePage = async ({ locationId }: { locationId:string }) => { +const fetchLocationById = async (locationId: string) => { + const response = await fetch(`/api/locations/by-id?id=${locationId}`); + const json = await response.json(); + return json.location as WithId; +} - const location = await fetchLocationById(locationId); +const LocationDeletePage = ({ locationId }: { locationId:string }) => { + + const [state, stateSet] = useState<{ + status: 'loading' | 'error' | 'success'; + location?: WithId; + error?: string; + }>({ status: 'loading' }); - if (!location) { - return(notFound()); + useEffect(() => { + + const fetchLocation = async () => { + try { + const location = await fetchLocationById(locationId); + stateSet({ location, status: 'success' }); + } catch(error:any) { + stateSet({ status: 'error', error: error.message }); + } + }; + + fetchLocation(); + + }, [locationId]); + + switch(state.status) { + case "error": + return(
Error: {state.error}
); + case "loading": + return(); + case "success": + if (!state.location) { + return(notFound()); + } + + return(); + default: + return(
Error: Unknown status
); } +} - return (); -} \ No newline at end of file +export default LocationDeletePage; \ No newline at end of file diff --git a/app/location/[id]/delete/page.tsx b/app/location/[id]/delete/page.tsx index 6ae17d5..ca0db96 100644 --- a/app/location/[id]/delete/page.tsx +++ b/app/location/[id]/delete/page.tsx @@ -1,17 +1,17 @@ -import { notFound } from 'next/navigation'; -import { fetchLocationById } from '@/app/lib/actions/locationActions'; -import { LocationDeleteForm } from '@/app/ui/LocationDeleteForm'; import { Main } from '@/app/ui/Main'; -import { Suspense } from 'react'; -import { LocationDeletePage } from './LocationDeletePage'; +import dynamic from 'next/dynamic' + +const LocationDeletePage = dynamic( + () => import('./LocationDeletePage'), + { ssr: false } + ) + export default async function Page({ params:{ id } }: { params: { id:string } }) { return (
- Loading...}> - - +
); } \ No newline at end of file diff --git a/app/location/[id]/edit/LocationEditPage.tsx b/app/location/[id]/edit/LocationEditPage.tsx index 505f634..df97fad 100644 --- a/app/location/[id]/edit/LocationEditPage.tsx +++ b/app/location/[id]/edit/LocationEditPage.tsx @@ -1,16 +1,53 @@ +"use client"; + import { notFound } from 'next/navigation'; -import { LocationEditForm } from '@/app/ui/LocationEditForm'; -import { fetchLocationById } from '@/app/lib/actions/locationActions'; +import { LocationEditForm, LocationEditFormSkeleton } from '@/app/ui/LocationEditForm'; +import { useEffect, useState } from 'react'; +import { WithId } from 'mongodb'; +import { BillingLocation } from '@/app/lib/db-types'; -export default async function LocationEditPage({ locationId }: { locationId:string }) { - const location = await fetchLocationById(locationId); +const fetchLocationById = async (locationId: string) => { + const response = await fetch(`/api/locations/by-id?id=${locationId}`); + const json = await response.json(); + return json.location as WithId; +} - if (!location) { - return(notFound()); +export default function LocationEditPage({ locationId }: { locationId:string }) { + + const [state, stateSet] = useState<{ + status: 'loading' | 'error' | 'success'; + location?: WithId; + error?: string; + }>({ status: 'loading' }); + + useEffect(() => { + + const fetchLocation = async () => { + try { + const location = await fetchLocationById(locationId); + stateSet({ location, status: 'success' }); + } catch(error:any) { + stateSet({ status: 'error', error: error.message }); + } + }; + + fetchLocation(); + + }, [locationId]); + + switch(state.status) { + case "error": + return(
Error: {state.error}
); + case "loading": + return(); + case "success": + if (!state.location) { + return(notFound()); + } + + return(); + default: + return(
Error: Unknown status
); } - - const result = ; - - return (result); } \ No newline at end of file diff --git a/app/location/[id]/edit/page.tsx b/app/location/[id]/edit/page.tsx index 251f944..0fb2160 100644 --- a/app/location/[id]/edit/page.tsx +++ b/app/location/[id]/edit/page.tsx @@ -1,15 +1,17 @@ -import { Suspense } from 'react'; -import LocationEditPage from './LocationEditPage'; import { Main } from '@/app/ui/Main'; -import { LocationEditFormSkeleton } from '@/app/ui/LocationEditForm'; +import dynamic from 'next/dynamic' + +const LocationEditPage = dynamic( + () => import('./LocationEditPage'), + { ssr: false } + ) + export default async function Page({ params:{ id } }: { params: { id:string } }) { return (
- }> - - +
); } \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index d321cd7..01601df 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,11 @@ import { FC, Suspense } from 'react'; import { Main } from './ui/Main'; -import HomePage from './ui/HomePage'; -import { HomePageSkeleton } from './ui/MonthCardSceleton'; +import dynamic from 'next/dynamic' + +const HomePage = dynamic( + () => import('./ui/HomePage'), + { ssr: false } +) export interface PageProps { searchParams?: { @@ -14,9 +18,7 @@ const Page:FC = async ({ searchParams }) => { return (
- }> - - +
); } diff --git a/app/ui/BillEditForm.tsx b/app/ui/BillEditForm.tsx index 500ca12..b08e84c 100644 --- a/app/ui/BillEditForm.tsx +++ b/app/ui/BillEditForm.tsx @@ -6,7 +6,6 @@ import React, { FC } from "react"; import { useFormState } from "react-dom"; 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 @@ -35,12 +34,7 @@ export const BillEditForm:FC = ({ location, bill }) => { const [ isPaid, setIsPaid ] = React.useState(paid); - // redirect to the main page - const handleCancel = () => { - gotoHome(location.yearMonth); - }; - - const billPaid_handleChange = (event: React.ChangeEvent) => { + const billPaid_handleChange = (event: React.ChangeEvent) => { setIsPaid(event.target.checked); } diff --git a/app/ui/HomePage.tsx b/app/ui/HomePage.tsx index 2f3f59a..262da6c 100644 --- a/app/ui/HomePage.tsx +++ b/app/ui/HomePage.tsx @@ -1,30 +1,118 @@ -import { fetchAllLocations } from '@/app/lib/actions/locationActions'; -import { fetchAvailableYears } from '@/app/lib/actions/monthActions'; +"use client"; + import { BillingLocation, YearMonth } from '@/app/lib/db-types'; -import { FC } from 'react'; +import { FC, useEffect, useState } from 'react'; import { MonthLocationList } from '@/app/ui/MonthLocationList'; +import { WithId } from 'mongodb'; +import { MonthCardSkeleton } from './MonthCardSkeleton'; +import { useSearchParams } from 'next/navigation'; export interface HomePageProps { - searchParams?: { - year?: string; - month?: string; - }; } -export const HomePage:FC = async ({ searchParams }) => { +type MonthsLocations = { + [key:string]:{ + yearMonth: YearMonth, + locations: BillingLocation[], + monthlyExpense: number + } +} - let availableYears: number[]; +const fetchAllLocations = async (year: number) => { + const response = await fetch(`/api/locations/in-year/?year=${year}`); + const { locations } : { locations: WithId[] } = await response.json(); + return locations; +} - // const asyncTimout = (ms:number) => new Promise(resolve => setTimeout(resolve, ms)); - // await asyncTimout(5000); +const fetchAvailableYears = async () => { + const response = await fetch(`/api/locations/available-years/`); + const { availableYears }: { availableYears: number[]} = await response.json(); + return availableYears; +} - try { - availableYears = await fetchAvailableYears(); - } catch (error:any) { +export const HomePage:FC = () => { + + const searchParams = useSearchParams(); + const year = searchParams.get('year'); + const currentYear = year ? parseInt(year, 10) : new Date().getFullYear(); + + const [ homePageStatus, setHomePageStatus ] = useState<{ + status: "loading" | "loaded" | "error", + availableYears: number[], + months?: MonthsLocations, + error?: string + }>({ + status: "loading", + availableYears: [], + }); + + const {availableYears, months, status, error} = homePageStatus; + + useEffect(() => { + + const fetchData = async () => { + + try { + const locations = await fetchAllLocations(currentYear); + + // 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 MonthsLocations); + + setHomePageStatus({ + availableYears: await fetchAvailableYears(), + months, + status: "loaded", + }); + + } catch (error: any) { + setHomePageStatus({ + status: "error", + availableYears: [], + error: error.message + }); + } + } + + fetchData(); + }, [currentYear]); + + if(status === "loading") { return ( -
-

{error.message}

-
); + <> + + + + + ); + } + + if(status === "error") { + return(

{error}

); } // if the database is in it's initial state, show the add location button for the current month @@ -32,42 +120,6 @@ export const HomePage:FC = async ({ searchParams }) => { return (); } - const currentYear = Number(searchParams?.year) || availableYears[0]; - - const locations = await fetchAllLocations(currentYear); - - // 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 ( ); diff --git a/app/ui/LocationDeleteForm.tsx b/app/ui/LocationDeleteForm.tsx index 35eb104..7f6651d 100644 --- a/app/ui/LocationDeleteForm.tsx +++ b/app/ui/LocationDeleteForm.tsx @@ -29,11 +29,23 @@ export const LocationDeleteForm:FC = ({ location }) => Please confirm deletion of location “{location.name}”.

- - Cancel + + Cancel
); } + + +export const LocationDeleteFormSkeleton:FC = () => +
+
+

+
+
+
+
+
+
\ No newline at end of file diff --git a/app/ui/LocationEditForm.tsx b/app/ui/LocationEditForm.tsx index 9b4aa27..b1fdc29 100644 --- a/app/ui/LocationEditForm.tsx +++ b/app/ui/LocationEditForm.tsx @@ -6,7 +6,6 @@ import { BillingLocation, YearMonth } from "../lib/db-types"; import { updateOrAddLocation } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; import Link from "next/link"; -import { gotoHome } from "../lib/actions/navigationActions"; export type LocationEditFormProps = { /** location which should be edited */ @@ -48,7 +47,7 @@ export const LocationEditForm:FC = ({ location, yearMonth ))} - +
{state.errors?.locationNotes && state.errors.locationNotes.map((error: string) => ( @@ -66,10 +65,9 @@ export const LocationEditForm:FC = ({ location, yearMonth

}
-
- - Cancel + + Cancel
@@ -80,10 +78,14 @@ export const LocationEditForm:FC = ({ location, yearMonth export const LocationEditFormSkeleton:FC = () => { return( -
+
- - +
+
+
+
+
+
) diff --git a/app/ui/MonthCardSceleton.tsx b/app/ui/MonthCardSkeleton.tsx similarity index 77% rename from app/ui/MonthCardSceleton.tsx rename to app/ui/MonthCardSkeleton.tsx index bc88b4b..d074358 100644 --- a/app/ui/MonthCardSceleton.tsx +++ b/app/ui/MonthCardSkeleton.tsx @@ -15,18 +15,9 @@ export interface MonthCardSkeletonProps { export const MonthCardSkeleton: React.FC = ({checked=false}) =>
-
-
+
; - - -export const HomePageSkeleton: React.FC = () => -<> - - - -; \ No newline at end of file diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml index a39518f..3dae28e 100644 --- a/docker-compose-deploy.yml +++ b/docker-compose-deploy.yml @@ -9,7 +9,7 @@ networks: services: web-app: - image: utility-bills-tracker:1.8.0 + image: utility-bills-tracker:1.9.0 networks: - traefik-network - mongo-network