Merge branch 'release/2.12.0'
This commit is contained in:
7
.env
7
.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
|
||||
USE_MOCK_AUTH=true
|
||||
|
||||
MAX_BILL_ATTACHMENT_UPLOAD_SIZE_KB=1024
|
||||
MAX_PROOF_OF_PAYMENT_UPLOAD_SIZE_KB=1024
|
||||
@@ -9,16 +9,16 @@ export async function GET(request: Request, { params:{ id } }: { params: { id:st
|
||||
const location = await dbClient.collection<BillingLocation>("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}`
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
48
app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx
Normal file
48
app/[locale]/share/proof-of-payment/per-bill/[id]/route.tsx
Normal file
@@ -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<BillingLocation>("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}`
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<FileAttachment | null> => {
|
||||
|
||||
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<BillingLocation>("lokacije").updateOne(
|
||||
// update bill in given location with the given locationID
|
||||
await dbClient.collection<BillingLocation>("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<BillingLocation>("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<BillingLocation>("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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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<BillingLocation>("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<BillingLocation>("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' };
|
||||
}
|
||||
}
|
||||
@@ -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<void> => {
|
||||
* @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<FileAttachment | null> => {
|
||||
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<BillingLocation>("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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<BillBadgeProps> = ({ locationId, bill: { _id: billId, name, paid, hasAttachment }}) =>
|
||||
<Link href={`/home/bill/${locationId}-${billId}/edit`} className={`badge badge-lg ${paid?"badge-success":" badge-outline"} ${ !paid && hasAttachment ? "btn-outline btn-success" : "" } cursor-pointer`}>
|
||||
{name}
|
||||
</Link>;
|
||||
export const BillBadge:FC<BillBadgeProps> = ({ 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 (
|
||||
<Link href={`/home/bill/${locationId}-${billId}/edit`} className={className}>
|
||||
{name}
|
||||
{
|
||||
proofOfPayment?.uploadedAt ?
|
||||
<TicketIcon className="h-[1em] w-[1em] inline-block ml-1" /> : null
|
||||
}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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<BillEditFormProps> = ({ 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<BillEditFormProps> = ({ location, bill }) => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 mt-4">
|
||||
<InfoBox>{t("billed-to-info")}</InfoBox>
|
||||
<legend className="fieldset-legend font-semibold uppercase">{t("billed-to-legend")}</legend>
|
||||
<select className="select select-bordered w-full" name="billedTo" defaultValue={billedToValue} onChange={billedTo_handleChange}>
|
||||
<option value={BilledTo.Tenant}>{t("billed-to-tenant-option")}</option>
|
||||
<option value={BilledTo.Landlord}>{t("billed-to-landlord-option")}</option>
|
||||
</select>
|
||||
<InfoBox>{t("billed-to-info")}</InfoBox>
|
||||
</fieldset>
|
||||
|
||||
{
|
||||
// IF proof of payment type is "per-bill" and proof of payment was uploaded
|
||||
proofOfPaymentType === "per-bill" && proofOfPayment?.uploadedAt ?
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-2 pt-0 mt-3 mb-3">
|
||||
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</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 ? (
|
||||
<div className="mt-3 ml-[-.5rem]">
|
||||
<Link
|
||||
href={`/share/proof-of-payment/per-bill/${locationID}-${billID}/`}
|
||||
target="_blank"
|
||||
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
|
||||
>
|
||||
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 text-teal-500" />
|
||||
{decodeURIComponent(proofOfPayment.fileName)}
|
||||
</Link>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</fieldset> : null
|
||||
}
|
||||
|
||||
{/* Show toggle only when adding a new bill (not editing) */}
|
||||
{!bill && (
|
||||
<div className="form-control">
|
||||
@@ -259,6 +284,7 @@ export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
@@ -24,7 +24,7 @@ export const LocationCard: FC<LocationCardProps> = ({ 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<LocationCardProps> = ({ location, currency }) => {
|
||||
</Link>
|
||||
<ShareIcon className="h-[1em] w-[1em] cursor-pointer text-2xl inline hover:text-red-500" title="create sharable link" onClick={handleCopyLinkClick} />
|
||||
</div>
|
||||
{ monthlyExpense > 0 || seenByTenantAt || utilBillsProofOfPaymentUploadedAt ?
|
||||
{ monthlyExpense > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ?
|
||||
<>
|
||||
<div className="flex ml-1">
|
||||
<div className="divider divider-horizontal p-0 m-0"></div>
|
||||
@@ -89,7 +89,7 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{utilBillsProofOfPaymentUploadedAt && (
|
||||
{utilBillsProofOfPayment?.uploadedAt && (
|
||||
<Link
|
||||
href={`/share/proof-of-payment/${_id}/`}
|
||||
target="_blank"
|
||||
|
||||
@@ -42,6 +42,7 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ 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<LocationEditFormProps> = ({ location, yearMont
|
||||
</>
|
||||
}
|
||||
</fieldset>
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
|
||||
<legend className="fieldset-legend font-semibold uppercase text-base">{t("proof-of-payment-attachment-type--legend")}</legend>
|
||||
|
||||
<InfoBox>{t("proof-of-payment-attachment-type--info")}</InfoBox>
|
||||
|
||||
<fieldset className="fieldset mt-2 p-2">
|
||||
<legend className="fieldset-legend">{t("proof-of-payment-attachment-type--option--label")}</legend>
|
||||
<select
|
||||
value={formValues.proofOfPaymentType}
|
||||
className="select input-bordered w-full"
|
||||
name="proofOfPaymentType"
|
||||
onChange={(e) => handleInputChange("proofOfPaymentType", e.target.value)}
|
||||
>
|
||||
<option value="none">{t("proof-of-payment-attachment-type--option--none")}</option>
|
||||
<option value="combined">{t("proof-of-payment-attachment-type--option--combined")}</option>
|
||||
<option value="per-bill">{t("proof-of-payment-attachment-type--option--per-bill")}</option>
|
||||
</select>
|
||||
{
|
||||
formValues.tenantPaymentMethod === "none" && formValues.proofOfPaymentType === "combined" ?
|
||||
<p className="mt-4 ml-4 text-sm w-[17rem] sm:w-[28rem] text-yellow-600">
|
||||
{
|
||||
t.rich("proof-of-payment-attachment-type--option--combined--hint",
|
||||
{
|
||||
strong: (children: React.ReactNode) => <strong>{children}</strong>
|
||||
}
|
||||
)
|
||||
}
|
||||
</p> :
|
||||
<p className="mt-4 ml-4 text-sm w-[17rem] sm:w-[28rem] italic text-gray-500">
|
||||
{
|
||||
formValues.proofOfPaymentType === "combined" ?
|
||||
t("proof-of-payment-attachment-type--option--combined--tooltip") :
|
||||
t("proof-of-payment-attachment-type--option--per-bill--tooltip")
|
||||
}
|
||||
</p>
|
||||
|
||||
|
||||
}
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
|
||||
<legend className="fieldset-legend font-semibold uppercase text-base">{t("auto-utility-bill-forwarding-legend")}</legend>
|
||||
<InfoBox>{t("auto-utility-bill-forwarding-info")}</InfoBox>
|
||||
|
||||
@@ -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<ViewBillBadgeProps> = ({ locationId, bill: { _id: billId, name, paid, attachment } }) => {
|
||||
export const ViewBillBadge: FC<ViewBillBadgeProps> = ({ 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 (
|
||||
<Link href={`/${currentLocale}//share/bill/${locationId}-${billId}`} className={`badge badge-lg p-[1em] ${paid ? "badge-success" : " badge-outline"} ${!paid && !!attachment ? "btn-outline btn-success" : ""} cursor-pointer`}>
|
||||
<TicketIcon className="h-[1em] w-[1em] inline-block mr-1" /> {name}
|
||||
<Link href={`/${currentLocale}//share/bill/${locationId}-${billId}`} className={className}>
|
||||
{name}
|
||||
{
|
||||
proofOfPayment?.uploadedAt ?
|
||||
<TicketIcon className="h-[1em] w-[1em] inline-block ml-1" /> : null
|
||||
}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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<ViewBillCardProps> = ({ location, bill }) => {
|
||||
export const ViewBillCard: FC<ViewBillCardProps> = ({ 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<string | null>(null);
|
||||
const [proofOfPaymentUploadedAt, setProofOfPaymentUploadedAt] = useState<Date | null>(proofOfPayment?.uploadedAt ?? null);
|
||||
const [proofOfPaymentFilename, setProofOfPaymentFilename] = useState(proofOfPayment?.fileName);
|
||||
|
||||
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('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(
|
||||
<div className="card card-compact card-bordered bg-base-100 shadow-s">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<h3 className="text-xl dark:text-neutral-300">{name}</h3>
|
||||
</span>
|
||||
<p className={`flex textarea textarea-bordered max-w-[400px] w-full block ${paid ? "bg-green-950" : "bg-red-950"}`}>
|
||||
<span className="font-bold uppercase">{t("paid-checkbox")}</span>
|
||||
<span className="text-right inline-block grow">{paid ? <CheckCircleIcon className="h-[1em] w-[1em] ml-[.5em] text-2xl inline-block text-green-500"/> : <XCircleIcon className="h-[1em] w-[1em] text-2xl inline-block text-red-500" />}</span>
|
||||
</p>
|
||||
return (
|
||||
<div className="card card-compact card-bordered bg-base-100 shadow-s">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<h3 className="text-xl dark:text-neutral-300">{name}</h3>
|
||||
</span>
|
||||
<p className={`flex textarea textarea-bordered max-w-[400px] w-full block ${paid ? "bg-green-950" : "bg-red-950"}`}>
|
||||
<span className="font-bold uppercase">{t("paid-checkbox")}</span>
|
||||
<span className="text-right inline-block grow">{paid ? <CheckCircleIcon className="h-[1em] w-[1em] ml-[.5em] text-2xl inline-block text-green-500" /> : <XCircleIcon className="h-[1em] w-[1em] text-2xl inline-block text-red-500" />}</span>
|
||||
</p>
|
||||
|
||||
<p className="flex textarea textarea-bordered max-w-[400px] w-full block">
|
||||
<span className="font-bold uppercase">{t("payed-amount")}</span>
|
||||
<span className="text-right inline-block grow">{payedAmount ? payedAmount/100 : ""}</span>
|
||||
</p>
|
||||
{
|
||||
notes ?
|
||||
<p className="flex textarea textarea-bordered max-w-[400px] w-full block">
|
||||
<span className="font-bold uppercase">{t("payed-amount")}</span>
|
||||
<span className="text-right inline-block grow">{payedAmount ? payedAmount / 100 : ""}</span>
|
||||
</p>
|
||||
{
|
||||
notes ?
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<p className="font-bold uppercase">{t("notes-placeholder")}</p>
|
||||
<p className="leading-[1.4em]">
|
||||
{notes}
|
||||
</p>
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
{
|
||||
attachment ?
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<p className="font-bold uppercase">{t("attachment")}</p>
|
||||
<Link href={`/share/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-2'>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{decodeURIComponent(attachment.fileName)}
|
||||
</Link>
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
{
|
||||
hub3aText ?
|
||||
<div className="form-control p-1">
|
||||
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
|
||||
<Pdf417Barcode hub3aText={hub3aText} />
|
||||
</label>
|
||||
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
|
||||
</div> :
|
||||
(
|
||||
// LEGACY SUPPORT ... untill all bills have been migrated
|
||||
barcodeImage ?
|
||||
<div className="p-1">
|
||||
: null
|
||||
}
|
||||
{
|
||||
attachment ?
|
||||
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
|
||||
<p className="font-bold uppercase">{t("attachment")}</p>
|
||||
<Link href={`/share/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-2'>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{decodeURIComponent(attachment.fileName)}
|
||||
</Link>
|
||||
</span>
|
||||
: null
|
||||
}
|
||||
{
|
||||
hub3aText ?
|
||||
<div className="form-control p-1">
|
||||
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={barcodeImage} className="grow sm:max-w-[350px]" alt="2D Barcode" />
|
||||
<Pdf417Barcode hub3aText={hub3aText} />
|
||||
</label>
|
||||
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
|
||||
</div> : null
|
||||
)
|
||||
}
|
||||
}
|
||||
{
|
||||
// IF proof of payment type is "per-bill", show upload fieldset
|
||||
proofOfPaymentType === "per-bill" &&
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
|
||||
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</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 ? (
|
||||
<div className="mt-3 ml-[-.5rem]">
|
||||
<Link
|
||||
href={`/share/proof-of-payment/per-bill/${locationID}-${billID}/`}
|
||||
target="_blank"
|
||||
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
|
||||
>
|
||||
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{ decodeURIComponent(proofOfPaymentFilename) }
|
||||
</Link>
|
||||
</div>
|
||||
) : null
|
||||
) : /* ELSE show upload input */ (
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="label-text">{t("upload-proof-of-payment-label")}</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="proofOfPayment"
|
||||
name="proofOfPayment"
|
||||
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>
|
||||
)}
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
<div className="text-right">
|
||||
<Link className="btn btn-neutral ml-3" href={`/share/location/${locationID}`}>{t("back-button")}</Link>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<Link className="btn btn-neutral ml-3" href={`/share/location/${locationID}`}>{t("back-button")}</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>);
|
||||
</div>);
|
||||
}
|
||||
@@ -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<ViewLocationCardProps> = ({location, userSettings}) => {
|
||||
export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSettings }) => {
|
||||
|
||||
const {
|
||||
_id,
|
||||
@@ -30,16 +31,17 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({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<string | null>(null);
|
||||
const [attachmentUploadedAt, setAttachmentUploadedAt ] = useState<Date | null>(utilBillsProofOfPaymentUploadedAt ?? null);
|
||||
const [attachmentFilename, setAttachmentFilename] = useState(utilBillsProofOfPaymentAttachment?.fileName);
|
||||
const [attachmentUploadedAt, setAttachmentUploadedAt] = useState<Date | null>(utilBillsProofOfPayment?.uploadedAt ?? null);
|
||||
const [attachmentFilename, setAttachmentFilename] = useState(utilBillsProofOfPayment?.fileName);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -57,13 +59,14 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({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<ViewLocationCardProps> = ({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<ViewLocationCardProps> = ({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(
|
||||
<div data-key={_id } className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1">
|
||||
return (
|
||||
<div data-key={_id} className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title mr-[2em] text-[1.3rem]">{formatYearMonth(yearMonth)} {locationName}</h2>
|
||||
<div className="card-actions mt-[1em] mb-[1em]">
|
||||
@@ -123,30 +126,30 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
|
||||
</div>
|
||||
{
|
||||
monthlyExpense > 0 ?
|
||||
<p className="text-[1.2rem]">
|
||||
{ t("payed-total-label") } <strong>{formatCurrency(monthlyExpense, userSettings?.currency)}</strong>
|
||||
</p>
|
||||
: null
|
||||
<p className="text-[1.2rem]">
|
||||
{t("payed-total-label")} <strong>{formatCurrency(monthlyExpense, userSettings?.currency)}</strong>
|
||||
</p>
|
||||
: null
|
||||
}
|
||||
{
|
||||
userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ?
|
||||
<>
|
||||
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
|
||||
<ul className="ml-4 mb-3">
|
||||
<li><strong>{t("payment-iban-label")}</strong><pre className="inline pl-1">{ formatIban(paymentParams.IBAN) }</pre></li>
|
||||
<li><strong>{t("payment-recipient-label")}</strong> <pre className="inline pl-1">{paymentParams.Primatelj}</pre></li>
|
||||
<li><strong>{t("payment-recipient-address-label")}</strong><pre className="inline pl-1">{paymentParams.AdresaPrimatelja}</pre></li>
|
||||
<li><strong>{t("payment-recipient-city-label")}</strong><pre className="inline pl-1">{paymentParams.SjedistePrimatelja}</pre></li>
|
||||
<li><strong>{t("payment-amount-label")}</strong> <pre className="inline pl-1">{paymentParams.Iznos} { userSettings?.currency }</pre></li>
|
||||
<li><strong>{t("payment-description-label")}</strong><pre className="inline pl-1">{paymentParams.OpisPlacanja}</pre></li>
|
||||
<li><strong>{t("payment-model-label")}</strong><pre className="inline pl-1">{paymentParams.ModelPlacanja}</pre></li>
|
||||
<li><strong>{t("payment-reference-label")}</strong><pre className="inline pl-1">{paymentParams.PozivNaBroj}</pre></li>
|
||||
</ul>
|
||||
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
|
||||
<Pdf417Barcode hub3aText={hub3aText} />
|
||||
</label>
|
||||
</>
|
||||
: null
|
||||
<>
|
||||
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
|
||||
<ul className="ml-4 mb-3">
|
||||
<li><strong>{t("payment-iban-label")}</strong><pre className="inline pl-1">{formatIban(paymentParams.IBAN)}</pre></li>
|
||||
<li><strong>{t("payment-recipient-label")}</strong> <pre className="inline pl-1">{paymentParams.Primatelj}</pre></li>
|
||||
<li><strong>{t("payment-recipient-address-label")}</strong><pre className="inline pl-1">{paymentParams.AdresaPrimatelja}</pre></li>
|
||||
<li><strong>{t("payment-recipient-city-label")}</strong><pre className="inline pl-1">{paymentParams.SjedistePrimatelja}</pre></li>
|
||||
<li><strong>{t("payment-amount-label")}</strong> <pre className="inline pl-1">{paymentParams.Iznos} {userSettings?.currency}</pre></li>
|
||||
<li><strong>{t("payment-description-label")}</strong><pre className="inline pl-1">{paymentParams.OpisPlacanja}</pre></li>
|
||||
<li><strong>{t("payment-model-label")}</strong><pre className="inline pl-1">{paymentParams.ModelPlacanja}</pre></li>
|
||||
<li><strong>{t("payment-reference-label")}</strong><pre className="inline pl-1">{paymentParams.PozivNaBroj}</pre></li>
|
||||
</ul>
|
||||
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
|
||||
<Pdf417Barcode hub3aText={hub3aText} />
|
||||
</label>
|
||||
</>
|
||||
: null
|
||||
}
|
||||
{
|
||||
userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => {
|
||||
@@ -158,7 +161,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
|
||||
<QRCode value={revolutPaymentUrl} size={200} className="p-4 bg-white border border-gray-300 rounded-box" />
|
||||
</div>
|
||||
<p className="text-center mt-1 mb-3">
|
||||
<LinkIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 ml-[-.5em]"/>
|
||||
<LinkIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 ml-[-.5em]" />
|
||||
<Link
|
||||
href={revolutPaymentUrl}
|
||||
target="_blank"
|
||||
@@ -170,53 +173,57 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
|
||||
</>
|
||||
);
|
||||
})()
|
||||
: null
|
||||
: null
|
||||
}
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
|
||||
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</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 ? (
|
||||
<div className="mt-3 ml-[-.5rem]">
|
||||
<Link
|
||||
href={`/share/proof-of-payment/${_id}/`}
|
||||
target="_blank"
|
||||
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
|
||||
>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{decodeURIComponent(attachmentFilename)}
|
||||
</Link>
|
||||
{
|
||||
// IF proof of payment type is "combined", show upload fieldset
|
||||
proofOfPaymentType === "combined" &&
|
||||
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
|
||||
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</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 ? (
|
||||
<div className="mt-3 ml-[-.5rem]">
|
||||
<Link
|
||||
href={`/share/proof-of-payment/combined/${_id}/`}
|
||||
target="_blank"
|
||||
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
|
||||
>
|
||||
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
|
||||
{decodeURIComponent(attachmentFilename)}
|
||||
</Link>
|
||||
</div>
|
||||
) : null
|
||||
) : /* ELSE show upload input */ (
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="label-text">{t("upload-proof-of-payment-label")}</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="utilBillsProofOfPayment"
|
||||
name="utilBillsProofOfPayment"
|
||||
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>
|
||||
) : null
|
||||
) : /* ELSE show upload input */ (
|
||||
<div className="form-control w-full">
|
||||
<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>
|
||||
)}
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
}
|
||||
</div>
|
||||
</div>);
|
||||
};
|
||||
@@ -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 \"<strong>{name}</strong>\".",
|
||||
@@ -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 <strong>payment instructions</strong> - 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",
|
||||
|
||||
@@ -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 \"<strong>{name}</strong>\".",
|
||||
@@ -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 <strong>prikaz uputa za uplatu</strong> - 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.",
|
||||
|
||||
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -59,5 +59,5 @@
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"version": "2.11.0"
|
||||
"version": "2.12.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user