Files
evidencija-rezija/app/lib/actions/billActions.ts
Nikola Derežić 3ce158825e add bulk bill deletion across subsequent months
Implement ability to delete bills from all subsequent months
with toggle option and warning message similar to location deletion.
Includes centered warning box and efficient bulk operations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 12:11:10 +02:00

437 lines
15 KiB
TypeScript

'use server';
import { z } from 'zod';
import { getDbClient } from '../dbClient';
import { Bill, BillAttachment, BillingLocation, YearMonth } from '../db-types';
import { ObjectId } from 'mongodb';
import { withUser } from '@/app/lib/auth';
import { AuthenticatedUser } from '../types/next-auth';
import { gotoHome } from './navigationActions';
import { getTranslations } from "next-intl/server";
import { IntlTemplateFn } from '@/app/i18n';
export type State = {
errors?: {
billName?: string[];
billAttachment?: string[],
billNotes?: string[],
payedAmount?: string[],
};
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({
_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 === '') {
return null;
}
const parsed = parseFloat(val.replace(',', '.'));
if (isNaN(parsed)) {
ctx.addIssue({
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
// inferred return type.
return z.NEVER;
}
if (parsed < 0) {
ctx.addIssue({
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) => {
if (!billAttachment) {
return null;
}
const {
name: fileName,
size: fileSize,
type: fileType,
lastModified: fileLastModified,
} = billAttachment;
if(!fileName || fileName === 'undefined') {
return null;
}
// convert the billAttachment file contents to format that can be stored in the database
const fileContents = await billAttachment.arrayBuffer();
const fileContentsBase64 = Buffer.from(fileContents).toString('base64');
// create an object to store the file in the database
return({
fileName,
fileSize,
fileType,
fileLastModified,
fileContentsBase64,
} as BillAttachment);
}
/**
* Server-side action which adds or updates a bill
* @param locationId location of the bill
* @param billId ID of the bill
* @param prevState previous state of the form
* @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) => {
const { id: userId } = user;
const t = await getTranslations("bill-edit-form.validation");
// FormSchema
const validatedFields = FormSchema(t)
.omit({ _id: true })
.safeParse({
billName: formData.get('billName'),
billNotes: formData.get('billNotes'),
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
payedAmount: formData.get('payedAmount'),
});
// If form validation fails, return errors early. Otherwise, continue...
if(!validatedFields.success) {
console.log("updateBill.validation-error");
return({
errors: validatedFields.error.flatten().fieldErrors,
message: t("form-error-message"),
});
}
const {
billName,
billNotes,
addToSubsequentMonths,
payedAmount,
} = validatedFields.data;
const billPaid = formData.get('billPaid') === 'on';
const barcodeImage = formData.get('barcodeImage')?.valueOf() as string;
// update the bill in the mongodb
const dbClient = await getDbClient();
const billAttachment = await serializeAttachment(formData.get('billAttachment') as File);
if(billId) {
// if there is an attachment, update the attachment field
// otherwise, do not update the attachment field
const mongoDbSet = billAttachment ? {
"bills.$[elem].name": billName,
"bills.$[elem].paid": billPaid,
"bills.$[elem].attachment": billAttachment,
"bills.$[elem].notes": billNotes,
"bills.$[elem].payedAmount": payedAmount,
"bills.$[elem].barcodeImage": barcodeImage,
}: {
"bills.$[elem].name": billName,
"bills.$[elem].paid": billPaid,
"bills.$[elem].notes": billNotes,
"bills.$[elem].payedAmount": payedAmount,
"bills.$[elem].barcodeImage": barcodeImage,
};
// find a location with the given locationID
const post = 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
},
{
$set: mongoDbSet
}, {
arrayFilters: [
{ "elem._id": { $eq: billId } } // find a bill with the given billID
]
});
} else {
// Create new bill - add to current location first
const newBill = {
_id: (new ObjectId()).toHexString(),
name: billName,
paid: billPaid,
attachment: billAttachment,
notes: billNotes,
payedAmount,
barcodeImage,
};
// Add to current location
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
},
{
$push: {
bills: newBill
}
});
// If addToSubsequentMonths is enabled, add to subsequent months
if (addToSubsequentMonths && billYear && billMonth) {
// Get the current location to find its name
const currentLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationId, userId }, { projection: { bills: 0 } });
if (currentLocation) {
// Find all subsequent months that have the same location name
const subsequentLocations = await dbClient.collection<BillingLocation>("lokacije")
.find({
userId,
name: currentLocation.name,
$or: [
{ "yearMonth.year": { $gt: billYear } },
{
"yearMonth.year": billYear,
"yearMonth.month": { $gt: billMonth }
}
]
}, { projection: { bills: 0 } })
.toArray();
// For each subsequent location, check if bill with same name already exists
const updateOperations = [];
for (const location of subsequentLocations) {
const existingBill = await dbClient.collection<BillingLocation>("lokacije")
.findOne({
_id: location._id,
"bills.name": billName
}, { projection: { "bills.$": 1 } });
// Only add if bill with same name doesn't already exist
if (!existingBill) {
updateOperations.push({
updateOne: {
filter: { _id: location._id, userId },
update: {
$push: {
bills: {
_id: (new ObjectId()).toHexString(),
name: billName,
paid: false, // New bills in subsequent months are unpaid
attachment: null, // No attachment for subsequent months
notes: billNotes,
payedAmount: null,
barcodeImage: undefined,
}
}
}
}
});
}
}
// Execute all update operations at once if any
if (updateOperations.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").bulkWrite(updateOperations);
}
}
}
}
if(billYear && billMonth ) {
await gotoHome({ year: billYear, month: billMonth });
}
})
/*
Funkcija zamijenjena sa `fetchBillByUserAndId`, koja brže radi i ne treba korisnika
export const fetchBillByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, includeAttachmentBinary:boolean = false) => {
const { id: userId } = user;
const dbClient = await getDbClient();
// don't include the attachment binary data in the response
// if the attachment binary data is not needed
const projection = includeAttachmentBinary ? {} : {
"bills.attachment.fileContentsBase64": 0,
};
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije").findOne(
{
_id: locationID,
userId
},
{
projection
})
if(!billLocation) {
console.log(`Location ${locationID} not found`);
return(null);
}
// find a bill with the given billID
const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID);
if(!bill) {
console.log('Bill not found');
return(null);
}
return([billLocation, bill] as [BillingLocation, Bill]);
})
*/
export const fetchBillById = async (locationID:string, billID:string, includeAttachmentBinary:boolean = false) => {
const dbClient = await getDbClient();
// don't include the attachment binary data in the response
// if the attachment binary data is not needed
const projection = includeAttachmentBinary ? {} : {
"bills.attachment.fileContentsBase64": 0,
};
// 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`);
return(null);
}
// find a bill with the given billID
const bill = billLocation?.bills.find(({ _id }) => _id.toString() === billID);
if(!bill) {
console.log('Bill not found');
return(null);
}
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) => {
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 });
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")
.find({
userId,
name: location.name,
$or: [
{ "yearMonth.year": { $gt: year } },
{
"yearMonth.year": year,
"yearMonth.month": { $gt: month }
}
],
"bills.name": bill.name
}, { projection: { bills: 0 } })
.toArray();
// Delete the bill from all subsequent locations (by name)
const updateOperations = subsequentLocations.map(loc => ({
updateOne: {
filter: { _id: loc._id, userId },
update: {
$pull: {
bills: { name: bill.name }
}
}
}
}));
// Also delete from current location (by ID for precision)
updateOperations.push({
updateOne: {
filter: { _id: locationID, userId },
update: {
$pull: {
bills: { _id: billID }
}
}
}
});
// Execute all delete operations
if (updateOperations.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").bulkWrite(updateOperations);
}
}
}
} else {
// Delete only from current location (original behavior)
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
},
{
// remove the bill with the given billID
$pull: {
bills: {
_id: billID
}
}
});
}
await gotoHome({year, month});
return { message: null };
});