diff --git a/app/[locale]/bill/[id]/delete/page.tsx b/app/[locale]/bill/[id]/delete/page.tsx index 82edc3d..e6db2ff 100644 --- a/app/[locale]/bill/[id]/delete/page.tsx +++ b/app/[locale]/bill/[id]/delete/page.tsx @@ -1,6 +1,4 @@ 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'; import { Main } from '@/app/ui/Main'; diff --git a/app/[locale]/share/bill/[id]/not-found.tsx b/app/[locale]/share/bill/[id]/not-found.tsx new file mode 100644 index 0000000..d9d84b1 --- /dev/null +++ b/app/[locale]/share/bill/[id]/not-found.tsx @@ -0,0 +1,6 @@ +import { NotFoundPage } from '@/app/ui/NotFoundPage'; + +const BillNotFound = () => +; + +export default BillNotFound; diff --git a/app/[locale]/share/bill/[id]/page.tsx b/app/[locale]/share/bill/[id]/page.tsx new file mode 100644 index 0000000..7576a78 --- /dev/null +++ b/app/[locale]/share/bill/[id]/page.tsx @@ -0,0 +1,20 @@ +import { fetchBillById } from '@/app/lib/actions/billActions'; +import { ViewBillCard } from '@/app/ui/ViewBillCard'; +import { Main } from '@/app/ui/Main'; +import { notFound } from 'next/navigation'; + +export default async function Page({ params:{ id } }: { params: { id:string } }) { + + const [locationID, billID] = id.split('-'); + + const [location, bill] = await fetchBillById(locationID, billID) ?? []; + + if (!bill || !location) { + return(notFound()); + } + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/app/[locale]/share/location/[id]/LocationViewPage.tsx b/app/[locale]/share/location/[id]/LocationViewPage.tsx new file mode 100644 index 0000000..d155069 --- /dev/null +++ b/app/[locale]/share/location/[id]/LocationViewPage.tsx @@ -0,0 +1,13 @@ +import { ViewLocationCard } from '@/app/ui/ViewLocationCard'; +import { fetchLocationById } from '@/app/lib/actions/locationActions'; +import { notFound } from 'next/navigation'; + +export default async function LocationViewPage({ locationId }: { locationId:string }) { + const location = await fetchLocationById(locationId); + + if (!location) { + return(notFound()); + } + + return (); +} \ No newline at end of file diff --git a/app/[locale]/share/location/[id]/page.tsx b/app/[locale]/share/location/[id]/page.tsx new file mode 100644 index 0000000..bdbf265 --- /dev/null +++ b/app/[locale]/share/location/[id]/page.tsx @@ -0,0 +1,15 @@ +import { Suspense } from 'react'; +import LocationViewPage from './LocationViewPage'; +import { Main } from '@/app/ui/Main'; +import { LocationEditFormSkeleton } from '@/app/ui/LocationEditForm'; + +export default async function Page({ params:{ id } }: { params: { id:string } }) { + + return ( +
+ }> + + +
+ ); +} \ No newline at end of file diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index 0db48ab..757999b 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -207,8 +207,10 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI await gotoHome({ year: billYear, month: billMonth }); } }) +/* +Funkcija zamijenjena sa `fetchBillByUserAndId`, koja brže radi i ne treba korisnika -export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, includeAttachmentBinary:boolean = false) => { +export const fetchBillByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, includeAttachmentBinary:boolean = false) => { const { id: userId } = user; @@ -245,6 +247,43 @@ export const fetchBillById = withUser(async (user:AuthenticatedUser, locationID: return([billLocation, bill] as [BillingLocation, Bill]); }) +*/ + +export const fetchBillById = async (locationID:string, billID:string, includeAttachmentBinary:boolean = false) => { + + + const dbClient = await getDbClient(); + + // don't include the attachment binary data in the response + // if the attachment binary data is not needed + const projection = includeAttachmentBinary ? {} : { + "bills.attachment.fileContentsBase64": 0, + }; + + // find a location with the given locationID + const billLocation = await dbClient.collection("lokacije").findOne( + { + _id: locationID, + }, + { + projection + }) + + if(!billLocation) { + console.log(`Location ${locationID} not found`); + return(null); + } + + // find a bill with the given billID + const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); + + if(!bill) { + console.log('Bill not found'); + return(null); + } + + return([billLocation, bill] as [BillingLocation, Bill]); +}; export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number, month:number) => { diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 635acfb..fcf9439 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -132,7 +132,10 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu return(locations) }) -export const fetchLocationById = withUser(async (user:AuthenticatedUser, locationID:string) => { +/* +ova metoda je zamijenjena sa jednostavnijom `fetchLocationById`, koja brže radi jer ne provjerava korisnika + +export const fetchLocationByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string) => { noStore(); @@ -158,7 +161,34 @@ export const fetchLocationById = withUser(async (user:AuthenticatedUser, locatio } return(billLocation); -}) +}); +*/ + +export const fetchLocationById = async (locationID:string) => { + + noStore(); + + const dbClient = await getDbClient(); + + // find a location with the given locationID + const billLocation = await dbClient.collection("lokacije") + .findOne( + { _id: locationID }, + { + projection: { + // don't include the attachment binary data in the response + "bills.attachment.fileContentsBase64": 0, + }, + } + ); + + if(!billLocation) { + console.log(`Location ${locationID} not found`); + return(null); + } + + return(billLocation); +}; export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string, yearMonth:YearMonth) => { diff --git a/app/lib/auth.ts b/app/lib/auth.ts index 12ed2d3..57657a7 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -5,6 +5,23 @@ import { Session } from 'next-auth'; import { AuthenticatedUser } from './types/next-auth'; import { defaultLocale } from '../i18n'; +export const myAuth = () => { + + // Ovo koristim u developmentu + // + // const session:Session = { + // user: { + // id: "123", + // name: "Test User", + // }, + // expires: "123", + // }; + // + // return(Promise.resolve(session)); + + return(auth()); +} + export const authConfig: NextAuthConfig = { callbacks: { // method verifies if the user is logged in or not @@ -83,7 +100,7 @@ export const isAuthErrorMessage = (obj: any): obj is AuthErrorMessage => { } export const withUser = (fn: (user: AuthenticatedUser, ...args:A) => Promise) => async (...args:A) => { - const session = await auth(); + const session = await myAuth(); if(!session) { throw new Error("Not authenticated") diff --git a/app/ui/LocationCard.tsx b/app/ui/LocationCard.tsx index 822ecc8..62647db 100644 --- a/app/ui/LocationCard.tsx +++ b/app/ui/LocationCard.tsx @@ -1,13 +1,14 @@ 'use client'; -import { Cog8ToothIcon, PlusCircleIcon } from "@heroicons/react/24/outline"; +import { Cog8ToothIcon, PlusCircleIcon, LinkIcon } from "@heroicons/react/24/outline"; import { FC } from "react"; import { BillBadge } from "./BillBadge"; import { BillingLocation } from "../lib/db-types"; import { formatYearMonth } from "../lib/format"; import { formatCurrency } from "../lib/formatStrings"; import Link from "next/link"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; +import { toast, useToast } from "react-toastify"; export interface LocationCardProps { location: BillingLocation @@ -16,9 +17,19 @@ export interface LocationCardProps { export const LocationCard:FC = ({location: { _id, name, yearMonth, bills }}) => { const t = useTranslations("home-page.location-card"); + const currentLocale = useLocale(); // sum all the billAmounts const monthlyExpense = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); + + const handleCopyLinkClick = () => { + // copy URL to clipboard + const url = `${window.location.origin}/${currentLocale}/share/location/${_id}`; + navigator.clipboard.writeText(url); + + // use NextJS toast to notiy user that the link was copied + toast.success(t("link-copy-message"), {theme: "dark"}); + } return(
@@ -42,6 +53,8 @@ export const LocationCard:FC = ({location: { _id, name, yearM

: null } + +
); }; \ No newline at end of file diff --git a/app/ui/MonthLocationList.tsx b/app/ui/MonthLocationList.tsx index 6a59ad6..8555cc6 100644 --- a/app/ui/MonthLocationList.tsx +++ b/app/ui/MonthLocationList.tsx @@ -8,6 +8,8 @@ import Pagination from "./Pagination"; import { LocationCard } from "./LocationCard"; import { BillingLocation, YearMonth } from "../lib/db-types"; import { useRouter, useSearchParams } from "next/navigation"; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; const getNextYearMonth = (yearMonth:YearMonth) => { const {year, month} = yearMonth; @@ -88,6 +90,6 @@ export const MonthLocationList:React.FC = ({
- + ) } \ No newline at end of file diff --git a/app/ui/ViewBillBadge.tsx b/app/ui/ViewBillBadge.tsx new file mode 100644 index 0000000..d0ae494 --- /dev/null +++ b/app/ui/ViewBillBadge.tsx @@ -0,0 +1,21 @@ +import { FC } from "react" +import { Bill } from "@/app/lib/db-types" +import Link from "next/link" +import { DocumentIcon, TicketIcon } from "@heroicons/react/24/outline"; +import { useLocale } from "next-intl"; + +export interface ViewBillBadgeProps { + locationId: string, + bill: Bill +}; + +export const ViewBillBadge: FC = ({ locationId, bill: { _id: billId, name, paid, attachment } }) => { + + const currentLocale = useLocale(); + + return ( + + {name} + + ); +} \ No newline at end of file diff --git a/app/ui/ViewBillCard.tsx b/app/ui/ViewBillCard.tsx new file mode 100644 index 0000000..88e8350 --- /dev/null +++ b/app/ui/ViewBillCard.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { DocumentIcon, CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"; +import { Bill, BillingLocation } from "../lib/db-types"; +import React, { FC } from "react"; +import { updateOrAddBill } from "../lib/actions/billActions"; +import Link from "next/link"; +import { formatYearMonth } from "../lib/format"; +import { useLocale, useTranslations } from "next-intl"; + +// 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, 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, billMonth, prevState, formData); +} + +export interface ViewBillCardProps { + location: BillingLocation, + bill?: Bill, +} + +export const ViewBillCard:FC = ({ location, bill }) => { + + const t = useTranslations("bill-edit-form"); + const locale = useLocale(); + + const { _id: billID, name, paid, attachment, notes, payedAmount, barcodeImage } = bill ?? { _id:undefined, name:"", paid:false, notes:"" }; + + const { yearMonth:{year: billYear, month: billMonth}, _id: locationID } = location; + + + return( +
+
+

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

+ +

{name}

+
+

+ {t("paid-checkbox")} + {paid ? : } +

+ +

+ {t("payed-amount")} + {payedAmount ? payedAmount/100 : ""} +

+ + { + notes ? + +

{t("notes-placeholder")}

+

+ {notes} +

+
+ : null + } + { + attachment ? + +

{t("attachment")}

+ + + {decodeURIComponent(attachment.fileName)} + +
+ : null + } + { + barcodeImage ? +
+ +

{t.rich('barcode-disclaimer', { br: () =>
})}

+
: null + } + +
+ {t("back-button")} +
+ +
+
); +} \ No newline at end of file diff --git a/app/ui/ViewLocationCard.tsx b/app/ui/ViewLocationCard.tsx new file mode 100644 index 0000000..d6e9135 --- /dev/null +++ b/app/ui/ViewLocationCard.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { FC } from "react"; +import { BillingLocation } from "../lib/db-types"; +import { formatYearMonth } from "../lib/format"; +import { formatCurrency } from "../lib/formatStrings"; +import { useTranslations } from "next-intl"; +import { ViewBillBadge } from "./ViewBillBadge"; + +export interface ViewLocationCardProps { + location: BillingLocation +} + +export const ViewLocationCard:FC = ({location: { _id, name, yearMonth, bills }}) => { + + const t = useTranslations("home-page.location-card"); + + // sum all the billAmounts + const monthlyExpense = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); + + return( +
+
+

{formatYearMonth(yearMonth)} {name}

+
+ { + bills.map(bill => ) + } +
+ { + monthlyExpense > 0 ? +

+ { t("payed-total-label") } ${formatCurrency(monthlyExpense)} +

+ : null + } +
+
); +}; \ No newline at end of file diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml index 985aabf..990a2e0 100644 --- a/docker-compose-deploy.yml +++ b/docker-compose-deploy.yml @@ -13,7 +13,7 @@ networks: services: web-app: - image: utility-bills-tracker:1.30.0 + image: utility-bills-tracker:1.32.0 networks: - traefik-network - mongo-network diff --git a/messages/en.json b/messages/en.json index afa9020..5a5980d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -54,7 +54,8 @@ "location-card": { "edit-card-tooltip": "Edit realestate", "add-bill-button-tooltip": "Add a new bill", - "payed-total-label": "Payed total:" + "payed-total-label": "Payed total:", + "link-copy-message": "Link copied to clipboard" }, "month-card": { "payed-total-label": "Total monthly expenditure:" @@ -80,7 +81,9 @@ "not-a-number": "Not a number", "negative-number": "Value must be a positive number", "form-error-message": "Form validation error. Please check the form and try again." - } + }, + "attachment": "Attachment", + "back-button": "Back" }, "location-delete-form": { "text": "Please confirm deletion of realestate “{name}””.", diff --git a/messages/hr.json b/messages/hr.json index 2297d2b..e99d658 100644 --- a/messages/hr.json +++ b/messages/hr.json @@ -54,7 +54,8 @@ "location-card": { "edit-card-tooltip": "Izmjeni nekretninu", "add-bill-button-tooltip": "Dodaj novi račun", - "payed-total-label": "Ukupno plaćeno:" + "payed-total-label": "Ukupno plaćeno:", + "link-copy-message": "Link kopiran na clipboard" }, "month-card": { "payed-total-label": "Ukupni mjesečni trošak:" @@ -79,7 +80,9 @@ "not-a-number": "Vrijednost mora biti brojka", "negative-number": "Vrijednost mora biti veća od nule", "form-error-message": "Forma nije ispravno popunjena. Molimo provjeri, pa pokušaj ponovno" - } + }, + "attachment": "Privitak", + "back-button": "Nazad" }, "location-delete-form": { "text": "Molim potvrdi brisanje nekretnine “{name}””.", diff --git a/middleware.ts b/middleware.ts index afb06a9..9dcd075 100644 --- a/middleware.ts +++ b/middleware.ts @@ -3,12 +3,14 @@ * @description hooks-up `next-auth` into the page processing pipeline */ -import { auth, authConfig } from '@/app/lib/auth' +import { auth, authConfig, myAuth } from '@/app/lib/auth' import createIntlMiddleware from 'next-intl/middleware'; import { NextRequest, NextResponse } from 'next/server'; import { locales, defaultLocale } from '@/app/i18n'; +import { Session } from 'next-auth'; -const publicPages = ['/terms', '/policy', '/login']; +// http://localhost:3000/share/location/675c41b227d0df76a35f106e +const publicPages = ['/terms', '/policy', '/login', '/share/location/.*', '/share/bill/.*']; const intlMiddleware = createIntlMiddleware({ locales, @@ -30,7 +32,8 @@ export default async function middleware(req: NextRequest) { // based on https://github.com/nextauthjs/next-auth/discussions/8961 // The official way of chaining middlewares in AuthJS v5 does not work and is not fully documented if (!isPublicPage) { - const session = await auth(); + + const session = await myAuth(); if (!session) { const signInUrl = `${req.nextUrl.protocol}//${req.nextUrl.hostname}${req.nextUrl.port ? `:${req.nextUrl.port}` : ''}${authConfig.pages?.signIn as string}`; diff --git a/package-lock.json b/package-lock.json index 1c3da12..dd00143 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "rezije", + "name": "evidencija-rezija", "lockfileVersion": 3, "requires": true, "packages": { @@ -25,6 +25,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-infinite-scroll-component": "^6.1.0", + "react-toastify": "^10.0.6", "tailwindcss": "^3.4.0", "typescript": "5.2.2", "use-debounce": "^10.0.0", @@ -6447,6 +6448,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-toastify": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz", + "integrity": "sha512-yYjp+omCDf9lhZcrZHKbSq7YMuK0zcYkDFTzfRFgTXkTFHZ1ToxwAonzA4JI5CxA91JpjFLmwEsZEgfYfOqI1A==", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index e64b394..6b46a8a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-infinite-scroll-component": "^6.1.0", + "react-toastify": "^10.0.6", "tailwindcss": "^3.4.0", "typescript": "5.2.2", "use-debounce": "^10.0.0",