Implement per-bill proof of payment and update field names
Frontend changes: - Added ViewBillCard proof of payment upload for per-bill mode - Conditional rendering based on proofOfPaymentType - File upload with PDF validation and loading states - Download link to /share/proof-of-payment/per-bill/ - Updated LocationCard to use new utilBillsProofOfPayment field structure Backend changes: - Updated locationActions with improved file validation - File size validation using MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB - PDF type validation before database operations - Enhanced serializeAttachment with FileAttachment type - Updated database projections for optimized queries - Updated monthActions to use consolidated field name - Updated proof-of-payment download route with new field names Data structure migration: - Replaced utilBillsProofOfPaymentAttachment + utilBillsProofOfPaymentUploadedAt with single utilBillsProofOfPayment object containing uploadedAt - Consistent use of FileAttachment type across all upload functions Translations: - Added upload-proof-of-payment-legend and upload-proof-of-payment-label to bill-edit-form section in both English and Croatian This completes the proof of payment feature implementation for both combined (location-level) and per-bill modes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
import { getDbClient } from '@/app/lib/dbClient';
|
||||
import { BillingLocation } from '@/app/lib/db-types';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export async function GET(request: Request, { params:{ id } }: { params: { id:string } }) {
|
||||
const locationID = id;
|
||||
|
||||
const dbClient = await getDbClient();
|
||||
const location = await dbClient.collection<BillingLocation>("lokacije")
|
||||
.findOne({ _id: locationID }, {
|
||||
projection: {
|
||||
utilBillsProofOfPaymentAttachment: 1,
|
||||
}
|
||||
});
|
||||
|
||||
if(!location?.utilBillsProofOfPaymentAttachment) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Convert fileContentsBase64 from Base64 string to binary
|
||||
const fileContentsBuffer = Buffer.from(location.utilBillsProofOfPaymentAttachment.fileContentsBase64, 'base64');
|
||||
|
||||
// Convert fileContentsBuffer to format that can be sent to the client
|
||||
const fileContents = new Uint8Array(fileContentsBuffer);
|
||||
|
||||
return new Response(fileContents, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${location.utilBillsProofOfPaymentAttachment.fileName}"`,
|
||||
'Last-Modified': `${location.utilBillsProofOfPaymentAttachment.fileLastModified}`
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import { getDbClient } from '../dbClient';
|
||||
import { BillingLocation, YearMonth } from '../db-types';
|
||||
import { BillingLocation, FileAttachment, YearMonth } from '../db-types';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { withUser } from '@/app/lib/auth';
|
||||
import { AuthenticatedUser } from '../types/next-auth';
|
||||
@@ -442,8 +442,8 @@ 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,
|
||||
"utilBillsProofOfPayment.fileName": 1,
|
||||
"utilBillsProofOfPayment.uploadedAt": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -505,7 +505,7 @@ export const fetchLocationById = async (locationID:string) => {
|
||||
projection: {
|
||||
// don't include the attachment binary data in the response
|
||||
"bills.attachment.fileContentsBase64": 0,
|
||||
"utilBillsProofOfPaymentAttachment.fileContentsBase64": 0,
|
||||
"utilBillsProofOfPayment.fileContentsBase64": 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -599,7 +599,7 @@ export const setSeenByTenantAt = async (locationID: string): Promise<void> => {
|
||||
* @param file - The file to serialize
|
||||
* @returns BillAttachment object or null if file is invalid
|
||||
*/
|
||||
const serializeAttachment = async (file: File | null) => {
|
||||
const serializeAttachment = async (file: File | null):Promise<FileAttachment | null> => {
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
@@ -625,11 +625,12 @@ const serializeAttachment = async (file: File | null) => {
|
||||
fileType,
|
||||
fileLastModified,
|
||||
fileContentsBase64,
|
||||
uploadedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads utility bills proof of payment attachment for a location
|
||||
* Uploads a single proof of payment for all utility bills in a location
|
||||
* @param locationID - The ID of the location
|
||||
* @param formData - FormData containing the file
|
||||
* @returns Promise with success status
|
||||
@@ -639,23 +640,32 @@ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData
|
||||
|
||||
try {
|
||||
|
||||
// check if attachment already exists for the location
|
||||
const dbClient = await getDbClient();
|
||||
// First validate that the file is acceptable
|
||||
const file = formData.get('utilBillsProofOfPayment') as File;
|
||||
|
||||
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' };
|
||||
// validate max file size from env variable
|
||||
const maxFileSizeKB = parseInt(process.env.MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB || '1024', 10);
|
||||
const maxFileSizeBytes = maxFileSizeKB * 1024;
|
||||
|
||||
if (file && file.size > maxFileSizeBytes) {
|
||||
return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` };
|
||||
}
|
||||
|
||||
const file = formData.get('utilBillsProofOfPaymentAttachment') as File;
|
||||
|
||||
// Validate file type
|
||||
if (file && file.type !== 'application/pdf') {
|
||||
return { success: false, error: 'Only PDF files are accepted' };
|
||||
}
|
||||
|
||||
// check if attachment already exists for the location
|
||||
const dbClient = await getDbClient();
|
||||
|
||||
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
|
||||
.findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1 } });
|
||||
|
||||
if (existingLocation?.utilBillsProofOfPayment) {
|
||||
return { success: false, error: 'An attachment already exists for this location' };
|
||||
}
|
||||
|
||||
const attachment = await serializeAttachment(file);
|
||||
|
||||
if (!attachment) {
|
||||
@@ -667,8 +677,9 @@ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData
|
||||
.updateOne(
|
||||
{ _id: locationID },
|
||||
{ $set: {
|
||||
utilBillsProofOfPaymentAttachment: attachment,
|
||||
utilBillsProofOfPaymentUploadedAt: new Date()
|
||||
utilBillsProofOfPayment: {
|
||||
...attachment
|
||||
},
|
||||
} }
|
||||
);
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@ export const addMonth = withUser(async (user:AuthenticatedUser, { year, month }:
|
||||
...prevLocation,
|
||||
// clear properties specific to the month
|
||||
seenByTenantAt: undefined,
|
||||
utilBillsProofOfPaymentUploadedAt: undefined,
|
||||
utilBillsProofOfPaymentAttachment: undefined,
|
||||
utilBillsProofOfPayment: undefined,
|
||||
// assign a new ID
|
||||
_id: (new ObjectId()).toHexString(),
|
||||
yearMonth: {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
|
||||
bills,
|
||||
seenByTenantAt,
|
||||
// NOTE: only the fileName is projected from the DB to reduce data transfer
|
||||
utilBillsProofOfPaymentUploadedAt
|
||||
utilBillsProofOfPayment,
|
||||
} = location;
|
||||
|
||||
const t = useTranslations("home-page.location-card");
|
||||
@@ -64,7 +64,7 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
|
||||
</Link>
|
||||
<ShareIcon className="h-[1em] w-[1em] cursor-pointer text-2xl inline hover:text-red-500" title="create sharable link" onClick={handleCopyLinkClick} />
|
||||
</div>
|
||||
{ monthlyExpense > 0 || seenByTenantAt || utilBillsProofOfPaymentUploadedAt ?
|
||||
{ monthlyExpense > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ?
|
||||
<>
|
||||
<div className="flex ml-1">
|
||||
<div className="divider divider-horizontal p-0 m-0"></div>
|
||||
@@ -89,7 +89,7 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{utilBillsProofOfPaymentUploadedAt && (
|
||||
{utilBillsProofOfPayment?.uploadedAt && (
|
||||
<Link
|
||||
href={`/share/proof-of-payment/${_id}/`}
|
||||
target="_blank"
|
||||
|
||||
@@ -2,87 +2,165 @@
|
||||
|
||||
import { DocumentIcon, CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Bill, BillingLocation } from "../lib/db-types";
|
||||
import { FC } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { formatYearMonth } from "../lib/format";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Pdf417Barcode } from "./Pdf417Barcode";
|
||||
import { uploadProofOfPayment } from "../lib/actions/billActions";
|
||||
|
||||
export interface ViewBillCardProps {
|
||||
location: BillingLocation,
|
||||
bill?: Bill,
|
||||
bill: Bill,
|
||||
}
|
||||
|
||||
export const ViewBillCard:FC<ViewBillCardProps> = ({ location, bill }) => {
|
||||
export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill }) => {
|
||||
|
||||
const t = useTranslations("bill-edit-form");
|
||||
|
||||
const { _id: billID, name, paid, attachment, notes, payedAmount, barcodeImage, hub3aText } = bill ?? { _id:undefined, name:"", paid:false, notes:"" };
|
||||
const { _id: locationID } = location;
|
||||
const { _id: billID, name, paid, attachment, notes, payedAmount, hub3aText, proofOfPayment } = bill ?? { _id: undefined, name: "", paid: false, notes: "" };
|
||||
const { _id: locationID, proofOfPaymentType } = location;
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [proofOfPaymentUploadedAt, setProofOfPaymentUploadedAt] = useState<Date | null>(proofOfPayment?.uploadedAt ?? null);
|
||||
const [proofOfPaymentFilename, setProofOfPaymentFilename] = useState(proofOfPayment?.fileName);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (file.type !== 'application/pdf') {
|
||||
setUploadError('Only PDF files are accepted');
|
||||
e.target.value = ''; // Reset input
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('proofOfPayment', file);
|
||||
|
||||
const result = await uploadProofOfPayment(locationID, billID as string, formData);
|
||||
|
||||
if (result.success) {
|
||||
setProofOfPaymentFilename(file.name);
|
||||
setProofOfPaymentUploadedAt(new Date());
|
||||
} else {
|
||||
setUploadError(result.error || 'Upload failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setUploadError(error.message || 'Upload failed');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
e.target.value = ''; // Reset input
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return(
|
||||
<div className="card card-compact card-bordered bg-base-100 shadow-s">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<h3 className="text-xl dark:text-neutral-300">{name}</h3>
|
||||
</span>
|
||||
<p className={`flex textarea textarea-bordered max-w-[400px] w-full block ${paid ? "bg-green-950" : "bg-red-950"}`}>
|
||||
<span className="font-bold uppercase">{t("paid-checkbox")}</span>
|
||||
<span className="text-right inline-block grow">{paid ? <CheckCircleIcon className="h-[1em] w-[1em] ml-[.5em] text-2xl inline-block text-green-500"/> : <XCircleIcon className="h-[1em] w-[1em] text-2xl inline-block text-red-500" />}</span>
|
||||
</p>
|
||||
return (
|
||||
<div className="card card-compact card-bordered bg-base-100 shadow-s">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<h3 className="text-xl dark:text-neutral-300">{name}</h3>
|
||||
</span>
|
||||
<p className={`flex textarea textarea-bordered max-w-[400px] w-full block ${paid ? "bg-green-950" : "bg-red-950"}`}>
|
||||
<span className="font-bold uppercase">{t("paid-checkbox")}</span>
|
||||
<span className="text-right inline-block grow">{paid ? <CheckCircleIcon className="h-[1em] w-[1em] ml-[.5em] text-2xl inline-block text-green-500" /> : <XCircleIcon className="h-[1em] w-[1em] text-2xl inline-block text-red-500" />}</span>
|
||||
</p>
|
||||
|
||||
<p className="flex textarea textarea-bordered max-w-[400px] w-full block">
|
||||
<span className="font-bold uppercase">{t("payed-amount")}</span>
|
||||
<span className="text-right inline-block grow">{payedAmount ? payedAmount/100 : ""}</span>
|
||||
</p>
|
||||
{
|
||||
notes ?
|
||||
<p className="flex textarea textarea-bordered max-w-[400px] w-full block">
|
||||
<span className="font-bold uppercase">{t("payed-amount")}</span>
|
||||
<span className="text-right inline-block grow">{payedAmount ? payedAmount / 100 : ""}</span>
|
||||
</p>
|
||||
{
|
||||
notes ?
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<p className="font-bold uppercase">{t("notes-placeholder")}</p>
|
||||
<p className="leading-[1.4em]">
|
||||
{notes}
|
||||
</p>
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
{
|
||||
attachment ?
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<p className="font-bold uppercase">{t("attachment")}</p>
|
||||
<Link href={`/share/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-2'>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{decodeURIComponent(attachment.fileName)}
|
||||
</Link>
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
{
|
||||
hub3aText ?
|
||||
<div className="form-control p-1">
|
||||
<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>
|
||||
</div> :
|
||||
(
|
||||
// LEGACY SUPPORT ... untill all bills have been migrated
|
||||
barcodeImage ?
|
||||
<div className="p-1">
|
||||
: null
|
||||
}
|
||||
{
|
||||
attachment ?
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<p className="font-bold uppercase">{t("attachment")}</p>
|
||||
<Link href={`/share/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-2'>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{decodeURIComponent(attachment.fileName)}
|
||||
</Link>
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
{
|
||||
hub3aText ?
|
||||
<div className="form-control p-1">
|
||||
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={barcodeImage} className="grow sm:max-w-[350px]" alt="2D Barcode" />
|
||||
<Pdf417Barcode hub3aText={hub3aText} />
|
||||
</label>
|
||||
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
|
||||
</div> : null
|
||||
)
|
||||
}
|
||||
}
|
||||
{
|
||||
// IF proof of payment type is "per-bill", show upload fieldset
|
||||
proofOfPaymentType === "per-bill" &&
|
||||
<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>
|
||||
{
|
||||
// IF proof of payment was uploaded
|
||||
proofOfPaymentUploadedAt ? (
|
||||
// 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
|
||||
proofOfPaymentFilename ? (
|
||||
<div className="mt-3 ml-[-.5rem]">
|
||||
<Link
|
||||
href={`/share/proof-of-payment/per-bill/${locationID}-${billID}/`}
|
||||
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(proofOfPaymentFilename) }
|
||||
</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>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="proofOfPayment"
|
||||
name="proofOfPayment"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
className="file-input file-input-bordered grow file-input-sm my-2 block max-w-[17em] md:max-w-[80em] break-words"
|
||||
onChange={handleFileChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
{isUploading && (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="text-sm text-red-500 mt-1">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
<div className="text-right">
|
||||
<Link className="btn btn-neutral ml-3" href={`/share/location/${locationID}`}>{t("back-button")}</Link>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<Link className="btn btn-neutral ml-3" href={`/share/location/${locationID}`}>{t("back-button")}</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>);
|
||||
</div>);
|
||||
}
|
||||
@@ -135,7 +135,9 @@
|
||||
"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."
|
||||
"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.",
|
||||
"upload-proof-of-payment-legend": "Proof of payment",
|
||||
"upload-proof-of-payment-label": "Here you can upload proof of payment:"
|
||||
},
|
||||
"location-delete-form": {
|
||||
"text": "Please confirm deletion of realestate \"<strong>{name}</strong>\".",
|
||||
|
||||
@@ -134,7 +134,9 @@
|
||||
"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."
|
||||
"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.",
|
||||
"upload-proof-of-payment-legend": "Potvrda o uplati",
|
||||
"upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:"
|
||||
},
|
||||
"location-delete-form": {
|
||||
"text": "Molim potvrdi brisanje nekretnine \"<strong>{name}</strong>\".",
|
||||
|
||||
Reference in New Issue
Block a user