Merge branch 'release/2.1.0'

This commit is contained in:
Knee Cola
2025-11-23 22:51:59 +01:00
14 changed files with 107 additions and 98 deletions

View File

@@ -435,6 +435,7 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu
// "bills.hub3aText": 1,
// project only file name - leave out file content so that
// less data is transferred to the client
"utilBillsProofOfPaymentUploadedAt": 1,
"utilBillsProofOfPaymentAttachment.fileName": 1,
},
},
@@ -630,6 +631,17 @@ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData
noStore();
try {
// check if attachment already exists for the location
const dbClient = await getDbClient();
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID }, { projection: { utilBillsProofOfPaymentAttachment: 1 } });
if (existingLocation?.utilBillsProofOfPaymentAttachment) {
return { success: false, error: 'An attachment already exists for this location' };
}
const file = formData.get('utilBillsProofOfPaymentAttachment') as File;
// Validate file type
@@ -643,13 +655,14 @@ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData
return { success: false, error: 'Invalid file' };
}
const dbClient = await getDbClient();
// Update the location with the attachment
await dbClient.collection<BillingLocation>("lokacije")
.updateOne(
{ _id: locationID },
{ $set: { utilBillsProofOfPaymentAttachment: attachment } }
{ $set: {
utilBillsProofOfPaymentAttachment: attachment,
utilBillsProofOfPaymentUploadedAt: new Date()
} }
);
return { success: true };

View File

@@ -41,6 +41,7 @@ export const addMonth = withUser(async (user:AuthenticatedUser, { year, month }:
...prevLocation,
// clear properties specific to the month
seenByTenant: undefined,
utilBillsProofOfPaymentUploadedAt: undefined,
utilBillsProofOfPaymentAttachment: undefined,
// assign a new ID
_id: (new ObjectId()).toHexString(),

View File

@@ -71,6 +71,8 @@ export interface BillingLocation {
seenByTenant?: boolean | null;
/** (optional) utility bills proof of payment attachment */
utilBillsProofOfPaymentAttachment?: BillAttachment|null;
/** (optional) date when utility bills proof of payment was uploaded */
utilBillsProofOfPaymentUploadedAt?: Date|null;
};
export enum BilledTo {

View File

@@ -10,6 +10,7 @@ import { formatYearMonth } from "../lib/format";
import { decodeFromImage, DecodeResult, findDecodePdf417 } from "../lib/pdf/barcodeDecoder";
import { useLocale, useTranslations } from "next-intl";
import { Pdf417Barcode } from "./Pdf417Barcode";
import { InfoBox } from "./InfoBox";
// 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
@@ -67,7 +68,7 @@ export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
}, [bill?.barcodeImage, hub3aText]);
const billedTo_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const billedTo_handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setBilledToValue(event.target.value as BilledTo);
}
@@ -227,33 +228,14 @@ export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
))}
</div>
<div className="form-control mt-4">
<div className="flex items-center gap-4">
<span className="label-text">{t("billed-to-label")}</span>
<label className="label cursor-pointer gap-2">
<input
type="radio"
name="billedTo"
value={BilledTo.Tenant}
className="radio radio-primary"
checked={billedToValue === BilledTo.Tenant}
onChange={billedTo_handleChange}
/>
<span className="label-text">{t("billed-to-tenant-option")}</span>
</label>
<label className="label cursor-pointer gap-2">
<input
type="radio"
name="billedTo"
value={BilledTo.Landlord}
className="radio radio-primary"
checked={billedToValue === BilledTo.Landlord}
onChange={billedTo_handleChange}
/>
<span className="label-text">{t("billed-to-landlord-option")}</span>
</label>
</div>
</div>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("billed-to-legend")}</legend>
<select className="select select-bordered w-full" name="billedTo" defaultValue={billedToValue} onChange={billedTo_handleChange}>
<option value={BilledTo.Tenant}>{t("billed-to-tenant-option")}</option>
<option value={BilledTo.Landlord}>{t("billed-to-landlord-option")}</option>
</select>
<InfoBox className="m-0 mt-3 mb-1 p-0">{t("billed-to-info")}</InfoBox>
</fieldset>
{/* Show toggle only when adding a new bill (not editing) */}
{!bill && (

View File

@@ -1,6 +1,6 @@
'use client';
import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, DocumentIcon, EnvelopeIcon, LinkIcon, EyeIcon } from "@heroicons/react/24/outline";
import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon } from "@heroicons/react/24/outline";
import { FC } from "react";
import { BillBadge } from "./BillBadge";
import { BillingLocation } from "../lib/db-types";
@@ -23,7 +23,7 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
bills,
seenByTenant,
// NOTE: only the fileName is projected from the DB to reduce data transfer
utilBillsProofOfPaymentAttachment
utilBillsProofOfPaymentUploadedAt
} = location;
const t = useTranslations("home-page.location-card");
@@ -58,7 +58,7 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
</div>
{monthlyExpense > 0 || seenByTenant || utilBillsProofOfPaymentAttachment ?
{monthlyExpense > 0 || seenByTenant || utilBillsProofOfPaymentUploadedAt ?
<fieldset className="card card-compact card-bordered border-1 border-neutral p-3 mt-2 mr-[3.5rem]">
<legend className="fieldset-legend px-2 text-sm font-semibold uppercase">{t("monthly-statement-legend")}</legend>
@@ -78,13 +78,13 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
<CheckCircleIcon className="h-5 w-5 text-success" />
</div>
)}
{utilBillsProofOfPaymentAttachment && (
{utilBillsProofOfPaymentUploadedAt && (
<Link
href={`/share/proof-of-payment/${_id}/`}
target="_blank"
className="flex items-center gap-2 mt-2 ml-4"
>
<LinkIcon className="h-5 w-5" />
<TicketIcon className="h-5 w-5" />
<span className="text-sm">{t("download-proof-of-payment-label")}</span>
<CheckCircleIcon className="h-5 w-5 text-success" />
</Link>

View File

@@ -81,7 +81,6 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("tenant-2d-code-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("tenant-2d-code-info")}</InfoBox>
<fieldset className="fieldset">

View File

@@ -3,7 +3,6 @@
import { useState, useEffect, FC } from 'react';
import { generateBarcode } from '../lib/pdf/pdf417';
import { renderBarcode } from '../lib/pdf/renderBarcode';
import { EncodePayment, PaymentParams } from 'hub-3a-payment-encoder';
export const Pdf417Barcode:FC<{hub3aText:string, className?: string}> = ({hub3aText: hub3a_text, className}) => {
const [bitmapData, setBitmapData] = useState<string | undefined>(undefined);

View File

@@ -37,13 +37,6 @@ export const PrintPreview: React.FC<PrintPreviewProps> = ({ data, year, month, t
print-color-adjust: exact !important;
}
.print-barcode-img {
width: 69.6mm !important;
max-width: 69.6mm !important;
height: auto !important;
max-height: 85px !important;
}
.print-table {
page-break-inside: avoid;
}
@@ -79,10 +72,6 @@ export const PrintPreview: React.FC<PrintPreviewProps> = ({ data, year, month, t
.print-table thead tr {
background: #f5f5f5 !important;
}
.print-table td img {
margin: 5em auto;
}
}
`}</style>
@@ -144,15 +133,14 @@ export const PrintPreview: React.FC<PrintPreviewProps> = ({ data, year, month, t
<div className="flex justify-center items-center">
{
item.hub3aText ?
<Pdf417Barcode hub3aText={item.hub3aText} />
<Pdf417Barcode hub3aText={item.hub3aText} className="max-h-28 w-auto max-w-[270px] print:m-[5em_auto] print:h-[auto] print:max-h-[85px] print:w-[69.6mm] print:max-w-[69.6mm]" />
: (
// LEGACY SUPPORT ... untill all bills have been migrated
item.barcodeImage ?
<img
src={item.barcodeImage.startsWith('data:') ? item.barcodeImage : `data:image/png;base64,${item.barcodeImage}`}
alt={`Barcode for ${item.billName}`}
className="max-h-28 w-auto border border-gray-300 rounded print-barcode-img"
style={{ maxWidth: '270px' }}
className="max-h-28 w-auto max-w-[270px] print:m-[5em_auto] print:h-[auto] print:max-h-[85px] print:w-[69.6mm] print:max-w-[69.6mm]"
/> : null
)
}

View File

@@ -61,7 +61,7 @@ export const ViewBillCard:FC<ViewBillCardProps> = ({ location, bill }) => {
{
hub3aText ?
<div className="form-control p-1">
<label className="cursor-pointer label p-2 grow bg-white justify-center">
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
<Pdf417Barcode hub3aText={hub3aText} />
</label>
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
@@ -70,7 +70,7 @@ export const ViewBillCard:FC<ViewBillCardProps> = ({ location, bill }) => {
// LEGACY SUPPORT ... untill all bills have been migrated
barcodeImage ?
<div className="p-1">
<label className="label p-2 grow bg-white">
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
<img src={barcodeImage} className="grow sm:max-w-[350px]" alt="2D Barcode" />
</label>
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>

View File

@@ -1,6 +1,6 @@
'use client';
import { FC, useState } from "react";
import { FC, useEffect, useMemo, useState } from "react";
import { BillAttachment, BilledTo, BillingLocation, UserSettings } from "../lib/db-types";
import { formatYearMonth } from "../lib/format";
import { formatCurrency } from "../lib/formatStrings";
@@ -29,13 +29,15 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
tenantTown,
generateTenantCode,
// NOTE: only the fileName is projected from the DB to reduce data transfer
utilBillsProofOfPaymentAttachment
utilBillsProofOfPaymentAttachment,
utilBillsProofOfPaymentUploadedAt,
} = location;
const t = useTranslations("home-page.location-card");
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [attachmentUploadedAt, setAttachmentUploadedAt ] = useState<Date | null>(utilBillsProofOfPaymentUploadedAt ?? null);
const [attachmentFilename, setAttachmentFilename] = useState(utilBillsProofOfPaymentAttachment?.fileName);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -60,6 +62,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
if (result.success) {
setAttachmentFilename(file.name);
setAttachmentUploadedAt(new Date());
} else {
setUploadError(result.error || 'Upload failed');
}
@@ -74,27 +77,40 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
// 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 locationNameTrimmed_max20 = locationName.trimEnd().trimEnd().substring(0,19);
const { hub3aText, paymentParams } = useMemo(() => {
const paymentParams:PaymentParams = {
Iznos: (monthlyExpense/100).toFixed(2).replace(".",","),
ImePlatitelja: tenantName ?? "",
AdresaPlatitelja: tenantStreet ?? "",
SjedistePlatitelja: tenantTown ?? "",
Primatelj: userSettings?.ownerName ?? "",
AdresaPrimatelja: userSettings?.ownerStreet ?? "",
SjedistePrimatelja: userSettings?.ownerTown ?? "",
IBAN: userSettings?.ownerIBAN ?? "",
ModelPlacanja: "HR00",
PozivNaBroj: formatYearMonth(yearMonth),
SifraNamjene: "",
OpisPlacanja: `Režije-${locationNameTrimmed_max20}-${formatYearMonth(yearMonth)}`, // max length 35 = "Režije-" (7) + locationName (20) + "-" (1) + "YYYY-MM" (7)
};
if(!userSettings?.show2dCodeInMonthlyStatement || !generateTenantCode) {
return {
hub3aText: "",
paymentParams: {} as PaymentParams
};
}
const hub3a_text = EncodePayment(paymentParams);
const locationNameTrimmed_max20 = locationName.trimEnd().trimEnd().substring(0,19);
const paymentParams:PaymentParams = {
Iznos: (monthlyExpense/100).toFixed(2).replace(".",","),
ImePlatitelja: tenantName ?? "",
AdresaPlatitelja: tenantStreet ?? "",
SjedistePlatitelja: tenantTown ?? "",
Primatelj: userSettings?.ownerName ?? "",
AdresaPrimatelja: userSettings?.ownerStreet ?? "",
SjedistePrimatelja: userSettings?.ownerTown ?? "",
IBAN: userSettings?.ownerIBAN ?? "",
ModelPlacanja: "HR00",
PozivNaBroj: formatYearMonth(yearMonth),
SifraNamjene: "",
OpisPlacanja: `Režije-${locationNameTrimmed_max20}-${formatYearMonth(yearMonth)}`, // max length 35 = "Režije-" (7) + locationName (20) + "-" (1) + "YYYY-MM" (7)
};
return({
hub3aText: EncodePayment(paymentParams),
paymentParams
});
}, [userSettings?.show2dCodeInMonthlyStatement, generateTenantCode, locationName, tenantName, tenantStreet, tenantTown, userSettings, monthlyExpense, yearMonth]);
return(
<div data-key={_id } className="card card-compact card-bordered max-w-[30em] min-w-[350px] bg-base-100 border-1 border-neutral my-1">
<div data-key={_id } className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1">
<div className="card-body">
<h2 className="card-title mr-[2em] text-[1.3rem]">{formatYearMonth(yearMonth)} {locationName}</h2>
<div className="card-actions mt-[1em] mb-[1em]">
@@ -118,31 +134,38 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
<li><strong>{t("payment-recipient-label")}</strong> <pre className="inline pl-1">{paymentParams.Primatelj}</pre></li>
<li><strong>{t("payment-recipient-address-label")}</strong><pre className="inline pl-1">{paymentParams.AdresaPrimatelja}</pre></li>
<li><strong>{t("payment-recipient-city-label")}</strong><pre className="inline pl-1">{paymentParams.SjedistePrimatelja}</pre></li>
<li><strong>{t("payment-amount-label")}</strong> <pre className="inline pl-1">{paymentParams.Iznos}</pre></li>
<li><strong>{t("payment-amount-label")}</strong> <pre className="inline pl-1">{paymentParams.Iznos} { userSettings?.currency }</pre></li>
<li><strong>{t("payment-description-label")}</strong><pre className="inline pl-1">{paymentParams.OpisPlacanja}</pre></li>
<li><strong>{t("payment-model-label")}</strong><pre className="inline pl-1">{paymentParams.ModelPlacanja}</pre></li>
<li><strong>{t("payment-reference-label")}</strong><pre className="inline pl-1">{paymentParams.PozivNaBroj}</pre></li>
<li><strong>{t("payment-purpose-code-label")}</strong><pre className="inline pl-1">{paymentParams.SifraNamjene}</pre></li>
</ul>
<Pdf417Barcode hub3aText={hub3a_text} />
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
<Pdf417Barcode hub3aText={hub3aText} />
</label>
</>
: null
}
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend>
{attachmentFilename ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/${_id}/`}
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{decodeURIComponent(attachmentFilename)}
</Link>
</div>
) : (
{
// IF proof of payment was uploaded
attachmentUploadedAt ? (
// IF file name is available, show link to download
// ELSE it's not available that means that the uploaded file was purged by housekeeping
// -> don't show anything
attachmentFilename ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/${_id}/`}
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{decodeURIComponent(attachmentFilename)}
</Link>
</div>
) : null
) : /* ELSE show upload input */ (
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("upload-proof-of-payment-label")}</span>

View File

@@ -124,9 +124,10 @@
},
"attachment": "Attachment",
"back-button": "Back",
"billed-to-label": "Billed to",
"billed-to-tenant-option": "tenant",
"billed-to-landlord-option": "landlord"
"billed-to-legend": "Who bears the cost?",
"billed-to-tenant-option": "the tenant bears this cost",
"billed-to-landlord-option": "the landlord bears this cost",
"billed-to-info": "This option is intended for cases where part of the utility costs are not charged to the tenant. If 'the landlord bears this cost' is selected, this bill will not be included in the monthly statement shown to the tenant."
},
"location-delete-form": {
"text": "Please confirm deletion of realestate \"<strong>{name}</strong>\".",

View File

@@ -123,9 +123,10 @@
},
"attachment": "Privitak",
"back-button": "Nazad",
"billed-to-label": "Račun plaća",
"billed-to-tenant-option": "podstanar",
"billed-to-landlord-option": "vlasnik"
"billed-to-legend": "Tko snosi trošak?",
"billed-to-tenant-option": "ovaj trošak snosi podstanar",
"billed-to-landlord-option": "ovaj trošak snosi vlasnik",
"billed-to-info": "Ova opcija je predviđena za slučaj kada se dio režija ne naplaćuje od podstanara. Ako je odabrano 'trošak snosi vlasnik', ovaj račun neće biti uključen u mjesečni obračun koji se prikazuje podstanaru."
},
"location-delete-form": {
"text": "Molim potvrdi brisanje nekretnine \"<strong>{name}</strong>\".",

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "evidencija-rezija",
"version": "2.0.1",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"version": "2.0.1",
"version": "2.1.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -58,5 +58,5 @@
"engines": {
"node": ">=18.17.0"
},
"version": "2.0.1"
"version": "2.1.0"
}