Add utility bills proof of payment file upload functionality

Changes:
- Updated BillingLocation interface:
  - Added utilBillsProofOfPaymentAttachment field (BillAttachment type)
- Added server action uploadUtilBillsProofOfPayment:
  - Validates PDF file type
  - Serializes file attachment to base64
  - Stores attachment in BillingLocation document
  - Returns success/error status
- Updated ViewLocationCard component:
  - Added file upload input with PDF-only accept
  - Implemented handleFileChange with immediate upload
  - Added upload state management (isUploading, uploadError, attachment)
  - Shows spinner while uploading
  - Input disabled during upload
  - Conditionally renders file input or download link
  - Link displayed after successful upload
- Created route handler for serving proof of payment PDFs:
  - GET /share/proof-of-payment/[id]/route.tsx
  - Fetches attachment from database
  - Converts base64 to binary
  - Returns PDF with proper headers
- Added not-found page for proof of payment route
- Updated middleware to include proof-of-payment in public pages
- Added translations:
  - en: "Upload proof of payment (PDF only)"
  - hr: "Priložite potvrdu o uplati:"

File uploads immediately on selection without page reload.
Only PDF files accepted with client and server-side validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Knee Cola
2025-11-22 23:47:08 +01:00
parent 82d29b39c3
commit 6df9557921
6 changed files with 195 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-6 bg-base-300">
<h2 className="text-2xl font-bold">Proof of payment not found</h2>
</div>
);
}

View File

@@ -0,0 +1,30 @@
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 });
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}`
}
});
}

View File

@@ -580,4 +580,77 @@ export const setSeenByTenant = async (locationID: string): Promise<void> => {
{ _id: locationID }, { _id: locationID },
{ $set: { seenByTenant: true } } { $set: { seenByTenant: true } }
); );
}
/**
* Serializes a file attachment to be stored in the database
* @param file - The file to serialize
* @returns BillAttachment object or null if file is invalid
*/
const serializeAttachment = async (file: File | null) => {
if (!file) {
return null;
}
const {
name: fileName,
size: fileSize,
type: fileType,
lastModified: fileLastModified,
} = file;
if(!fileName || fileName === 'undefined' || fileSize === 0) {
return null;
}
// Convert file contents to base64 for database storage
const fileContents = await file.arrayBuffer();
const fileContentsBase64 = Buffer.from(fileContents).toString('base64');
return {
fileName,
fileSize,
fileType,
fileLastModified,
fileContentsBase64,
};
}
/**
* Uploads utility bills proof of payment attachment for a location
* @param locationID - The ID of the location
* @param formData - FormData containing the file
* @returns Promise with success status
*/
export const uploadUtilBillsProofOfPayment = async (locationID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => {
noStore();
try {
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' };
}
const attachment = await serializeAttachment(file);
if (!attachment) {
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 } }
);
return { success: true };
} catch (error: any) {
console.error('Error uploading util bills proof of payment:', error);
return { success: false, error: error.message || 'Upload failed' };
}
} }

View File

@@ -69,6 +69,8 @@ export interface BillingLocation {
rentAmount?: number | null; rentAmount?: number | null;
/** (optional) whether the location has been seen by tenant */ /** (optional) whether the location has been seen by tenant */
seenByTenant?: boolean | null; seenByTenant?: boolean | null;
/** (optional) utility bills proof of payment attachment */
utilBillsProofOfPaymentAttachment?: BillAttachment|null;
}; };
export enum BilledTo { export enum BilledTo {

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { FC } from "react"; import { FC, useState } from "react";
import { BilledTo, BillingLocation, UserSettings } from "../lib/db-types"; import { BilledTo, BillingLocation, UserSettings } from "../lib/db-types";
import { formatYearMonth } from "../lib/format"; import { formatYearMonth } from "../lib/format";
import { formatCurrency } from "../lib/formatStrings"; import { formatCurrency } from "../lib/formatStrings";
@@ -10,6 +10,7 @@ import { Pdf417Barcode } from "./Pdf417Barcode";
import { PaymentParams } from "hub-3a-payment-encoder"; import { PaymentParams } from "hub-3a-payment-encoder";
import Link from "next/link"; import Link from "next/link";
import { DocumentIcon } from "@heroicons/react/24/outline"; import { DocumentIcon } from "@heroicons/react/24/outline";
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
export interface ViewLocationCardProps { export interface ViewLocationCardProps {
location: BillingLocation; location: BillingLocation;
@@ -18,10 +19,54 @@ export interface ViewLocationCardProps {
export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettings}) => { export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettings}) => {
const { _id, name: locationName, yearMonth, bills, tenantName, tenantStreet, tenantTown, generateTenantCode } = location; const { _id, name: locationName, yearMonth, bills, tenantName, tenantStreet, tenantTown, generateTenantCode, utilBillsProofOfPaymentAttachment } = location;
const t = useTranslations("home-page.location-card"); const t = useTranslations("home-page.location-card");
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [attachment, setAttachment] = useState(utilBillsProofOfPaymentAttachment);
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('utilBillsProofOfPaymentAttachment', file);
const result = await uploadUtilBillsProofOfPayment(_id, formData);
if (result.success) {
// Update local state with the uploaded attachment
setAttachment({
fileName: file.name,
fileSize: file.size,
fileType: file.type,
fileLastModified: file.lastModified,
fileContentsBase64: '', // We don't need the contents in the UI
});
} else {
setUploadError(result.error || 'Upload failed');
}
} catch (error: any) {
setUploadError(error.message || 'Upload failed');
} finally {
setIsUploading(false);
e.target.value = ''; // Reset input
}
};
// sum all the billAmounts (only for bills billed to tenant) // 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 monthlyExpense = bills.reduce((acc, bill) => (bill.paid && (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant) ? acc + (bill.payedAmount ?? 0) : acc, 0);
@@ -77,17 +122,41 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
</> </>
: null : null
} }
<Link href={`/proof-of-payment/locationID/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-4'> {attachment ? (
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" /> <div className="mt-4">
utility-bills-proof-of-payment.pdf <Link
{/*decodeURIComponent(utilBillsProofOfPaymentAttachment.fileName)*/} href={`/share/proof-of-payment/${_id}/`}
</Link> target="_blank"
<div className="form-control w-full mb-4"> className='text-center w-full max-w-[20em] text-nowrap truncate inline-block'
<label className="label"> >
<span className="label-text">{t("upload-proof-of-payment-label")}</span> <DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
</label> {decodeURIComponent(attachment.fileName)}
<input id="utilBillsProofOfPaymentAttachment" name="utilBillsProofOfPaymentAttachment" type="file" className="file-input file-input-bordered grow file-input-s my-2 block max-w-[17em] md:max-w-[80em] break-words" /> </Link>
</div> </div>
) : (
<div className="form-control w-full mb-4 mt-4">
<label className="label">
<span className="label-text">{t("upload-proof-of-payment-label")}</span>
</label>
<div className="flex items-center gap-2">
<input
id="utilBillsProofOfPaymentAttachment"
name="utilBillsProofOfPaymentAttachment"
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>
)}
</div> </div>
</div>); </div>);
}; };

View File

@@ -10,7 +10,7 @@ import { locales, defaultLocale } from '@/app/i18n';
import { Session } from 'next-auth'; import { Session } from 'next-auth';
// http://localhost:3000/share/location/675c41b227d0df76a35f106e // http://localhost:3000/share/location/675c41b227d0df76a35f106e
const publicPages = ['/terms', '/policy', '/login', '/share/location/.*', '/share/bill/.*', '/share/attachment/.*']; const publicPages = ['/terms', '/policy', '/login', '/share/location/.*', '/share/bill/.*', '/share/attachment/.*', '/share/proof-of-payment/.*'];
const intlMiddleware = createIntlMiddleware({ const intlMiddleware = createIntlMiddleware({
locales, locales,