diff --git a/.env b/.env index 15de31f..6364b54 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -MONGODB_URI=mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@localhost:27017/utility-bills +MONGODB_URI=mongodb://root:HjktJCPWMBtM1ACrDaw7@localhost:27017 GOOGLE_ID=355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com GOOGLE_SECRET=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 @@ -6,4 +6,7 @@ AUTH_SECRET=Gh0jQ35oq6DR8HkLR3heA8EaEDtxYN/xkP6blvukZ0w= LINKEDIN_ID=776qlcsykl1rag LINKEDIN_SECRET=ugf61aJ2iyErLK40 -USE_MOCK_AUTH=true \ No newline at end of file +USE_MOCK_AUTH=true + +MAX_BILL_ATTACHMENT_UPLOAD_SIZE_KB=1024 +MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024 \ No newline at end of file diff --git a/app/[locale]/share/proof-of-payment/[id]/not-found.tsx b/app/[locale]/share/proof-of-payment/combined/[id]/not-found.tsx similarity index 100% rename from app/[locale]/share/proof-of-payment/[id]/not-found.tsx rename to app/[locale]/share/proof-of-payment/combined/[id]/not-found.tsx diff --git a/app/[locale]/share/proof-of-payment/[id]/route.tsx b/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx similarity index 81% rename from app/[locale]/share/proof-of-payment/[id]/route.tsx rename to app/[locale]/share/proof-of-payment/combined/[id]/route.tsx index ad3b76b..820566c 100644 --- a/app/[locale]/share/proof-of-payment/[id]/route.tsx +++ b/app/[locale]/share/proof-of-payment/combined/[id]/route.tsx @@ -9,16 +9,16 @@ export async function GET(request: Request, { params:{ id } }: { params: { id:st const location = await dbClient.collection("lokacije") .findOne({ _id: locationID }, { projection: { - utilBillsProofOfPaymentAttachment: 1, + utilBillsProofOfPayment: 1, } }); - if(!location?.utilBillsProofOfPaymentAttachment) { + if(!location?.utilBillsProofOfPayment) { notFound(); } // Convert fileContentsBase64 from Base64 string to binary - const fileContentsBuffer = Buffer.from(location.utilBillsProofOfPaymentAttachment.fileContentsBase64, 'base64'); + const fileContentsBuffer = Buffer.from(location.utilBillsProofOfPayment.fileContentsBase64, 'base64'); // Convert fileContentsBuffer to format that can be sent to the client const fileContents = new Uint8Array(fileContentsBuffer); @@ -27,8 +27,8 @@ export async function GET(request: Request, { params:{ id } }: { params: { id:st status: 200, headers: { 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename="${location.utilBillsProofOfPaymentAttachment.fileName}"`, - 'Last-Modified': `${location.utilBillsProofOfPaymentAttachment.fileLastModified}` + 'Content-Disposition': `attachment; filename="${location.utilBillsProofOfPayment.fileName}"`, + 'Last-Modified': `${location.utilBillsProofOfPayment.fileLastModified}` } }); } diff --git a/app/[locale]/share/proof-of-payment/per-bill/[id]/not-found.tsx b/app/[locale]/share/proof-of-payment/per-bill/[id]/not-found.tsx new file mode 100644 index 0000000..ce78ef3 --- /dev/null +++ b/app/[locale]/share/proof-of-payment/per-bill/[id]/not-found.tsx @@ -0,0 +1,7 @@ +export default function NotFound() { + return ( +
+

Proof of payment not found

+
+ ); +} diff --git a/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx b/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx new file mode 100644 index 0000000..df1c1b4 --- /dev/null +++ b/app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx @@ -0,0 +1,48 @@ +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 } }) { + // Parse locationID-billID format + const [locationID, billID] = id.split('-'); + + if (!locationID || !billID) { + notFound(); + } + + const dbClient = await getDbClient(); + const location = await dbClient.collection("lokacije") + .findOne({ _id: locationID }, { + projection: { + // Don't load bill attachments, only proof of payment + "bills._id": 1, + "bills.proofOfPayment": 1, + } + }); + + if(!location) { + notFound(); + } + + // Find the specific bill + const bill = location.bills.find(b => b._id === billID); + + if(!bill?.proofOfPayment) { + notFound(); + } + + // Convert fileContentsBase64 from Base64 string to binary + const fileContentsBuffer = Buffer.from(bill.proofOfPayment.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="${bill.proofOfPayment.fileName}"`, + 'Last-Modified': `${bill.proofOfPayment.fileLastModified}` + } + }); +} diff --git a/app/lib/actions/billActions.ts b/app/lib/actions/billActions.ts index 2990179..dfb1d52 100644 --- a/app/lib/actions/billActions.ts +++ b/app/lib/actions/billActions.ts @@ -2,13 +2,14 @@ import { z } from 'zod'; import { getDbClient } from '../dbClient'; -import { Bill, BilledTo, BillAttachment, BillingLocation, YearMonth } from '../db-types'; +import { Bill, BilledTo, FileAttachment, BillingLocation } from '../db-types'; import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; -import { gotoHome, gotoHomeWithMessage } from './navigationActions'; +import { gotoHomeWithMessage } from './navigationActions'; import { getTranslations, getLocale } from "next-intl/server"; import { IntlTemplateFn } from '@/app/i18n'; +import { unstable_noStore, revalidatePath } from 'next/cache'; export type State = { errors?: { @@ -17,21 +18,21 @@ export type State = { billNotes?: string[], payedAmount?: string[], }; - message?:string | null; + message?: string | null; } /** * Schema for validating bill form fields * @description this is defined as factory function so that it can be used with the next-intl library */ -const FormSchema = (t:IntlTemplateFn) => z.object({ +const FormSchema = (t: IntlTemplateFn) => z.object({ _id: z.string(), billName: z.coerce.string().min(1, t("bill-name-required")), billNotes: z.string(), addToSubsequentMonths: z.boolean().optional(), payedAmount: z.string().nullable().transform((val, ctx) => { - if(!val || val === '') { + if (!val || val === '') { return null; } @@ -42,7 +43,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ code: z.ZodIssueCode.custom, message: t("not-a-number"), }); - + // This is a special symbol you can use to // return early from the transform function. // It has type `never` so it does not affect the @@ -55,25 +56,25 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ code: z.ZodIssueCode.custom, message: t("negative-number") }); - + // This is a special symbol you can use to // return early from the transform function. // It has type `never` so it does not affect the // inferred return type. return z.NEVER; } - + return Math.floor(parsed * 100); // value is stored in cents - }), - }); + }), +}); /** * converts the file to a format stored in the database * @param billAttachment * @returns */ -const serializeAttachment = async (billAttachment: File | null) => { +const serializeAttachment = async (billAttachment: File | null): Promise => { if (!billAttachment) { return null; @@ -86,7 +87,7 @@ const serializeAttachment = async (billAttachment: File | null) => { lastModified: fileLastModified, } = billAttachment; - if(!fileName || fileName === 'undefined' || fileSize === 0) { + if (!fileName || fileName === 'undefined' || fileSize === 0) { return null; } @@ -95,13 +96,14 @@ const serializeAttachment = async (billAttachment: File | null) => { const fileContentsBase64 = Buffer.from(fileContents).toString('base64'); // create an object to store the file in the database - return({ + return ({ fileName, fileSize, fileType, fileLastModified, fileContentsBase64, - } as BillAttachment); + uploadedAt: new Date() + }); } /** @@ -112,7 +114,9 @@ const serializeAttachment = async (billAttachment: File | null) => { * @param formData form data * @returns */ -export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationId: string, billId:string|undefined, billYear:number|undefined, billMonth:number|undefined, prevState:State, formData: FormData) => { +export const updateOrAddBill = withUser(async (user: AuthenticatedUser, locationId: string, billId: string | undefined, billYear: number | undefined, billMonth: number | undefined, prevState: State, formData: FormData) => { + + unstable_noStore(); const { id: userId } = user; @@ -129,9 +133,9 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI }); // If form validation fails, return errors early. Otherwise, continue... - if(!validatedFields.success) { + if (!validatedFields.success) { console.log("updateBill.validation-error"); - return({ + return ({ errors: validatedFields.error.flatten().fieldErrors, message: t("form-error-message"), }); @@ -150,10 +154,26 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI // update the bill in the mongodb const dbClient = await getDbClient(); - - const billAttachment = await serializeAttachment(formData.get('billAttachment') as File); - if(billId) { + // First validate that the file is acceptable + const attachmentFile = formData.get('billAttachment') as File; + + // 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 (attachmentFile && attachmentFile.size > maxFileSizeBytes) { + return { success: false, error: `File size exceeds the maximum limit of ${maxFileSizeKB} KB` }; + } + + // Validate file type + if (attachmentFile && attachmentFile.size > 0 && attachmentFile.type !== 'application/pdf') { + return { success: false, error: 'Only PDF files are accepted' }; + } + + const billAttachment = await serializeAttachment(attachmentFile); + + if (billId) { // if there is an attachment, update the attachment field // otherwise, do not update the attachment field @@ -165,8 +185,8 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI "bills.$[elem].notes": billNotes, "bills.$[elem].payedAmount": payedAmount, "bills.$[elem].hub3aText": hub3aText, - - }: { + + } : { "bills.$[elem].name": billName, "bills.$[elem].paid": billPaid, "bills.$[elem].billedTo": billedTo, @@ -175,8 +195,8 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI "bills.$[elem].hub3aText": hub3aText, }; - // find a location with the given locationID - const post = await dbClient.collection("lokacije").updateOne( + // update bill in given location with the given locationID + await dbClient.collection("lokacije").updateOne( { _id: locationId, // find a location with the given locationID userId // make sure that the location belongs to the user @@ -184,10 +204,10 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI { $set: mongoDbSet }, { - arrayFilters: [ - { "elem._id": { $eq: billId } } // find a bill with the given billID - ] - }); + arrayFilters: [ + { "elem._id": { $eq: billId } } // find a bill with the given billID + ] + }); } else { // Create new bill - add to current location first const newBill = { @@ -227,13 +247,13 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI name: currentLocation.name, $or: [ { "yearMonth.year": { $gt: billYear } }, - { - "yearMonth.year": billYear, - "yearMonth.month": { $gt: billMonth } + { + "yearMonth.year": billYear, + "yearMonth.month": { $gt: billMonth } } ] }, { projection: { _id: 1 } }) - .toArray(); + .toArray(); // For each subsequent location, check if bill with same name already exists const updateOperations = []; @@ -278,7 +298,7 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI } } } - if(billYear && billMonth) { + if (billYear && billMonth) { const locale = await getLocale(); await gotoHomeWithMessage(locale, 'billSaved', { year: billYear, month: billMonth }); } @@ -331,8 +351,9 @@ export const fetchBillByUserAndId = withUser(async (user:AuthenticatedUser, loca }) */ -export const fetchBillById = async (locationID:string, billID:string, includeAttachmentBinary:boolean = false) => { +export const fetchBillById = async (locationID: string, billID: string, includeAttachmentBinary: boolean = false) => { + unstable_noStore(); const dbClient = await getDbClient(); @@ -351,44 +372,46 @@ export const fetchBillById = async (locationID:string, billID:string, includeAtt projection }) - if(!billLocation) { + if (!billLocation) { console.log(`Location ${locationID} not found`); - return(null); + return (null); } // find a bill with the given billID const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); - if(!bill) { + if (!bill) { console.log('Bill not found'); - return(null); + return (null); } - return([billLocation, bill] as [BillingLocation, Bill]); + return ([billLocation, bill] as [BillingLocation, Bill]); }; -export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number, month:number, _prevState:any, formData?: FormData) => { +export const deleteBillById = withUser(async (user: AuthenticatedUser, locationID: string, billID: string, year: number, month: number, _prevState: any, formData?: FormData) => { + + unstable_noStore(); const { id: userId } = user; const dbClient = await getDbClient(); - + const deleteInSubsequentMonths = formData?.get('deleteInSubsequentMonths') === 'on'; if (deleteInSubsequentMonths) { // Get the current location and bill to find the bill name and location name const location = await dbClient.collection("lokacije") - .findOne({ _id: locationID, userId }, { + .findOne({ _id: locationID, userId }, { projection: { "name": 1, "bills._id": 1, "bills.name": 1 - } + } }); if (location) { const bill = location.bills.find(b => b._id === billID); - + if (bill) { // Find all subsequent locations with the same name that have the same bill const subsequentLocations = await dbClient.collection("lokacije") @@ -397,9 +420,9 @@ export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID name: location.name, $or: [ { "yearMonth.year": { $gt: year } }, - { - "yearMonth.year": year, - "yearMonth.month": { $gt: month } + { + "yearMonth.year": year, + "yearMonth.month": { $gt: month } } ], "bills.name": bill.name @@ -461,4 +484,98 @@ export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID message: null, errors: undefined, }; -}); \ No newline at end of file +}); + +/** + * Uploads proof of payment for the given bill + * @param locationID - The ID of the location + * @param formData - FormData containing the file + * @returns Promise with success status + */ +export const uploadProofOfPayment = async (locationID: string, billID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => { + + unstable_noStore(); + + try { + // First validate that the file is acceptable + const file = formData.get('proofOfPayment') as File; + + // 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` }; + } + + // Validate file type + if (file && file.size > 0 && file.type !== 'application/pdf') { + return { success: false, error: 'Only PDF files are accepted' }; + } + + // update the bill in the mongodb + const dbClient = await getDbClient(); + + const projection = { + // attachment is not required in this context - this will reduce data transfer + "bills.attachment": 0, + // ommit file content - not needed here - this will reduce data transfer + "bills.proofOfPayment.fileContentsBase64": 0, + }; + + // Checking if proof of payment already exists + + // find a location with the given locationID + const billLocation = await dbClient.collection("lokacije").findOne( + { + _id: locationID, + }, + { + projection + }) + + if (!billLocation) { + console.log(`Location ${locationID} not found - Proof of payment upload failed`); + return { success: false, error: "Location not found - Proof of payment upload failed" }; + } + + // find a bill with the given billID + const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID); + + + if (bill?.proofOfPayment?.uploadedAt) { + return { success: false, error: 'Proof payment already uploaded for this bill' }; + } + + const attachment = await serializeAttachment(file); + + if (!attachment) { + return { success: false, error: 'Invalid file' }; + } + + // Add proof of payment to the bill + await dbClient.collection("lokacije").updateOne( + { + _id: locationID // find a location with the given locationID + }, + { + $set: { + "bills.$[elem].proofOfPayment": { + ...attachment + } + } + }, { + arrayFilters: [ + { "elem._id": { $eq: billID } } // find a bill with the given billID + ] + }); + + // Invalidate the location view cache + revalidatePath(`/share/location/${locationID}`, 'page'); + + return { success: true }; + } catch (error: any) { + console.error('Error uploading proof of payment for a bill:', error); + return { success: false, error: error.message || 'Upload failed' }; + } +} \ No newline at end of file diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 7be5986..88b7686 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -2,12 +2,12 @@ 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'; import { gotoHomeWithMessage } from './navigationActions'; -import { unstable_noStore as noStore } from 'next/cache'; +import { unstable_noStore, revalidatePath } from 'next/cache'; import { IntlTemplateFn } from '@/app/i18n'; import { getTranslations, getLocale } from "next-intl/server"; @@ -35,6 +35,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ _id: z.string(), locationName: z.coerce.string().min(1, t("location-name-required")), tenantPaymentMethod: z.enum(["none", "iban", "revolut"]).optional().nullable(), + proofOfPaymentType: z.enum(["none", "combined", "per-bill"]).optional().nullable(), tenantName: z.string().max(30).optional().nullable(), tenantStreet: z.string().max(27).optional().nullable(), tenantTown: z.string().max(27).optional().nullable(), @@ -105,13 +106,14 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ */ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locationId: string | undefined, yearMonth: YearMonth | undefined, prevState:State, formData: FormData) => { - noStore(); + unstable_noStore(); const t = await getTranslations("location-edit-form.validation"); const validatedFields = FormSchema(t).safeParse({ locationName: formData.get('locationName'), tenantPaymentMethod: formData.get('tenantPaymentMethod') as "none" | "iban" | "revolut" | undefined, + proofOfPaymentType: formData.get('proofOfPaymentType') as "none" | "combined" | "per-bill" | undefined, tenantName: formData.get('tenantName') || null, tenantStreet: formData.get('tenantStreet') || null, tenantTown: formData.get('tenantTown') || null, @@ -136,6 +138,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat const { locationName, tenantPaymentMethod, + proofOfPaymentType, tenantName, tenantStreet, tenantTown, @@ -178,6 +181,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat $set: { name: locationName, tenantPaymentMethod: tenantPaymentMethod || "none", + proofOfPaymentType: proofOfPaymentType || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -208,6 +212,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat $set: { name: locationName, tenantPaymentMethod: tenantPaymentMethod || "none", + proofOfPaymentType: proofOfPaymentType || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -231,6 +236,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat $set: { name: locationName, tenantPaymentMethod: tenantPaymentMethod || "none", + proofOfPaymentType: proofOfPaymentType || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -253,6 +259,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat name: locationName, notes: null, tenantPaymentMethod: tenantPaymentMethod || "none", + proofOfPaymentType: proofOfPaymentType || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -327,6 +334,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat name: locationName, notes: null, tenantPaymentMethod: tenantPaymentMethod || "none", + proofOfPaymentType: proofOfPaymentType || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -365,7 +373,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:number) => { - noStore(); + unstable_noStore(); const dbClient = await getDbClient(); @@ -412,6 +420,7 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu billedTo: "$$bill.billedTo", payedAmount: "$$bill.payedAmount", hasAttachment: { $ne: ["$$bill.attachment", null] }, + proofOfPayment: "$$bill.proofOfPayment", }, }, } @@ -427,15 +436,20 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu // "yearMonth": 1, "yearMonth.year": 1, "yearMonth.month": 1, - "bills": 1, + "bills._id": 1, + "bills.name": 1, + "bills.paid": 1, + "bills.hasAttachment": 1, + "bills.payedAmount": 1, + "bills.proofOfPayment.uploadedAt": 1, "seenByTenantAt": 1, // "bills.attachment": 0, // "bills.notes": 0, // "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, }, }, { @@ -456,7 +470,7 @@ ova metoda je zamijenjena sa jednostavnijom `fetchLocationById`, koja brže radi export const fetchLocationByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string) => { - noStore(); + unstable_noStore(); const dbClient = await getDbClient(); @@ -485,7 +499,7 @@ export const fetchLocationByUserAndId = withUser(async (user:AuthenticatedUser, export const fetchLocationById = async (locationID:string) => { - noStore(); + unstable_noStore(); const dbClient = await getDbClient(); @@ -497,7 +511,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, }, } ); @@ -512,7 +526,7 @@ export const fetchLocationById = async (locationID:string) => { export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string, yearMonth:YearMonth, _prevState:any, formData: FormData) => { - noStore(); + unstable_noStore(); const dbClient = await getDbClient(); @@ -591,7 +605,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; } @@ -617,37 +631,48 @@ 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 */ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData: FormData): Promise<{ success: boolean; error?: string }> => { - noStore(); + + unstable_noStore(); try { + // First validate that the file is acceptable + const file = formData.get('utilBillsProofOfPayment') as File; + + // 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` }; + } + + // Validate file type + if (file && file.size > 0 && 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: { utilBillsProofOfPaymentAttachment: 1 } }); + .findOne({ _id: locationID }, { projection: { utilBillsProofOfPayment: 1 } }); - if (existingLocation?.utilBillsProofOfPaymentAttachment) { + if (existingLocation?.utilBillsProofOfPayment) { return { success: false, error: 'An attachment already exists for this location' }; } - 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) { @@ -659,11 +684,15 @@ export const uploadUtilBillsProofOfPayment = async (locationID: string, formData .updateOne( { _id: locationID }, { $set: { - utilBillsProofOfPaymentAttachment: attachment, - utilBillsProofOfPaymentUploadedAt: new Date() + utilBillsProofOfPayment: { + ...attachment + }, } } ); + // Invalidate the location view cache + revalidatePath(`/share/location/${locationID}`, 'page'); + return { success: true }; } catch (error: any) { console.error('Error uploading util bills proof of payment:', error); 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/lib/db-types.ts b/app/lib/db-types.ts index a61f0a9..341ed62 100644 --- a/app/lib/db-types.ts +++ b/app/lib/db-types.ts @@ -1,12 +1,11 @@ -import { ObjectId } from "mongodb"; -import { inter } from "../ui/fonts"; -export interface BillAttachment { +export interface FileAttachment { fileName: string; fileSize: number; fileType: string; fileLastModified: number; fileContentsBase64: string; + uploadedAt: Date; }; export interface YearMonth { @@ -55,6 +54,9 @@ export interface BillingLocation { /** (optional) method for showing payment instructions to tenant */ tenantPaymentMethod?: "none" | "iban" | "revolut" | null; + /** (optional) type of proof of payment attachment */ + proofOfPaymentType: "none" | "combined" | "per-bill"; + /** (optional) tenant name */ tenantName?: string | null; /** (optional) tenant street */ @@ -76,9 +78,9 @@ export interface BillingLocation { /** (optional) whether the location has been seen by tenant */ seenByTenantAt?: Date | null; /** (optional) utility bills proof of payment attachment */ - utilBillsProofOfPaymentAttachment?: BillAttachment|null; - /** (optional) date when utility bills proof of payment was uploaded */ - utilBillsProofOfPaymentUploadedAt?: Date|null; + utilBillsProofOfPayment?: FileAttachment|null; + /** (optional) rent proof of payment attachment */ + rentProofOfPayment?: FileAttachment|null; }; export enum BilledTo { @@ -98,7 +100,7 @@ export interface Bill { /** payed amount amount in cents */ payedAmount?: number | null; /** attached document (optional) */ - attachment?: BillAttachment|null; + attachment?: FileAttachment|null; /** * true if there an attachment * @description this field enables us to send this info to the client without sending large attachment - it's an optimization @@ -113,4 +115,6 @@ export interface Bill { barcodeImage?:string; /** (optional) HUB-3A text for generating PDF417 bar code */ hub3aText?:string; + /** (optional) proof of payment attachment */ + proofOfPayment?: FileAttachment|null; }; \ No newline at end of file diff --git a/app/ui/BillBadge.tsx b/app/ui/BillBadge.tsx index 9496098..73f52d2 100644 --- a/app/ui/BillBadge.tsx +++ b/app/ui/BillBadge.tsx @@ -1,13 +1,24 @@ import { FC } from "react" import { Bill } from "@/app/lib/db-types" import Link from "next/link" +import { TicketIcon } from "@heroicons/react/24/outline" export interface BillBadgeProps { locationId: string, bill: Bill }; -export const BillBadge:FC = ({ locationId, bill: { _id: billId, name, paid, hasAttachment }}) => - - {name} -; \ No newline at end of file +export const BillBadge:FC = ({ locationId, bill: { _id: billId, name, paid, hasAttachment, proofOfPayment }}) => { + + const className = `badge badge-lg ${paid?"badge-success":" badge-outline"} ${ !paid && hasAttachment ? "btn-outline btn-success" : "" } cursor-pointer`; + + return ( + + {name} + { + proofOfPayment?.uploadedAt ? + : null + } + + ); +} \ No newline at end of file diff --git a/app/ui/BillEditForm.tsx b/app/ui/BillEditForm.tsx index 7ac71d7..ab68d3e 100644 --- a/app/ui/BillEditForm.tsx +++ b/app/ui/BillEditForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { DocumentIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { DocumentIcon, TicketIcon, TrashIcon } from "@heroicons/react/24/outline"; import { Bill, BilledTo, BillingLocation } from "../lib/db-types"; import React, { FC, useEffect } from "react"; import { useFormState } from "react-dom"; @@ -31,9 +31,9 @@ export const BillEditForm: FC = ({ location, bill }) => { const t = useTranslations("bill-edit-form"); const locale = useLocale(); - const { _id: billID, name, paid, billedTo = BilledTo.Tenant, attachment, notes, payedAmount: initialPayedAmount } = bill ?? { _id: undefined, name: "", paid: false, notes: "" }; + const { _id: billID, name, paid, billedTo = BilledTo.Tenant, attachment, notes, payedAmount: initialPayedAmount, proofOfPayment } = bill ?? { _id: undefined, name: "", paid: false, notes: "" }; - const { yearMonth: { year: billYear, month: billMonth }, _id: locationID } = location; + const { yearMonth: { year: billYear, month: billMonth }, _id: locationID, proofOfPaymentType } = location; const initialState = { message: null, errors: {} }; @@ -228,15 +228,40 @@ export const BillEditForm: FC = ({ location, bill }) => { ))} -
+
+ {t("billed-to-info")} {t("billed-to-legend")} - {t("billed-to-info")}
+ { + // IF proof of payment type is "per-bill" and proof of payment was uploaded + proofOfPaymentType === "per-bill" && proofOfPayment?.uploadedAt ? +
+ {t("upload-proof-of-payment-legend")} + { + // 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 + proofOfPayment.fileName ? ( +
+ + + {decodeURIComponent(proofOfPayment.fileName)} + +
+ ) : null + } +
: null + } + {/* Show toggle only when adding a new bill (not editing) */} {!bill && (
@@ -259,6 +284,7 @@ export const BillEditForm: FC = ({ location, bill }) => {

}
+ ); 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, yearMont tenantTown: location?.tenantTown ?? "", tenantEmail: location?.tenantEmail ?? "", tenantPaymentMethod: location?.tenantPaymentMethod ?? "none", + proofOfPaymentType: location?.proofOfPaymentType ?? "none", autoBillFwd: location?.autoBillFwd ?? false, billFwdStrategy: location?.billFwdStrategy ?? "when-payed", rentDueNotification: location?.rentDueNotification ?? false, @@ -218,6 +219,47 @@ export const LocationEditForm: FC = ({ location, yearMont }
+
+ {t("proof-of-payment-attachment-type--legend")} + + {t("proof-of-payment-attachment-type--info")} + +
+ {t("proof-of-payment-attachment-type--option--label")} + + { + formValues.tenantPaymentMethod === "none" && formValues.proofOfPaymentType === "combined" ? +

+ { + t.rich("proof-of-payment-attachment-type--option--combined--hint", + { + strong: (children: React.ReactNode) => {children} + } + ) + } +

: +

+ { + formValues.proofOfPaymentType === "combined" ? + t("proof-of-payment-attachment-type--option--combined--tooltip") : + t("proof-of-payment-attachment-type--option--per-bill--tooltip") + } +

+ + + } +
+
+
{t("auto-utility-bill-forwarding-legend")} {t("auto-utility-bill-forwarding-info")} diff --git a/app/ui/ViewBillBadge.tsx b/app/ui/ViewBillBadge.tsx index 672a96b..47a2c2c 100644 --- a/app/ui/ViewBillBadge.tsx +++ b/app/ui/ViewBillBadge.tsx @@ -1,7 +1,7 @@ -import { FC } from "react" -import { Bill } from "@/app/lib/db-types" -import Link from "next/link" -import { DocumentIcon, TicketIcon } from "@heroicons/react/24/outline"; +import { FC } from "react"; +import { Bill } from "@/app/lib/db-types"; +import Link from "next/link"; +import { TicketIcon } from "@heroicons/react/24/outline"; import { useLocale } from "next-intl"; export interface ViewBillBadgeProps { @@ -9,13 +9,19 @@ export interface ViewBillBadgeProps { bill: Bill }; -export const ViewBillBadge: FC = ({ locationId, bill: { _id: billId, name, paid, attachment } }) => { +export const ViewBillBadge: FC = ({ locationId, bill: { _id: billId, name, paid, attachment, proofOfPayment } }) => { const currentLocale = useLocale(); + const className = `badge badge-lg p-[1em] ${paid ? "badge-success" : " badge-outline"} ${!paid && !!attachment ? "btn-outline btn-success" : ""} cursor-pointer`; + return ( - - {name} + + {name} + { + proofOfPayment?.uploadedAt ? + : null + } ); } \ No newline at end of file diff --git a/app/ui/ViewBillCard.tsx b/app/ui/ViewBillCard.tsx index aa30f40..7cddd1e 100644 --- a/app/ui/ViewBillCard.tsx +++ b/app/ui/ViewBillCard.tsx @@ -1,88 +1,169 @@ "use client"; -import { DocumentIcon, CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline"; +import { TicketIcon, CheckCircleIcon, XCircleIcon, DocumentIcon } 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 { useRouter } from "next/navigation"; 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 = ({ location, bill }) => { +export const ViewBillCard: FC = ({ location, bill }) => { + const router = useRouter(); 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()); + router.refresh(); + } 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/app/ui/ViewLocationCard.tsx b/app/ui/ViewLocationCard.tsx index 8a6bb0e..b3c1cb8 100644 --- a/app/ui/ViewLocationCard.tsx +++ b/app/ui/ViewLocationCard.tsx @@ -5,6 +5,7 @@ import { BilledTo, BillingLocation, UserSettings } from "../lib/db-types"; import { formatYearMonth } from "../lib/format"; import { formatCurrency, formatIban } from "../lib/formatStrings"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; import { ViewBillBadge } from "./ViewBillBadge"; import { Pdf417Barcode } from "./Pdf417Barcode"; import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder"; @@ -18,7 +19,7 @@ export interface ViewLocationCardProps { userSettings: UserSettings | null; } -export const ViewLocationCard:FC = ({location, userSettings}) => { +export const ViewLocationCard: FC = ({ location, userSettings }) => { const { _id, @@ -30,16 +31,17 @@ export const ViewLocationCard:FC = ({location, userSettin tenantTown, tenantPaymentMethod, // NOTE: only the fileName is projected from the DB to reduce data transfer - utilBillsProofOfPaymentAttachment, - utilBillsProofOfPaymentUploadedAt, + utilBillsProofOfPayment, + proofOfPaymentType, } = location; + const router = useRouter(); const t = useTranslations("home-page.location-card"); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); - const [attachmentUploadedAt, setAttachmentUploadedAt ] = useState(utilBillsProofOfPaymentUploadedAt ?? null); - const [attachmentFilename, setAttachmentFilename] = useState(utilBillsProofOfPaymentAttachment?.fileName); + const [attachmentUploadedAt, setAttachmentUploadedAt] = useState(utilBillsProofOfPayment?.uploadedAt ?? null); + const [attachmentFilename, setAttachmentFilename] = useState(utilBillsProofOfPayment?.fileName); const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -57,13 +59,14 @@ export const ViewLocationCard:FC = ({location, userSettin try { const formData = new FormData(); - formData.append('utilBillsProofOfPaymentAttachment', file); + formData.append('utilBillsProofOfPayment', file); const result = await uploadUtilBillsProofOfPayment(_id, formData); if (result.success) { setAttachmentFilename(file.name); setAttachmentUploadedAt(new Date()); + router.refresh(); } else { setUploadError(result.error || 'Upload failed'); } @@ -80,17 +83,17 @@ export const ViewLocationCard:FC = ({location, userSettin const { hub3aText, paymentParams } = useMemo(() => { - if(!userSettings?.enableIbanPayment || tenantPaymentMethod !== "iban") { + if (!userSettings?.enableIbanPayment || tenantPaymentMethod !== "iban") { return { hub3aText: "", paymentParams: {} as PaymentParams }; } - const locationNameTrimmed_max20 = locationName.trimEnd().trimEnd().substring(0,19); + const locationNameTrimmed_max20 = locationName.trimEnd().trimEnd().substring(0, 19); - const paymentParams:PaymentParams = { - Iznos: (monthlyExpense/100).toFixed(2).replace(".",","), + const paymentParams: PaymentParams = { + Iznos: (monthlyExpense / 100).toFixed(2).replace(".", ","), ImePlatitelja: tenantName ?? "", AdresaPlatitelja: tenantStreet ?? "", SjedistePlatitelja: tenantTown ?? "", @@ -104,16 +107,16 @@ export const ViewLocationCard:FC = ({location, userSettin OpisPlacanja: `Režije-${locationNameTrimmed_max20}-${formatYearMonth(yearMonth)}`, // max length 35 = "Režije-" (7) + locationName (20) + "-" (1) + "YYYY-MM" (7) }; - return({ + return ({ hub3aText: EncodePayment(paymentParams), paymentParams }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - []); + // eslint-disable-next-line react-hooks/exhaustive-deps + []); - return( -
+ return ( +

{formatYearMonth(yearMonth)} {locationName}

@@ -123,30 +126,30 @@ export const ViewLocationCard:FC = ({location, userSettin
{ monthlyExpense > 0 ? -

- { t("payed-total-label") } {formatCurrency(monthlyExpense, userSettings?.currency)} -

- : null +

+ {t("payed-total-label")} {formatCurrency(monthlyExpense, userSettings?.currency)} +

+ : null } { userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ? - <> -

{t("payment-info-header")}

-
    -
  • {t("payment-iban-label")}
    { formatIban(paymentParams.IBAN) }
  • -
  • {t("payment-recipient-label")}
    {paymentParams.Primatelj}
  • -
  • {t("payment-recipient-address-label")}
    {paymentParams.AdresaPrimatelja}
  • -
  • {t("payment-recipient-city-label")}
    {paymentParams.SjedistePrimatelja}
  • -
  • {t("payment-amount-label")}
    {paymentParams.Iznos} { userSettings?.currency }
  • -
  • {t("payment-description-label")}
    {paymentParams.OpisPlacanja}
  • -
  • {t("payment-model-label")}
    {paymentParams.ModelPlacanja}
  • -
  • {t("payment-reference-label")}
    {paymentParams.PozivNaBroj}
  • -
- - - : null + <> +

{t("payment-info-header")}

+
    +
  • {t("payment-iban-label")}
    {formatIban(paymentParams.IBAN)}
  • +
  • {t("payment-recipient-label")}
    {paymentParams.Primatelj}
  • +
  • {t("payment-recipient-address-label")}
    {paymentParams.AdresaPrimatelja}
  • +
  • {t("payment-recipient-city-label")}
    {paymentParams.SjedistePrimatelja}
  • +
  • {t("payment-amount-label")}
    {paymentParams.Iznos} {userSettings?.currency}
  • +
  • {t("payment-description-label")}
    {paymentParams.OpisPlacanja}
  • +
  • {t("payment-model-label")}
    {paymentParams.ModelPlacanja}
  • +
  • {t("payment-reference-label")}
    {paymentParams.PozivNaBroj}
  • +
+ + + : null } { userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => { @@ -158,7 +161,7 @@ export const ViewLocationCard:FC = ({location, userSettin

- + = ({location, userSettin ); })() - : null + : null } -

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

{uploadError}

+ )}
- ) : null - ) : /* ELSE show upload input */ ( -
- -
- - {isUploading && ( - - )} -
- {uploadError && ( -

{uploadError}

)} -
- )} -
+
+ }
); }; \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 91739cb..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}\".", @@ -150,18 +152,27 @@ "location-name-placeholder": "enter realestate name", "notes-placeholder": "notes", + "proof-of-payment-attachment-type--legend": "Proof of Payment", + "proof-of-payment-attachment-type--info": "Here you can choose how the tenant can provide proof of payment for utilities. Select the option that best matches the payment arrangement you have agreed upon.", + "proof-of-payment-attachment-type--option--label": "Tenant provides ...", + "proof-of-payment-attachment-type--option--none": "⛔ attaching proof of payment disabled", + "proof-of-payment-attachment-type--option--none--tooltip": "The selected option means that the tenant will not be able to upload proof of payment attachments", + "proof-of-payment-attachment-type--option--combined": "📦 a single proof of payment for all bills", + "proof-of-payment-attachment-type--option--combined--tooltip": "The selected option is useful if you pay all utilities on behalf of the tenant, and the tenant reimburses you for this cost", + "proof-of-payment-attachment-type--option--combined--hint": "💡 with the selected option you might also want to activate payment instructions - see above", + "proof-of-payment-attachment-type--option--per-bill": "✂️ separate proof of payment for each bill", + "proof-of-payment-attachment-type--option--per-bill--tooltip": "The selected option is useful if the tenant pays utilities directly to individual service providers", + "tenant-payment-instructions-legend": "PAYMENT INSTRUCTIONS", "tenant-payment-instructions-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.", "tenant-payment-instructions-method--legend": "Show payment instructions to tenant:", - "tenant-payment-instructions-method--none": "do not show payment instructions", - "tenant-payment-instructions-method--iban": "payment via IBAN", + "tenant-payment-instructions-method--none": "⛔ do not show payment instructions", + "tenant-payment-instructions-method--iban": "🏛️ payment via IBAN", "tenant-payment-instructions-method--iban-disabled": "payment via IBAN - disabled in app settings", - "tenant-payment-instructions-method--revolut": "payment via Revolut", + "tenant-payment-instructions-method--revolut": "🅡 payment via Revolut", "tenant-payment-instructions-method--revolut-disabled": "payment via Revolut - disabled in app settings", - - "iban-payment--tenant-name-label": "Tenant First and Last Name", "iban-payment--tenant-name-placeholder": "enter tenant's first and last name", "iban-payment--tenant-street-label": "Tenant Street and House Number", diff --git a/messages/hr.json b/messages/hr.json index 79f1d03..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}\".", @@ -149,14 +151,25 @@ "location-name-placeholder": "unesite naziv nekretnine", "notes-placeholder": "bilješke", - "tenant-payment-instructions-legend": "UPUTE ZA UPLATU", + "proof-of-payment-attachment-type--legend": "Potvrda o uplati", + "proof-of-payment-attachment-type--info": "Ovdje možete odabrati na koji način na koji podstanar može priložiti potvrdu o uplati režija. Izaberite način koji najbolje odgovara načinu na koji ste dogovorili plaćanje režija.", + "proof-of-payment-attachment-type--option--label": "Podstanar prilaže ...", + "proof-of-payment-attachment-type--option--none": "⛔ prilaganje potvrde onemogućeno", + "proof-of-payment-attachment-type--option--none--tooltip": "Odabrana opcija znači da podstanar neće moći priložiti potvrdu o uplati", + "proof-of-payment-attachment-type--option--combined": "📦 jedinstvena potvrda za sve račune", + "proof-of-payment-attachment-type--option--combined--tooltip": "Odabrana opcija je korisna ako vi plaćate sve režije u ime podstanara, a podstanar vam taj trošak refundira", + "proof-of-payment-attachment-type--option--combined--hint": "💡 za odabranu opciju dobro je uključiti i prikaz uputa za uplatu - vidi gore", + "proof-of-payment-attachment-type--option--per-bill": "✂️ zasebna potvrda za svaki račun", + "proof-of-payment-attachment-type--option--per-bill--tooltip": "Odabrana opcija je korisna ako podstanar plaća režije izravno pojedinačnim davateljima usluga", + + "tenant-payment-instructions-legend": "Upute za uplatu", "tenant-payment-instructions-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN ili Revolut.", "tenant-payment-instructions-method--legend": "Podstanaru prikaži upute za uplatu:", - "tenant-payment-instructions-method--none": "ne prikazuj upute za uplatu", - "tenant-payment-instructions-method--iban": "uplata na IBAN", + "tenant-payment-instructions-method--none": "⛔ ne prikazuj upute za uplatu", + "tenant-payment-instructions-method--iban": "🏛️ uplata na IBAN", "tenant-payment-instructions-method--iban-disabled": "uplata na IBAN - onemogućeno u app postavkama", - "tenant-payment-instructions-method--revolut": "uplata na Revolut", + "tenant-payment-instructions-method--revolut": "🅡 uplata na Revolut", "tenant-payment-instructions-method--revolut-disabled": "uplata na Revolut - onemogućeno u app postavkama", "tenant-payment-instructions-method--disabled-message": "Ova opcija je nedostupna zato što nije omogućena u postavkama aplikacije.", diff --git a/package-lock.json b/package-lock.json index 10dd4a6..0161bd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "evidencija-rezija", - "version": "2.11.0", + "version": "2.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "version": "2.11.0", + "version": "2.12.0", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -148,6 +148,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -501,6 +502,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -544,6 +546,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1070,6 +1073,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.5", @@ -1470,6 +1474,7 @@ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.1.0.tgz", "integrity": "sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==", "dev": true, + "peer": true, "dependencies": { "glob": "10.3.10" } @@ -1800,6 +1805,7 @@ "version": "18.2.21", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -1921,6 +1927,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2220,6 +2227,7 @@ "version": "0.20.0", "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.20.0.tgz", "integrity": "sha512-6Ev6rcqVjMakZFIDvbUf0dtpPGeZMTfyxYg4HkVWioWeN7cRcnUWT3bU6sdohc82O1nPXcjq6WiGfXX2Pnit6A==", + "peer": true, "dependencies": { "ts-custom-error": "^3.2.1" }, @@ -2246,6 +2254,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2665,6 +2674,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -3343,6 +3353,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3538,6 +3549,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -5977,6 +5989,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-14.2.33.tgz", "integrity": "sha512-GiKHLsD00t4ACm1p00VgrI0rUFAC9cRDGReKyERlM57aeEZkOQGcZTpIbsGn0b562FTPJWmYfKwplfO9EaT6ng==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "14.2.33", "@swc/helpers": "0.5.5", @@ -6494,6 +6507,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -6695,6 +6709,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -6880,6 +6895,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6908,6 +6924,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7054,6 +7071,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7065,6 +7083,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -8001,6 +8020,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8306,6 +8326,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 425e0b2..29edd0a 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "engines": { "node": ">=18.17.0" }, - "version": "2.11.0" + "version": "2.12.0" }