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}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>);
|
||||||
};
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user