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:
7
app/[locale]/share/proof-of-payment/[id]/not-found.tsx
Normal file
7
app/[locale]/share/proof-of-payment/[id]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
app/[locale]/share/proof-of-payment/[id]/route.tsx
Normal file
30
app/[locale]/share/proof-of-payment/[id]/route.tsx
Normal 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}`
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -581,3 +581,76 @@ export const setSeenByTenant = async (locationID: string): Promise<void> => {
|
||||
{ $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' };
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,8 @@ export interface BillingLocation {
|
||||
rentAmount?: number | null;
|
||||
/** (optional) whether the location has been seen by tenant */
|
||||
seenByTenant?: boolean | null;
|
||||
/** (optional) utility bills proof of payment attachment */
|
||||
utilBillsProofOfPaymentAttachment?: BillAttachment|null;
|
||||
};
|
||||
|
||||
export enum BilledTo {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { FC } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { BilledTo, BillingLocation, UserSettings } from "../lib/db-types";
|
||||
import { formatYearMonth } from "../lib/format";
|
||||
import { formatCurrency } from "../lib/formatStrings";
|
||||
@@ -10,6 +10,7 @@ import { Pdf417Barcode } from "./Pdf417Barcode";
|
||||
import { PaymentParams } from "hub-3a-payment-encoder";
|
||||
import Link from "next/link";
|
||||
import { DocumentIcon } from "@heroicons/react/24/outline";
|
||||
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
|
||||
|
||||
export interface ViewLocationCardProps {
|
||||
location: BillingLocation;
|
||||
@@ -18,10 +19,54 @@ export interface ViewLocationCardProps {
|
||||
|
||||
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 [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)
|
||||
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
|
||||
}
|
||||
<Link href={`/proof-of-payment/locationID/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-4'>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
utility-bills-proof-of-payment.pdf
|
||||
{/*decodeURIComponent(utilBillsProofOfPaymentAttachment.fileName)*/}
|
||||
</Link>
|
||||
<div className="form-control w-full mb-4">
|
||||
<label className="label">
|
||||
<span className="label-text">{t("upload-proof-of-payment-label")}</span>
|
||||
</label>
|
||||
<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" />
|
||||
</div>
|
||||
{attachment ? (
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href={`/share/proof-of-payment/${_id}/`}
|
||||
target="_blank"
|
||||
className='text-center w-full max-w-[20em] text-nowrap truncate inline-block'
|
||||
>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{decodeURIComponent(attachment.fileName)}
|
||||
</Link>
|
||||
</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>);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { locales, defaultLocale } from '@/app/i18n';
|
||||
import { Session } from 'next-auth';
|
||||
|
||||
// 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({
|
||||
locales,
|
||||
|
||||
Reference in New Issue
Block a user