Files
evidencija-rezija/app/lib/actions/locationActions.ts
Knee Cola f4e82b7314 Implement bill forwarding strategy with radio button persistence
Added billFwdStrategy field to store user's choice for when to forward
utility bills to tenants, with database persistence and UI updates.

Changes:
- Added billFwdStrategy field to BillingLocation interface ("when-payed" | "when-attached")
- Updated FormSchema to validate billFwdStrategy enum values
- Modified updateOrAddLocation to persist billFwdStrategy in all database operations
- Defaults to "when-payed" (first option) when no value exists in database
- Updated LocationEditForm radio buttons to use persisted database values
- Radio button selection is preserved across edits and restored from database
- Renamed autoTenantNotification to autoBillFwd throughout codebase
- Updated localization strings for bill forwarding features

Form behavior:
- New locations: "when-payed" radio selected by default
- Existing locations: Radio selection matches stored database value
- Value persisted in current, subsequent, and all month update operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 10:10:18 +01:00

503 lines
18 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 { 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[];
generateTenantCode?: string[];
tenantFirstName?: string[];
tenantLastName?: string[];
autoBillFwd?: string[];
tenantEmail?: string[];
billFwdStrategy?: 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(),
generateTenantCode: z.boolean().optional().nullable(),
tenantFirstName: z.string().optional().nullable(),
tenantLastName: z.string().optional().nullable(),
autoBillFwd: z.boolean().optional().nullable(),
tenantEmail: z.string().email(t("tenant-email-invalid")).optional().or(z.literal("")).nullable(),
billFwdStrategy: z.enum(["when-payed", "when-attached"]).optional().nullable(),
addToSubsequentMonths: z.boolean().optional().nullable(),
updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(),
})
// dont include the _id field in the response
.omit({ _id: true })
// Add conditional validation: if generateTenantCode is true, tenant names are required
.refine((data) => {
if (data.generateTenantCode) {
return !!data.tenantFirstName && data.tenantFirstName.trim().length > 0;
}
return true;
}, {
message: t("tenant-first-name-required"),
path: ["tenantFirstName"],
})
.refine((data) => {
if (data.generateTenantCode) {
return !!data.tenantLastName && data.tenantLastName.trim().length > 0;
}
return true;
}, {
message: t("tenant-last-name-required"),
path: ["tenantLastName"],
})
.refine((data) => {
if (data.autoBillFwd) {
return !!data.tenantEmail && data.tenantEmail.trim().length > 0;
}
return true;
}, {
message: t("tenant-email-required"),
path: ["tenantEmail"],
});
/**
* 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'),
generateTenantCode: formData.get('generateTenantCode') === 'on',
tenantFirstName: formData.get('tenantFirstName') || null,
tenantLastName: formData.get('tenantLastName') || null,
autoBillFwd: formData.get('autoBillFwd') === 'on',
tenantEmail: formData.get('tenantEmail') || null,
billFwdStrategy: formData.get('billFwdStrategy') as "when-payed" | "when-attached" | undefined,
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: t("validation-failed"),
});
}
const {
locationName,
locationNotes,
generateTenantCode,
tenantFirstName,
tenantLastName,
autoBillFwd,
tenantEmail,
billFwdStrategy,
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,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
}
}
);
} 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,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
}
}
);
} 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,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
}
}
);
}
} 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,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
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,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
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);
}
}
}
// Redirect to home page with year and month parameters, including success message
if (yearMonth) {
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'locationSaved', yearMonth);
}
// This return is needed for TypeScript, but won't be reached due to redirect
return {
message: null,
errors: undefined,
};
});
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');
// This return is needed for TypeScript, but won't be reached due to redirect
return {
message: null,
errors: undefined,
};
})