diff --git a/app/[locale]/share/proof-of-payment/[id]/route.tsx b/app/[locale]/share/proof-of-payment/[id]/route.tsx deleted file mode 100644 index ad3b76b..0000000 --- a/app/[locale]/share/proof-of-payment/[id]/route.tsx +++ /dev/null @@ -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("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}` - } - }); -} diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 0087041..9daa9d5 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -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 => { * @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 => { 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("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("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 + }, } } ); diff --git a/app/lib/actions/monthActions.ts b/app/lib/actions/monthActions.ts index ead6e66..2b71f8d 100644 --- a/app/lib/actions/monthActions.ts +++ b/app/lib/actions/monthActions.ts @@ -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: { diff --git a/app/ui/LocationCard.tsx b/app/ui/LocationCard.tsx index 95654a8..2a6c4a8 100644 --- a/app/ui/LocationCard.tsx +++ b/app/ui/LocationCard.tsx @@ -24,7 +24,7 @@ export const LocationCard: FC = ({ 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 = ({ location, currency }) => { - { monthlyExpense > 0 || seenByTenantAt || utilBillsProofOfPaymentUploadedAt ? + { monthlyExpense > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ? <>
@@ -89,7 +89,7 @@ export const LocationCard: FC = ({ location, currency }) => {
)} - {utilBillsProofOfPaymentUploadedAt && ( + {utilBillsProofOfPayment?.uploadedAt && ( = ({ location, bill }) => { +export const ViewBillCard: FC = ({ 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(null); + const [proofOfPaymentUploadedAt, setProofOfPaymentUploadedAt] = useState(proofOfPayment?.uploadedAt ?? null); + const [proofOfPaymentFilename, setProofOfPaymentFilename] = useState(proofOfPayment?.fileName); + + const handleFileChange = async (e: React.ChangeEvent) => { + 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( -
-
-

{`${formatYearMonth(location.yearMonth)} ${location.name}`}

- -

{name}

-
-

- {t("paid-checkbox")} - {paid ? : } -

+ return ( +
+
+

{`${formatYearMonth(location.yearMonth)} ${location.name}`}

+ +

{name}

+
+

+ {t("paid-checkbox")} + {paid ? : } +

-

- {t("payed-amount")} - {payedAmount ? payedAmount/100 : ""} -

- { - notes ? +

+ {t("payed-amount")} + {payedAmount ? payedAmount / 100 : ""} +

+ { + notes ?

{t("notes-placeholder")}

{notes}

- : null - } - { - attachment ? - -

{t("attachment")}

- - - {decodeURIComponent(attachment.fileName)} - -
- : null - } - { - hub3aText ? -
- -

{t.rich('barcode-disclaimer', { br: () =>
})}

-
: - ( - // LEGACY SUPPORT ... untill all bills have been migrated - barcodeImage ? -
+ : null + } + { + attachment ? + +

{t("attachment")}

+ + + {decodeURIComponent(attachment.fileName)} + +
+ : null + } + { + hub3aText ? +

{t.rich('barcode-disclaimer', { br: () =>
})}

: null - ) - } + } + { + // IF proof of payment type is "per-bill", show upload fieldset + proofOfPaymentType === "per-bill" && +
+ {t("upload-proof-of-payment-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 ? ( +
+ + + { decodeURIComponent(proofOfPaymentFilename) } + +
+ ) : null + ) : /* ELSE show upload input */ ( +
+ +
+ + {isUploading && ( + + )} +
+ {uploadError && ( +

{uploadError}

+ )} +
+ )} +
+ } + +
+ {t("back-button")} +
-
- {t("back-button")}
- -
-
); +
); } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index c268055..31d4b0c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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 \"{name}\".", diff --git a/messages/hr.json b/messages/hr.json index 3643dde..2912b40 100644 --- a/messages/hr.json +++ b/messages/hr.json @@ -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 \"{name}\".",