From e9ade045d8ed1add86286e6c259f090994418ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Dere=C5=BEi=C4=87?= Date: Thu, 18 Dec 2025 14:59:11 +0100 Subject: [PATCH 1/3] feat: add separate unpaid and paid bill totals to location cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Display both unpaid and paid bill amounts in LocationCard and MonthCard - Rename variables for clarity: totalUnpaid, totalPayed, unpaidTotal, payedTotal - ViewLocationCard uses totalAmount for tenant bills (regardless of payment status) - Update Croatian translations: "Ukupno neplaćeno" (unpaid), "Ukupno plaćeno" (paid) - Add ShoppingCartIcon for unpaid amounts, BanknotesIcon for paid amounts - Update HomePage to calculate and pass both totals to month cards 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- app/ui/HomePage.tsx | 9 ++++++--- app/ui/LocationCard.tsx | 24 +++++++++++++++++------- app/ui/MonthCard.tsx | 15 +++++++++++---- app/ui/MonthLocationList.tsx | 9 +++++---- app/ui/ViewLocationCard.tsx | 10 +++++----- messages/en.json | 6 ++++-- messages/hr.json | 6 ++++-- 7 files changed, 52 insertions(+), 27 deletions(-) diff --git a/app/ui/HomePage.tsx b/app/ui/HomePage.tsx index d1f152d..2932d5f 100644 --- a/app/ui/HomePage.tsx +++ b/app/ui/HomePage.tsx @@ -51,7 +51,8 @@ export const HomePage:FC = async ({ searchParams }) => { [key]: { yearMonth: location.yearMonth, locations: [...locationsInMonth.locations, location], - monthlyExpense: locationsInMonth.monthlyExpense + location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) + unpaidTotal: locationsInMonth.unpaidTotal + location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0), + payedTotal: locationsInMonth.payedTotal + location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) } }) } @@ -61,13 +62,15 @@ export const HomePage:FC = async ({ searchParams }) => { [key]: { yearMonth: location.yearMonth, locations: [location], - monthlyExpense: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) + unpaidTotal: location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0), + payedTotal: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0) } }); }, {} as {[key:string]:{ yearMonth: YearMonth, locations: BillingLocation[], - monthlyExpense: number + unpaidTotal: number, + payedTotal: number } }); return ( diff --git a/app/ui/LocationCard.tsx b/app/ui/LocationCard.tsx index c2e88d9..0adf88d 100644 --- a/app/ui/LocationCard.tsx +++ b/app/ui/LocationCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon } from "@heroicons/react/24/outline"; import { FC } from "react"; import { BillBadge } from "./BillBadge"; import { BillingLocation } from "../lib/db-types"; @@ -9,7 +9,6 @@ import { formatCurrency } from "../lib/formatStrings"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { toast } from "react-toastify"; -import { get } from "http"; import { generateShareLink } from "../lib/actions/locationActions"; export interface LocationCardProps { @@ -31,8 +30,9 @@ export const LocationCard: FC = ({ location, currency }) => { const t = useTranslations("home-page.location-card"); const currentLocale = useLocale(); - // sum all the paid bill amounts (regardless of who pays) - const monthlyExpense = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); + // sum all the unpaid and paid bill amounts (regardless of who pays) + const totalUnpaid = bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); + const totalPayed = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0); const handleCopyLinkClick = async () => { // copy URL to clipboard @@ -69,17 +69,27 @@ export const LocationCard: FC = ({ location, currency }) => { - { monthlyExpense > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ? + { totalUnpaid > 0 || totalPayed > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ? <>
{ - monthlyExpense > 0 ? + totalUnpaid > 0 ? +
+ + + {t("total-due-label")} {formatCurrency(totalUnpaid, currency ?? "EUR")} + +
+ : null + } + { + totalPayed > 0 ?
- {t("payed-total-label")} {formatCurrency(monthlyExpense, currency ?? "EUR")} + {t("total-payed-label")} {formatCurrency(totalPayed, currency ?? "EUR")}
diff --git a/app/ui/MonthCard.tsx b/app/ui/MonthCard.tsx index 4e8120e..6b7c702 100644 --- a/app/ui/MonthCard.tsx +++ b/app/ui/MonthCard.tsx @@ -9,13 +9,14 @@ import { useTranslations } from "next-intl"; export interface MonthCardProps { yearMonth: YearMonth, children?: React.ReactNode, - monthlyExpense:number, + unpaidTotal: number, + payedTotal: number, currency?: string | null, expanded?:boolean, onToggle: (yearMonth:YearMonth) => void } -export const MonthCard:FC = ({ yearMonth, children, monthlyExpense, currency, expanded, onToggle }) => { +export const MonthCard:FC = ({ yearMonth, children, unpaidTotal, payedTotal, currency, expanded, onToggle }) => { const elRef = useRef(null); const t = useTranslations("home-page.month-card"); @@ -37,9 +38,15 @@ export const MonthCard:FC = ({ yearMonth, children, monthlyExpen
{`${formatYearMonth(yearMonth)}`} { - monthlyExpense>0 ? + unpaidTotal>0 ?

- {t("payed-total-label")} { formatCurrency(monthlyExpense, currency ?? "EUR") } + {t("total-due-label")} { formatCurrency(unpaidTotal, currency ?? "EUR") } +

: null + } + { + payedTotal>0 ? +

+ {t("total-payed-label")} { formatCurrency(payedTotal, currency ?? "EUR") }

: null }
diff --git a/app/ui/MonthLocationList.tsx b/app/ui/MonthLocationList.tsx index 00668ce..ea2131e 100644 --- a/app/ui/MonthLocationList.tsx +++ b/app/ui/MonthLocationList.tsx @@ -27,7 +27,8 @@ export interface MonthLocationListProps { [key: string]: { yearMonth: YearMonth; locations: BillingLocation[]; - monthlyExpense: number; + payedTotal: number; + unpaidTotal: number; }; }; userSettings?: UserSettings | null; @@ -93,7 +94,7 @@ export const MonthLocationList:React.FC = ({ return( <> - {}} expanded={true} > + {}} expanded={true} > ) @@ -117,8 +118,8 @@ export const MonthLocationList:React.FC = ({ return(<> { - monthsArray.map(([monthKey, { yearMonth, locations, monthlyExpense }], monthIx) => - + monthsArray.map(([monthKey, { yearMonth, locations, unpaidTotal, payedTotal }], monthIx) => + { yearMonth.month === expandedMonth ? locations.map((location, ix) => ) diff --git a/app/ui/ViewLocationCard.tsx b/app/ui/ViewLocationCard.tsx index d7ed6cc..e0fd0b0 100644 --- a/app/ui/ViewLocationCard.tsx +++ b/app/ui/ViewLocationCard.tsx @@ -86,7 +86,7 @@ export const ViewLocationCard: FC = ({ location, userSett }; // sum all the billAmounts (only for bills billed to tenant) - const monthlyExpense = bills.reduce((acc, bill) => (bill.paid && (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant) ? acc + (bill.payedAmount ?? 0) : acc, 0); + const totalAmount = bills.reduce((acc, bill) => (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant ? acc + (bill.payedAmount ?? 0) : acc, 0); const { hub3aText, paymentParams } = useMemo(() => { @@ -100,7 +100,7 @@ export const ViewLocationCard: FC = ({ location, userSett const locationNameTrimmed_max20 = locationName.trimEnd().trimEnd().substring(0, 19); const paymentParams: PaymentParams = { - Iznos: (monthlyExpense / 100).toFixed(2).replace(".", ","), + Iznos: (totalAmount / 100).toFixed(2).replace(".", ","), ImePlatitelja: tenantName ?? "", AdresaPlatitelja: tenantStreet ?? "", SjedistePlatitelja: tenantTown ?? "", @@ -132,9 +132,9 @@ export const ViewLocationCard: FC = ({ location, userSett }
{ - monthlyExpense > 0 ? + totalAmount > 0 ?

- {t("payed-total-label")} {formatCurrency(monthlyExpense, userSettings?.currency)} + {t("total-due-label")} {formatCurrency(totalAmount, userSettings?.currency)}

: null } @@ -160,7 +160,7 @@ export const ViewLocationCard: FC = ({ location, userSett } { userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => { - const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(monthlyExpense).toFixed(0)}¤cy=${userSettings.currency}`; + const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(totalAmount).toFixed(0)}¤cy=${userSettings.currency}`; return ( <>

{t("payment-info-header")}

diff --git a/messages/en.json b/messages/en.json index 31d4b0c..fd95348 100644 --- a/messages/en.json +++ b/messages/en.json @@ -61,7 +61,8 @@ "location-card": { "edit-card-tooltip": "Edit realestate", "add-bill-button-tooltip": "Add a new bill", - "payed-total-label": "Payed total:", + "total-due-label": "Total due:", + "total-payed-label": "Total payed:", "link-copy-message": "Link copied to clipboard", "monthly-statement-legend": "Monthly statement", "seen-by-tenant-label": "seen by tenant", @@ -81,7 +82,8 @@ "revolut-link-text": "Pay with Revolut" }, "month-card": { - "payed-total-label": "Total monthly expenditure:", + "total-due-label": "Monthly due total:", + "total-payed-label": "Monthly payed total:", "print-codes-tooltip": "Print 2D codes", "print-codes-label": "Print codes" }, diff --git a/messages/hr.json b/messages/hr.json index a55fac4..583889d 100644 --- a/messages/hr.json +++ b/messages/hr.json @@ -61,7 +61,8 @@ "location-card": { "edit-card-tooltip": "Izmjeni nekretninu", "add-bill-button-tooltip": "Dodaj novi račun", - "payed-total-label": "Ukupno plaćeno:", + "total-due-label": "Ukupno neplaćeno:", + "total-payed-label": "Ukupno plaćeno:", "link-copy-message": "Link kopiran na clipboard", "monthly-statement-legend": "Obračun", "seen-by-tenant-label": "viđeno od strane podstanara", @@ -81,7 +82,8 @@ "revolut-link-text": "Plati pomoću Revoluta" }, "month-card": { - "payed-total-label": "Ukupni mjesečni trošak:", + "total-due-label": "Ukupno neplaćeno u mjesecu:", + "total-payed-label": "Ukupno plaćeno u mjesecu:", "print-codes-tooltip": "Ispis 2d kodova", "print-codes-label": "Ispis kodova" }, From 0145a2030d4bc0d4166a11b3b5751dd397e14126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Dere=C5=BEi=C4=87?= Date: Thu, 18 Dec 2025 17:45:27 +0100 Subject: [PATCH 2/3] feat: add multi-bill-edit page for batch bill status updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getLocationsByMonth server action with aggregation pipeline to calculate hasAttachment - Add updateMonth server action for bulk bill status updates with path revalidation - Create multi-bill-edit page at /home/multi-bill-edit/[year]/[month] - Implement MultiBillEdit component with toggle functionality for all bills - Add BillToggleBadge component integration for consistent bill display - Add "set all as paid/unpaid" toggle button for batch operations - Implement server-side redirect with success message after save - Add Suspense boundary with loading skeleton - Update translations for multi-bill-edit feature (Croatian and English) - Ensure data freshness with unstable_noStore and revalidatePath 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../[year]/[month]/BillToggleBadge.tsx | 24 +++ .../[year]/[month]/MultiBillEdit.tsx | 162 ++++++++++++++++++ .../[year]/[month]/MultiBillEditButton.tsx | 26 +++ .../[year]/[month]/MultiBillEditPage.tsx | 16 ++ .../[year]/[month]/not-found.tsx | 6 + .../multi-bill-edit/[year]/[month]/page.tsx | 21 +++ app/lib/actions/monthActions.ts | 138 ++++++++++++++- app/ui/MonthLocationList.tsx | 8 + messages/en.json | 33 ++-- messages/hr.json | 33 ++-- 10 files changed, 442 insertions(+), 25 deletions(-) create mode 100644 app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx create mode 100644 app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx create mode 100644 app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx create mode 100644 app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx create mode 100644 app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx create mode 100644 app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx b/app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx new file mode 100644 index 0000000..887b269 --- /dev/null +++ b/app/[locale]/home/multi-bill-edit/[year]/[month]/BillToggleBadge.tsx @@ -0,0 +1,24 @@ +import { FC } from "react" +import { Bill } from "@/app/lib/db-types" +import { TicketIcon } from "@heroicons/react/24/outline" + +export interface BillBadgeProps { + locationId: string, + bill: Pick, + onClick?: () => void +}; + +export const BillToggleBadge:FC = ({ bill: { name, paid, hasAttachment, proofOfPayment }, onClick}) => { + + const className = `badge badge-lg ${paid?"badge-success":" badge-outline"} ${ !paid && hasAttachment ? "btn-outline btn-success" : "" } cursor-pointer`; + + return ( +
+ {name} + { + proofOfPayment?.uploadedAt ? + : null + } +
+ ); +} \ No newline at end of file diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx new file mode 100644 index 0000000..cf483d5 --- /dev/null +++ b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { FC, useState } from "react"; +import { BillingLocation, YearMonth } from "../../../../../lib/db-types"; +import { formatYearMonth } from "../../../../../lib/format"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { updateMonth } from "../../../../../lib/actions/monthActions"; +import { toast, ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { BillToggleBadge } from "./BillToggleBadge"; + +export interface MultiBillEditProps { + locations: BillingLocation[]; + year: number; + month: number; +} + +interface BillState { + locationId: string; + billId: string; + paid: boolean; +} + +export const MultiBillEdit: FC = ({ locations, year, month }) => { + const t = useTranslations("multi-bill-edit"); + const router = useRouter(); + + // Initialize bill states from locations + const initialBillStates: BillState[] = locations.flatMap(location => + location.bills.map(bill => ({ + locationId: location._id, + billId: bill._id, + paid: bill.paid, + })) + ); + + const [billStates, setBillStates] = useState(initialBillStates); + const [isSaving, setIsSaving] = useState(false); + const [allPaidMode, setAllPaidMode] = useState(false); + + // Toggle individual bill paid status + const handleBillToggle = (locationId: string, billId: string) => { + setBillStates(prevStates => + prevStates.map(state => + state.locationId === locationId && state.billId === billId + ? { ...state, paid: !state.paid } + : state + ) + ); + }; + + // Toggle all bills paid status + const handleSetAllAsPayed = () => { + const newPaidState = !allPaidMode; + setAllPaidMode(newPaidState); + setBillStates(prevStates => + prevStates.map(state => ({ ...state, paid: newPaidState })) + ); + }; + + // Save changes to database + const handleSave = async () => { + setIsSaving(true); + try { + const updates = billStates.map(state => ({ + locationId: state.locationId, + billId: state.billId, + paid: state.paid, + })); + + await updateMonth({ year, month }, updates); + + } catch (error) { + console.error('Error saving bill updates:', error); + toast.error(t("save-error-message"), { theme: "dark" }); + setIsSaving(false); + } + }; + + // Cancel and return to home page + const handleCancel = () => { + router.push(`/home?year=${year}`); + }; + + // Get bill state for a specific bill + const getBillState = (locationId: string, billId: string): boolean => { + const state = billStates.find( + s => s.locationId === locationId && s.billId === billId + ); + return state?.paid ?? false; + }; + + const yearMonth: YearMonth = { year, month }; + + return ( +
+

{formatYearMonth(yearMonth)}

+
+ +
+
+ {locations.map(location => ( +
+
+

+ {formatYearMonth(yearMonth)} {location.name} +

+
+ {location.bills.length > 0 ? ( +
+ {location.bills.map(bill => { + const isPaid = getBillState(location._id, bill._id); + return ( + handleBillToggle(location._id, bill._id)} + /> + ); + })} +
+ ) : ( +

{t("no-bills-message")}

+ )} +
+
+
+ ))} + {/* Action buttons */} +
+ + +
+
+ +
+ ); +}; diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx new file mode 100644 index 0000000..de8f248 --- /dev/null +++ b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { InboxStackIcon, Square3Stack3DIcon } from '@heroicons/react/24/outline'; +import { useTranslations } from 'next-intl'; +import { YearMonth } from '../../../../../lib/db-types'; +import Link from 'next/link'; + +export interface MultiBillEditButtonProps { + yearMonth: YearMonth; +} + +export const MultiBillEditButton: React.FC = ({ yearMonth }) => { + + const t = useTranslations("home-page.multi-bill-edit-button"); + + return ( +
+ + + + {t("tooltip")} + + +
+ ); +}; \ No newline at end of file diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx new file mode 100644 index 0000000..8218b0a --- /dev/null +++ b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditPage.tsx @@ -0,0 +1,16 @@ +import { notFound } from 'next/navigation'; +import { MultiBillEdit } from '@/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit'; +import { getLocationsByMonth } from '@/app/lib/actions/monthActions'; + +export default async function MultiBillEditPage({ year, month }: { year: number; month: number }) { + + const locations = await getLocationsByMonth({ year, month }); + + if (!locations || locations.length === 0) { + return(notFound()); + } + + const result = ; + + return (result); +} diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx b/app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx new file mode 100644 index 0000000..794ae95 --- /dev/null +++ b/app/[locale]/home/multi-bill-edit/[year]/[month]/not-found.tsx @@ -0,0 +1,6 @@ +import { NotFoundPage } from '@/app/ui/NotFoundPage'; + +const MultiBillEditNotFound = () => +; + +export default MultiBillEditNotFound; \ No newline at end of file diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx b/app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx new file mode 100644 index 0000000..de3a4f0 --- /dev/null +++ b/app/[locale]/home/multi-bill-edit/[year]/[month]/page.tsx @@ -0,0 +1,21 @@ +import { Suspense } from 'react'; +import MultiBillEditPage from './MultiBillEditPage'; +import { Main } from '@/app/ui/Main'; + +const MultiBillEditSkeleton = () => ( +
+ +

Loading...

+
+); + +export default async function Page({ params }: { params: { year: string; month: string } }) { + + return ( +
+ }> + + +
+ ); +} diff --git a/app/lib/actions/monthActions.ts b/app/lib/actions/monthActions.ts index 2b71f8d..d71def5 100644 --- a/app/lib/actions/monthActions.ts +++ b/app/lib/actions/monthActions.ts @@ -5,7 +5,9 @@ import { ObjectId } from 'mongodb'; import { Bill, BillingLocation, YearMonth } from '../db-types'; import { AuthenticatedUser } from '../types/next-auth'; import { withUser } from '../auth'; -import { unstable_noStore as noStore } from 'next/cache'; +import { unstable_noStore as noStore, unstable_noStore, revalidatePath } from 'next/cache'; +import { getLocale } from 'next-intl/server'; +import { gotoHomeWithMessage } from './navigationActions'; /** * Server-side action which adds a new month to the database @@ -82,3 +84,137 @@ export const fetchAvailableYears = withUser(async (user:AuthenticatedUser) => { return(sortedYears); }) + +/** + * Fetches all locations for a specific month for the authenticated user + * Only projects essential fields needed for the multi-bill-edit page + * @param yearMonth - The year and month to fetch + * @returns Array of locations with minimal bill data + */ +export const getLocationsByMonth = withUser(async (user: AuthenticatedUser, yearMonth: YearMonth) => { + + unstable_noStore(); + + const { id: userId } = user; + const dbClient = await getDbClient(); + + // Use aggregation pipeline to calculate hasAttachment field + const locations = await dbClient.collection("lokacije") + .aggregate([ + { + $match: { + userId, + yearMonth: { + year: yearMonth.year, + month: yearMonth.month, + } + } + }, + { + $addFields: { + _id: { $toString: "$_id" }, + bills: { + $map: { + input: "$bills", + as: "bill", + in: { + _id: { $toString: "$$bill._id" }, + name: "$$bill.name", + paid: "$$bill.paid", + hasAttachment: { $ne: ["$$bill.attachment", null] }, + proofOfPayment: "$$bill.proofOfPayment", + }, + }, + } + } + }, + { + $project: { + "_id": 1, + "name": 1, + "yearMonth.year": 1, + "yearMonth.month": 1, + "bills._id": 1, + "bills.name": 1, + "bills.paid": 1, + "bills.hasAttachment": 1, + "bills.proofOfPayment.uploadedAt": 1, + } + }, + { + $sort: { + name: 1, + }, + }, + ]) + .toArray(); + + return locations as Array; +}); + +/** + * Updates the paid status of bills for locations in a specific month + * @param yearMonth - The year and month to update + * @param updates - Array of updates with locationId, billId, and paid status + * @returns Success status + */ +export const updateMonth = withUser(async ( + user: AuthenticatedUser, + yearMonth: YearMonth, + updates: Array<{ locationId: string; billId: string; paid: boolean }> +) => { + unstable_noStore(); + + const { id: userId } = user; + const dbClient = await getDbClient(); + + // Group updates by location to minimize database operations + const updatesByLocation = updates.reduce((acc, update) => { + if (!acc[update.locationId]) { + acc[update.locationId] = []; + } + acc[update.locationId].push(update); + return acc; + }, {} as Record); + + // Perform bulk updates + const updatePromises = Object.entries(updatesByLocation).map( + async ([locationId, locationUpdates]) => { + // For each bill update in this location + const billUpdatePromises = locationUpdates.map(({ billId, paid }) => + dbClient.collection("lokacije").updateOne( + { + _id: locationId, + userId, // Ensure the location belongs to the authenticated user + yearMonth: { + year: yearMonth.year, + month: yearMonth.month, + }, + 'bills._id': billId, + }, + { + $set: { + 'bills.$.paid': paid, + }, + } + ) + ); + + return Promise.all(billUpdatePromises); + } + ); + + await Promise.all(updatePromises); + + // Revalidate the home page and multi-edit page to show fresh data + revalidatePath('/home'); + revalidatePath(`/home/multi-bill-edit/${yearMonth.year}/${yearMonth.month}`); + + // Redirect to home page with year and month parameters, including success message + if (yearMonth) { + const locale = await getLocale(); + await gotoHomeWithMessage(locale, 'bill-multi-edit-saved', yearMonth); + } + + return { success: true }; +}); diff --git a/app/ui/MonthLocationList.tsx b/app/ui/MonthLocationList.tsx index ea2131e..10ff494 100644 --- a/app/ui/MonthLocationList.tsx +++ b/app/ui/MonthLocationList.tsx @@ -12,6 +12,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { ToastContainer, toast } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { useTranslations } from "next-intl"; +import { MultiBillEditButton } from "../[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton"; const getNextYearMonth = (yearMonth:YearMonth) => { const {year, month} = yearMonth; @@ -84,6 +85,12 @@ export const MonthLocationList:React.FC = ({ params.delete('locationDeleted'); messageShown = true; } + + if (search.get('bill-multi-edit-saved') === 'true') { + toast.success(t("bill-multi-edit-save-success-message"), { theme: "dark" }); + params.delete('bill-multi-edit-saved'); + messageShown = true; + } }, [search, router, t]); if(!availableYears || !months) { @@ -128,6 +135,7 @@ export const MonthLocationList:React.FC = ({
+
) diff --git a/messages/en.json b/messages/en.json index fd95348..d38caeb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -58,6 +58,9 @@ "add-month-button": { "tooltip": "Add next mont" }, + "multi-bill-edit-button": { + "tooltip": "Multi Bills Edit" + }, "location-card": { "edit-card-tooltip": "Edit realestate", "add-bill-button-tooltip": "Add a new bill", @@ -103,7 +106,8 @@ "bill-saved-message": "Bill saved successfully", "bill-deleted-message": "Bill deleted successfully", "location-saved-message": "Location saved successfully", - "location-deleted-message": "Location deleted successfully" + "location-deleted-message": "Location deleted successfully", + "bill-multi-edit-save-success-message": "Changes saved successfully" }, "bill-delete-form": { "text": "Please confirm deletion of bill \"{bill_name}\" at \"{location_name}\".", @@ -153,7 +157,6 @@ "location-name-legend": "Realestate name", "location-name-placeholder": "enter realestate name", "notes-placeholder": "notes", - "proof-of-payment-attachment-type--legend": "Proof of Payment", "proof-of-payment-attachment-type--info": "Here you can choose how the tenant can provide proof of payment for utilities. Select the option that best matches the payment arrangement you have agreed upon.", "proof-of-payment-attachment-type--option--label": "Tenant provides ...", @@ -164,24 +167,20 @@ "proof-of-payment-attachment-type--option--combined--hint": "💡 with the selected option you might also want to activate payment instructions - see above", "proof-of-payment-attachment-type--option--per-bill": "✂️ separate proof of payment for each bill", "proof-of-payment-attachment-type--option--per-bill--tooltip": "The selected option is useful if the tenant pays utilities directly to individual service providers", - "tenant-payment-instructions-legend": "PAYMENT INSTRUCTIONS", "tenant-payment-instructions-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.", - "tenant-payment-instructions-method--legend": "Show payment instructions to tenant:", "tenant-payment-instructions-method--none": "⛔ do not show payment instructions", "tenant-payment-instructions-method--iban": "🏛️ payment via IBAN", "tenant-payment-instructions-method--iban-disabled": "payment via IBAN - disabled in app settings", "tenant-payment-instructions-method--revolut": "🅡 payment via Revolut", "tenant-payment-instructions-method--revolut-disabled": "payment via Revolut - disabled in app settings", - "iban-payment--tenant-name-label": "Tenant First and Last Name", "iban-payment--tenant-name-placeholder": "enter tenant's first and last name", "iban-payment--tenant-street-label": "Tenant Street and House Number", "iban-payment--tenant-street-placeholder": "enter tenant's street", "iban-payment--tenant-town-label": "Tenant Postal Code and Town", "iban-payment--tenant-town-placeholder": "enter tenant's town", - "auto-utility-bill-forwarding-legend": "Automatic utility bill forwarding", "auto-utility-bill-forwarding-info": "This option enables automatic forwarding of utility bills to the tenant via email according to the selected forwarding strategy.", "auto-utility-bill-forwarding-toggle-label": "forward utility bills", @@ -222,12 +221,10 @@ }, "user-settings-form": { "title": "User settings", - "iban-payment-instructions--legend": "Payment to Your IBAN", "iban-payment-instructions--intro-title": "What does this option do?", "iban-payment-instructions--intro-message": "By activating this option, the monthly statement sent to the tenant will contain payment details and a 2D barcode allowing a direct payment to your bank account.", "iban-payment-instructions--toggle-label": "enable IBAN payment instructions", - "iban-form-title": "Payment Information for IBAN", "iban-owner-name-label": "Your First and Last Name", "iban-owner-name-placeholder": "enter your first and last name", @@ -237,22 +234,17 @@ "iban-owner-town-placeholder": "enter your postal code and town", "iban-owner-iban-label": "IBAN", "iban-owner-iban-placeholder": "enter your IBAN for receiving payments", - - "revolut-form-title": "Payment Information for Revolut", "revolut-payment-instructions--legend": "Payment to Your Revolut Profile", "revolut-payment-instructions--intro-title": "What does this option do?", "revolut-payment-instructions--intro-message": "By activating this option, the monthly statement sent to the tenant will contain a link allowing a direct payment to your Revolut account.", "revolut-payment-instructions--toggle-label": "enable Revolut payment instructions", - "revolut-profile-label": "Revolut profile name", "revolut-profile-placeholder": "enter your Revolut profile name for receiving payments", "revolut-profile-tooltip": "You can find your Revolut profile name in the Revolut app under your user profile. It is displayed below your name and starts with the '@' symbol (e.g., '@john123').", "revolut-profile--test-link-label": "Test your Revolut link:", "revolut-profile--test-link-text": "Pay with Revolut", - "payment-additional-notes": "IMPORTANT: For the payment instructions to be displayed to the tenant, you must also enable this option in the property's settings.", - "general-settings-legend": "General Settings", "currency-label": "Currency", "save-button": "Save", @@ -271,5 +263,20 @@ }, "info-box": { "default-title": "What is this option for?" + }, + "multi-bill-edit": { + "title": "Multi Bill Edit", + "loading-message": "Loading...", + "error-title": "Error", + "no-locations-title": "No Locations", + "no-locations-message": "No locations found for the selected month", + "no-bills-message": "No bills", + "set-all-as-paid-button": "Mark all as paid", + "set-all-as-unpaid-button": "Mark all as unpaid", + "save-button": "Save", + "saving-button": "Saving...", + "cancel-button": "Cancel", + "back-to-home-button": "Back to Home", + "save-error-message": "Error saving changes" } } \ No newline at end of file diff --git a/messages/hr.json b/messages/hr.json index 583889d..bc542b3 100644 --- a/messages/hr.json +++ b/messages/hr.json @@ -58,6 +58,9 @@ "add-month-button": { "tooltip": "Dodaj idući mjesec" }, + "multi-bill-edit-button": { + "tooltip": "Izmjena više računa" + }, "location-card": { "edit-card-tooltip": "Izmjeni nekretninu", "add-bill-button-tooltip": "Dodaj novi račun", @@ -103,7 +106,10 @@ "bill-saved-message": "Račun uspješno spremljen", "bill-deleted-message": "Račun uspješno obrisan", "location-saved-message": "Nekretnina uspješno spremljena", - "location-deleted-message": "Nekretnina uspješno obrisana" + "location-deleted-message": "Nekretnina uspješno obrisana", + "bill-multi-edit-save-success-message": "Promjene uspješno spremljene", + "bill-multi-edit-save-error-message": "Greška pri spremanju promjena", + "bill-multi-edit-load-error-message": "Greška pri učitavanju podataka" }, "bill-delete-form": { "text": "Molim potvrdi brisanje računa \"{bill_name}\" koji pripada nekretnini \"{location_name}\".", @@ -152,7 +158,6 @@ "location-name-legend": "Realestate name", "location-name-placeholder": "unesite naziv nekretnine", "notes-placeholder": "bilješke", - "proof-of-payment-attachment-type--legend": "Potvrda o uplati", "proof-of-payment-attachment-type--info": "Ovdje možete odabrati na koji način na koji podstanar može priložiti potvrdu o uplati režija. Izaberite način koji najbolje odgovara načinu na koji ste dogovorili plaćanje režija.", "proof-of-payment-attachment-type--option--label": "Podstanar prilaže ...", @@ -163,10 +168,8 @@ "proof-of-payment-attachment-type--option--combined--hint": "💡 za odabranu opciju dobro je uključiti i prikaz uputa za uplatu - vidi gore", "proof-of-payment-attachment-type--option--per-bill": "✂️ zasebna potvrda za svaki račun", "proof-of-payment-attachment-type--option--per-bill--tooltip": "Odabrana opcija je korisna ako podstanar plaća režije izravno pojedinačnim davateljima usluga", - "tenant-payment-instructions-legend": "Upute za uplatu", "tenant-payment-instructions-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN ili Revolut.", - "tenant-payment-instructions-method--legend": "Podstanaru prikaži upute za uplatu:", "tenant-payment-instructions-method--none": "⛔ ne prikazuj upute za uplatu", "tenant-payment-instructions-method--iban": "🏛️ uplata na IBAN", @@ -174,7 +177,6 @@ "tenant-payment-instructions-method--revolut": "🅡 uplata na Revolut", "tenant-payment-instructions-method--revolut-disabled": "uplata na Revolut - onemogućeno u app postavkama", "tenant-payment-instructions-method--disabled-message": "Ova opcija je nedostupna zato što nije omogućena u postavkama aplikacije.", - "iban-payment--form-title": "Informacije za uplatu na IBAN", "iban-payment--tenant-name-label": "Ime i prezime podstanara", "iban-payment--tenant-name-placeholder": "unesite ime i prezime podstanara", @@ -182,7 +184,6 @@ "iban-payment--tenant-street-placeholder": "unesite ulicu podstanara", "iban-payment--tenant-town-label": "Poštanski broj i Grad podstanara", "iban-payment--tenant-town-placeholder": "unesite poštanski broj i grad podstanara", - "auto-utility-bill-forwarding-legend": "AUTOMATSKO PROSLJEĐIVANJE REŽIJA", "auto-utility-bill-forwarding-info": "Ova opcija omogućuje automatsko prosljeđivanje režija podstanaru putem emaila u skladu s odabranom strategijom.", "auto-utility-bill-forwarding-toggle-label": "proslijedi režije automatski", @@ -223,11 +224,9 @@ }, "user-settings-form": { "title": "Korisničke postavke", - "iban-payment-instructions--legend": "Uplata na vaš IBAN", "iban-payment-instructions--intro-message": "Aktiviranjem ove opcije, mjesečni obračun poslan podstanaru sadržavati će podatke za uplatu i 2D barkod putem kojeg će podstanar moći izvršiti izravnu uplatu sredstava na bankovni račun.", "iban-payment-instructions--toggle-label": "omogući IBAN uplatu", - "iban-form-title": "Informacije za uplatu na IBAN", "iban-owner-name-label": "Vaše ime i prezime", "iban-owner-name-placeholder": "unesite svoje ime i prezime", @@ -237,18 +236,15 @@ "iban-owner-town-placeholder": "unesite poštanski broj i grad", "iban-owner-iban-label": "IBAN", "iban-owner-iban-placeholder": "IBAN putem kojeg ćete primate uplate", - "revolut-payment-instructions--legend": "Uplata na vaš Revolut profil", "revolut-payment-instructions--intro-message": "Aktiviranjem ove opcije, mjesečni obračun poslan podstanaru sadržavati će link putem kojeg će podstanar moći izvršiti izravnu uplatu sredstava na vaš Revolut račun.", "revolut-payment-instructions--toggle-label": "omogući Revolut uplatu", - "revolut-form-title": "Info za uplatu na Revolut", "revolut-profile-label": "Naziv vašeg Revolut profila", "revolut-profile-placeholder": "profil putem kojeg ćete primati uplate", "revolut-profile-tooltip": "Naziv vašeg Revolut profila možete pronaći u aplikaciji Revolut u korisničkom profilu. Prikazan je ispod vašeg imena i prezimena - počinje sa znakom '@' (npr: '@ivan123').", "revolut-profile--test-link-label": "Testiraj svoju Revolut poveznicu:", "revolut-profile--test-link-text": "Plati pomoću Revoluta", - "general-settings-legend": "Opće postavke", "currency-label": "Valuta", "save-button": "Spremi", @@ -268,5 +264,20 @@ }, "info-box": { "default-title": "Čemu služi ova opcija?" + }, + "multi-bill-edit": { + "title": "Masovna izmjena računa", + "loading-message": "Učitavanje...", + "error-title": "Greška", + "no-locations-title": "Nema lokacija", + "no-locations-message": "Nisu pronađene lokacije za odabrani mjesec", + "no-bills-message": "Nema računa", + "set-all-as-paid-button": "Označi sve kao plaćeno", + "set-all-as-unpaid-button": "Označi sve kao neplaćeno", + "save-button": "Spremi", + "saving-button": "Spremanje...", + "cancel-button": "Odustani", + "back-to-home-button": "Povratak na početnu", + "save-error-message": "Greška pri spremanju promjena" } } \ No newline at end of file From b50603a8fed902cfa3759f52dce2470d70a71789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Dere=C5=BEi=C4=87?= Date: Thu, 18 Dec 2025 17:57:51 +0100 Subject: [PATCH 3/3] refactor: optimize state initialization with lazy initializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move billStates calculation into useState lazy initializer to prevent recalculation on re-renders - Initialize allPaidMode based on billStates to reflect actual data state - Use lazy initialization pattern for better performance - Remove intermediate variables to simplify code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../[year]/[month]/MultiBillEdit.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx index cf483d5..00af55e 100644 --- a/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx +++ b/app/[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEdit.tsx @@ -26,18 +26,21 @@ export const MultiBillEdit: FC = ({ locations, year, month } const t = useTranslations("multi-bill-edit"); const router = useRouter(); - // Initialize bill states from locations - const initialBillStates: BillState[] = locations.flatMap(location => - location.bills.map(bill => ({ - locationId: location._id, - billId: bill._id, - paid: bill.paid, - })) + const [isSaving, setIsSaving] = useState(false); + + const [billStates, setBillStates] = useState(() => + locations.flatMap(location => + location.bills.map(bill => ({ + locationId: location._id, + billId: bill._id, + paid: bill.paid, + })) + ) ); - const [billStates, setBillStates] = useState(initialBillStates); - const [isSaving, setIsSaving] = useState(false); - const [allPaidMode, setAllPaidMode] = useState(false); + const [allPaidMode, setAllPaidMode] = useState(() => + billStates.length > 0 && billStates.every(bill => bill.paid) + ); // Toggle individual bill paid status const handleBillToggle = (locationId: string, billId: string) => {