Files
evidencija-rezija/app/lib/actions/locationActions.ts
Knee Cola aee6dc0932 Remove billedTo filtering to show all bills to landlord
The billedTo field indicates payment responsibility (tenant vs landlord),
not viewing permissions. Landlords should see and manage ALL bills.

Changes:
- LocationCard: Display all bills regardless of billedTo value
- LocationCard: Calculate monthlyExpense from all paid bills
- HomePage: Include all paid bills in monthlyExpense aggregation
- printActions: Print all bills with barcodes regardless of billedTo
- locationActions: Add billedTo property to fetchAllLocations result

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 19:32:54 +01:00

408 lines
14 KiB
TypeScript

'use server';
import { z } from 'zod';
import { getDbClient } from '../dbClient';
import { BillingLocation, YearMonth } 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 { unstable_noStore as noStore } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server";
export type State = {
errors?: {
locationName?: string[];
locationNotes?: string[],
};
message?:string | null;
};
/**
* Schema for validating location 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(),
locationName: z.coerce.string().min(1, t("location-name-required")),
locationNotes: z.string(),
addToSubsequentMonths: z.boolean().optional().nullable(),
updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(),
})
// dont include the _id field in the response
.omit({ _id: true });
/**
* Server-side action which adds or updates a bill
* @param locationId location of the bill
* @param prevState previous state of the form
* @param formData form data
* @returns
*/
export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locationId: string | undefined, yearMonth: YearMonth | undefined, prevState:State, formData: FormData) => {
noStore();
const t = await getTranslations("location-edit-form.validation");
const validatedFields = FormSchema(t).safeParse({
locationName: formData.get('locationName'),
locationNotes: formData.get('locationNotes'),
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined,
});
// If form validation fails, return errors early. Otherwise, continue...
if(!validatedFields.success) {
return({
errors: validatedFields.error.flatten().fieldErrors,
message: "Missing Fields",
});
}
const {
locationName,
locationNotes,
addToSubsequentMonths,
updateScope,
} = validatedFields.data;
// update the bill in the mongodb
const dbClient = await getDbClient();
const { id: userId, email: userEmail } = user;
if(locationId) {
// Get the current location first to find its name
const currentLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationId, userId }, { projection: { bills: 0 } });
if (!currentLocation) {
return {
message: "Location not found",
errors: undefined,
};
}
// Handle different update scopes
if (updateScope === "current" || !updateScope) {
// Update only the current location (default behavior)
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationId,
userId
},
{
$set: {
name: locationName,
notes: locationNotes,
}
}
);
} else if (updateScope === "subsequent") {
// Update current and all subsequent months
await dbClient.collection<BillingLocation>("lokacije").updateMany(
{
userId,
name: currentLocation.name,
$or: [
{ "yearMonth.year": { $gt: currentLocation.yearMonth.year } },
{
"yearMonth.year": currentLocation.yearMonth.year,
"yearMonth.month": { $gte: currentLocation.yearMonth.month }
}
]
},
{
$set: {
name: locationName,
notes: locationNotes,
}
}
);
} else if (updateScope === "all") {
// Update all locations with the same name across all months
await dbClient.collection<BillingLocation>("lokacije").updateMany(
{
userId,
name: currentLocation.name
},
{
$set: {
name: locationName,
notes: locationNotes,
}
}
);
}
} else if(yearMonth) {
// Always add location to the specified month
await dbClient.collection<BillingLocation>("lokacije").insertOne({
_id: (new ObjectId()).toHexString(),
userId,
userEmail,
name: locationName,
notes: locationNotes,
yearMonth: yearMonth,
bills: [],
});
// If addToSubsequentMonths is enabled, add to all subsequent months
if (addToSubsequentMonths) {
// Find all subsequent months that exist in the database
const subsequentMonths = await dbClient.collection<BillingLocation>("lokacije")
.aggregate([
{
$match: {
userId,
$or: [
{ "yearMonth.year": { $gt: yearMonth.year } },
{
"yearMonth.year": yearMonth.year,
"yearMonth.month": { $gt: yearMonth.month }
}
]
}
},
{
$group: {
_id: {
year: "$yearMonth.year",
month: "$yearMonth.month"
}
}
},
{
$project: {
_id: 0,
year: "$_id.year",
month: "$_id.month"
}
},
{
$sort: {
year: 1,
month: 1
}
}
])
.toArray();
// For each subsequent month, check if location with same name already exists
const locationsToInsert = [];
for (const monthData of subsequentMonths) {
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({
userId,
name: locationName,
"yearMonth.year": monthData.year,
"yearMonth.month": monthData.month
}, { projection: { bills: 0 } });
// Only add if location with same name doesn't already exist in that month
if (!existingLocation) {
locationsToInsert.push({
_id: (new ObjectId()).toHexString(),
userId,
userEmail,
name: locationName,
notes: locationNotes,
yearMonth: { year: monthData.year, month: monthData.month },
bills: [],
});
}
}
// Insert all new locations at once if any
if (locationsToInsert.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").insertMany(locationsToInsert);
}
}
}
if(yearMonth) {
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'locationSaved');
}
});
export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:number) => {
noStore();
const dbClient = await getDbClient();
const { id: userId } = user;
// fetch all locations for the given year
const locations = await dbClient.collection<BillingLocation>("lokacije")
.aggregate<BillingLocation>([
{
$match: {
userId,
"yearMonth.year": year,
},
},
{
$addFields: {
bills: {
$map: {
input: "$bills",
as: "bill",
in: {
_id: "$$bill._id",
name: "$$bill.name",
paid: "$$bill.paid",
billedTo: "$$bill.billedTo",
payedAmount: "$$bill.payedAmount",
hasAttachment: { $ne: ["$$bill.attachment", null] },
},
},
},
},
},
{
$addFields: {
_id: { $toString: "$_id" },
bills: {
$map: {
input: "$bills",
as: "bill",
in: {
_id: { $toString: "$$bill._id" },
name: "$$bill.name",
paid: "$$bill.paid",
billedTo: "$$bill.billedTo",
payedAmount: "$$bill.payedAmount",
hasAttachment: "$$bill.hasAttachment",
},
},
},
}
},
{
$project: {
"_id": 1,
// "userId": 0,
// "userEmail": 0,
"name": 1,
// "notes": 0,
// "yearMonth": 1,
"yearMonth.year": 1,
"yearMonth.month": 1,
"bills": 1,
// "bills.attachment": 0,
// "bills.notes": 0,
// "bills.barcodeImage": 1,
},
},
{
$sort: {
"yearMonth.year": -1,
"yearMonth.month": -1,
name: 1,
},
},
])
.toArray();
return(locations)
})
/*
ova metoda je zamijenjena sa jednostavnijom `fetchLocationById`, koja brže radi jer ne provjerava korisnika
export const fetchLocationByUserAndId = withUser(async (user:AuthenticatedUser, locationID:string) => {
noStore();
const dbClient = await getDbClient();
const { id: userId } = user;
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne(
{ _id: locationID, userId },
{
projection: {
// don't include the attachment binary data in the response
"bills.attachment.fileContentsBase64": 0,
},
}
);
if(!billLocation) {
console.log(`Location ${locationID} not found`);
return(null);
}
return(billLocation);
});
*/
export const fetchLocationById = async (locationID:string) => {
noStore();
const dbClient = await getDbClient();
// find a location with the given locationID
const billLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne(
{ _id: locationID },
{
projection: {
// don't include the attachment binary data in the response
"bills.attachment.fileContentsBase64": 0,
},
}
);
if(!billLocation) {
console.log(`Location ${locationID} not found`);
return(null);
}
return(billLocation);
};
export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string, yearMonth:YearMonth, _prevState:any, formData: FormData) => {
noStore();
const dbClient = await getDbClient();
const { id: userId } = user;
const deleteInSubsequentMonths = formData.get('deleteInSubsequentMonths') === 'on';
if (deleteInSubsequentMonths) {
// Get the location name first to find all locations with the same name
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID, userId }, { projection: { name: 1 } });
if (location) {
// Delete all locations with the same name in current and subsequent months
await dbClient.collection<BillingLocation>("lokacije").deleteMany({
userId,
name: location.name,
$or: [
{ "yearMonth.year": { $gt: yearMonth.year } },
{
"yearMonth.year": yearMonth.year,
"yearMonth.month": { $gte: yearMonth.month }
}
]
});
}
} else {
// Delete only the specific location (current behavior)
await dbClient.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
}
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'locationDeleted');
})