Merge branch 'release/1.65.0'

This commit is contained in:
Knee Cola
2025-11-20 18:55:02 +01:00
20 changed files with 1136 additions and 361 deletions

View File

@@ -9,7 +9,8 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"mcp__serena__replace_regex",
"Bash(npm install:*)"
"Bash(npm install:*)",
"mcp__ide__getDiagnostics"
]
},
"enableAllProjectMcpServers": true,

View File

@@ -31,7 +31,9 @@ FROM gcr.io/distroless/nodejs20-debian12:nonroot AS production
WORKDIR /app
# making sure the production server does not use mock auth
ENV NODE_ENV=production
ENV USE_MOCK_AUTH=false
COPY --from=builder /app/public/* /app/public/
# this file is required for the pdfjs-dist package

View File

@@ -1,15 +1,15 @@
import { FC, Suspense } from 'react';
import { Main } from '@/app/ui/Main';
import { AccountForm, AccountFormSkeleton } from '@/app/ui/AccountForm';
import { getUserProfile } from '@/app/lib/actions/userProfileActions';
import { UserSettingsForm as UserSettingsForm, UserSettingsFormSkeleton } from '@/app/ui/AppSettingsForm';
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
const AccountPage: FC = async () => {
const profile = await getUserProfile();
const userSettings = await getUserSettings();
return (
<Main>
<div className="flex flex-col items-center">
<AccountForm profile={profile} />
<UserSettingsForm userSettings={userSettings} />
</div>
</Main>
);
@@ -21,7 +21,7 @@ const Page: FC = () => {
<Main>
<div className="flex flex-col items-center">
<div className="h-8 w-48 skeleton mb-4"></div>
<AccountFormSkeleton />
<UserSettingsFormSkeleton />
</div>
</Main>
}>

View File

@@ -1,6 +1,7 @@
import { ViewLocationCard } from '@/app/ui/ViewLocationCard';
import { fetchLocationById } from '@/app/lib/actions/locationActions';
import { fetchLocationById, setSeenByTenant } from '@/app/lib/actions/locationActions';
import { notFound } from 'next/navigation';
import { myAuth } from '@/app/lib/auth';
export default async function LocationViewPage({ locationId }: { locationId:string }) {
const location = await fetchLocationById(locationId);
@@ -9,5 +10,14 @@ export default async function LocationViewPage({ locationId }: { locationId:stri
return(notFound());
}
// Check if the page was accessed by an authenticated user who is the owner
const session = await myAuth();
const isOwner = session?.user?.id === location.userId;
// If the page is not visited by the owner, mark it as seen by tenant
if (!isOwner) {
await setSeenByTenant(locationId);
}
return (<ViewLocationCard location={location} />);
}

View File

@@ -6,7 +6,7 @@ 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 { gotoHomeWithMessage } from './navigationActions';
import { unstable_noStore as noStore } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server";
@@ -14,7 +14,15 @@ 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[];
rentDueNotification?: string[];
rentDueDay?: string[];
rentAmount?: string[];
};
message?:string | null;
};
@@ -26,12 +34,57 @@ export type State = {
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(),
rentDueNotification: z.boolean().optional().nullable(),
rentDueDay: z.coerce.number().min(1).max(31).optional().nullable(),
rentAmount: z.coerce.number().int(t("rent-amount-integer")).positive(t("rent-amount-positive")).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 });
.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 || data.rentDueNotification) {
return !!data.tenantEmail && data.tenantEmail.trim().length > 0;
}
return true;
}, {
message: t("tenant-email-required"),
path: ["tenantEmail"],
})
.refine((data) => {
if (data.rentDueNotification) {
return !!data.rentAmount && data.rentAmount > 0;
}
return true;
}, {
message: t("rent-amount-required"),
path: ["rentAmount"],
});
/**
* Server-side action which adds or updates a bill
@@ -48,7 +101,15 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
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,
rentDueNotification: formData.get('rentDueNotification') === 'on',
rentDueDay: formData.get('rentDueDay') || null,
rentAmount: formData.get('rentAmount') || null,
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined,
});
@@ -57,13 +118,21 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
if(!validatedFields.success) {
return({
errors: validatedFields.error.flatten().fieldErrors,
message: "Missing Fields",
message: t("validation-failed"),
});
}
const {
locationName,
locationNotes,
generateTenantCode,
tenantFirstName,
tenantLastName,
autoBillFwd,
tenantEmail,
billFwdStrategy,
rentDueNotification,
rentDueDay,
rentAmount,
addToSubsequentMonths,
updateScope,
} = validatedFields.data;
@@ -96,7 +165,15 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{
$set: {
name: locationName,
notes: locationNotes,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
}
}
);
@@ -108,16 +185,24 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
name: currentLocation.name,
$or: [
{ "yearMonth.year": { $gt: currentLocation.yearMonth.year } },
{
"yearMonth.year": currentLocation.yearMonth.year,
"yearMonth.month": { $gte: currentLocation.yearMonth.month }
{
"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",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
}
}
);
@@ -131,7 +216,15 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{
$set: {
name: locationName,
notes: locationNotes,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
}
}
);
@@ -143,7 +236,16 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
userId,
userEmail,
name: locationName,
notes: locationNotes,
notes: null,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
yearMonth: yearMonth,
bills: [],
});
@@ -207,7 +309,16 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
userId,
userEmail,
name: locationName,
notes: locationNotes,
notes: null,
generateTenantCode: generateTenantCode || false,
tenantFirstName: tenantFirstName || null,
tenantLastName: tenantLastName || null,
autoBillFwd: autoBillFwd || false,
tenantEmail: tenantEmail || null,
billFwdStrategy: billFwdStrategy || "when-payed",
rentDueNotification: rentDueNotification || false,
rentDueDay: rentDueDay || null,
rentAmount: rentAmount || null,
yearMonth: { year: monthData.year, month: monthData.month },
bills: [],
});
@@ -221,9 +332,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
}
}
if(yearMonth) {
// Redirect to home page with year and month parameters, including success message
if (yearMonth) {
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'locationSaved');
await gotoHomeWithMessage(locale, 'locationSaved', yearMonth);
}
// This return is needed for TypeScript, but won't be reached due to redirect
@@ -299,6 +411,7 @@ export const fetchAllLocations = withUser(async (user:AuthenticatedUser, year:nu
"yearMonth.year": 1,
"yearMonth.month": 1,
"bills": 1,
"seenByTenant": 1,
// "bills.attachment": 0,
// "bills.notes": 0,
// "bills.barcodeImage": 1,
@@ -417,4 +530,36 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati
message: null,
errors: undefined,
};
})
})
/**
* Sets the `seenByTenant` flag to true for a specific location.
*
* This function marks a location as viewed by the tenant. It first checks if the flag
* is already set to true to avoid unnecessary database updates.
*
* @param {string} locationID - The ID of the location to update
* @returns {Promise<void>}
*
* @example
* await setSeenByTenant("507f1f77bcf86cd799439011");
*/
export const setSeenByTenant = async (locationID: string): Promise<void> => {
const dbClient = await getDbClient();
// First check if the location exists and if seenByTenant is already true
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID });
// If location doesn't exist or seenByTenant is already true, no update needed
if (!location || location.seenByTenant === true) {
return;
}
// Update the location to mark it as seen by tenant
await dbClient.collection<BillingLocation>("lokacije")
.updateOne(
{ _id: locationID },
{ $set: { seenByTenant: true } }
);
}

View File

@@ -9,8 +9,10 @@ export async function gotoHome({year, month}: YearMonth) {
await gotoUrl(path);
}
export async function gotoHomeWithMessage(locale: string, message: string) {
const path = `/${locale}?${message}=true`;
export async function gotoHomeWithMessage(locale: string, message: string, yearMonth?: YearMonth) {
const path = yearMonth
? `/${locale}?year=${yearMonth.year}&month=${yearMonth.month}&${message}=true`
: `/${locale}?${message}=true`;
await gotoUrl(path);
}

View File

@@ -2,7 +2,7 @@
import { z } from 'zod';
import { getDbClient } from '../dbClient';
import { UserProfile } from '../db-types';
import { UserSettings } from '../db-types';
import { withUser } from '@/app/lib/auth';
import { AuthenticatedUser } from '../types/next-auth';
import { unstable_noStore as noStore } from 'next/cache';
@@ -18,13 +18,14 @@ export type State = {
lastName?: string[];
address?: string[];
iban?: string[];
show2dCodeInMonthlyStatement?: string[];
};
message?: string | null;
success?: boolean;
};
/**
* Schema for validating user profile form fields
* Schema for validating user settings form fields
*/
const FormSchema = (t: IntlTemplateFn) => z.object({
firstName: z.string().optional(),
@@ -41,36 +42,79 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
},
{ message: t("iban-invalid") }
),
show2dCodeInMonthlyStatement: z.boolean().optional().nullable(),
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
return !!data.firstName && data.firstName.trim().length > 0;
}
return true;
}, {
message: t("first-name-required"),
path: ["firstName"],
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
return !!data.lastName && data.lastName.trim().length > 0;
}
return true;
}, {
message: t("last-name-required"),
path: ["lastName"],
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
return !!data.address && data.address.trim().length > 0;
}
return true;
}, {
message: t("address-required"),
path: ["address"],
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
if (!data.iban || data.iban.trim().length === 0) {
return false;
}
// Validate IBAN format when required
const cleaned = data.iban.replace(/\s/g, '').toUpperCase();
return IBAN.isValid(cleaned);
}
return true;
}, {
message: t("iban-required"),
path: ["iban"],
});
/**
* Get user profile
* Get user settings
*/
export const getUserProfile = withUser(async (user: AuthenticatedUser) => {
export const getUserSettings = withUser(async (user: AuthenticatedUser) => {
noStore();
const dbClient = await getDbClient();
const { id: userId } = user;
const profile = await dbClient.collection<UserProfile>("users")
const userSettings = await dbClient.collection<UserSettings>("userSettings")
.findOne({ userId });
return profile;
return userSettings;
});
/**
* Update user profile
* Update user settings
*/
export const updateUserProfile = withUser(async (user: AuthenticatedUser, prevState: State, formData: FormData) => {
export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevState: State, formData: FormData) => {
noStore();
const t = await getTranslations("account-form.validation");
const t = await getTranslations("user-settings-form.validation");
const validatedFields = FormSchema(t).safeParse({
firstName: formData.get('firstName') || undefined,
lastName: formData.get('lastName') || undefined,
address: formData.get('address') || undefined,
iban: formData.get('iban') || undefined,
show2dCodeInMonthlyStatement: formData.get('generateTenantCode') === 'on',
});
// If form validation fails, return errors early. Otherwise, continue...
@@ -82,35 +126,36 @@ export const updateUserProfile = withUser(async (user: AuthenticatedUser, prevSt
};
}
const { firstName, lastName, address, iban } = validatedFields.data;
const { firstName, lastName, address, iban, show2dCodeInMonthlyStatement } = validatedFields.data;
// Normalize IBAN: remove spaces and convert to uppercase
const normalizedIban = iban ? iban.replace(/\s/g, '').toUpperCase() : null;
// Update the user profile in MongoDB
// Update the user settings in MongoDB
const dbClient = await getDbClient();
const { id: userId } = user;
const userProfile: UserProfile = {
const userSettings: UserSettings = {
userId,
firstName: firstName || null,
lastName: lastName || null,
address: address || null,
iban: normalizedIban,
show2dCodeInMonthlyStatement: show2dCodeInMonthlyStatement ?? false,
};
await dbClient.collection<UserProfile>("users")
await dbClient.collection<UserSettings>("userSettings")
.updateOne(
{ userId },
{ $set: userProfile },
{ $set: userSettings },
{ upsert: true }
);
revalidatePath('/account');
revalidatePath('/settings');
// Get current locale and redirect to home with success message
const locale = await getLocale();
await gotoHomeWithMessage(locale, 'profileSaved');
await gotoHomeWithMessage(locale, 'userSettingsSaved');
// This return is needed for TypeScript, but won't be reached due to redirect
return {

View File

@@ -14,8 +14,8 @@ export interface YearMonth {
month: number;
};
/** User profile data */
export interface UserProfile {
/** User settings data */
export interface UserSettings {
/** user's ID */
userId: string;
/** first name */
@@ -26,6 +26,8 @@ export interface UserProfile {
address?: string | null;
/** IBAN */
iban?: string | null;
/** whether to show 2D code in monthly statement */
show2dCodeInMonthlyStatement?: boolean | null;
};
/** bill object in the form returned by MongoDB */
@@ -43,6 +45,26 @@ export interface BillingLocation {
bills: Bill[];
/** (optional) notes */
notes: string|null;
/** (optional) whether to generate 2D code for tenant */
generateTenantCode?: boolean | null;
/** (optional) tenant first name */
tenantFirstName?: string | null;
/** (optional) tenant last name */
tenantLastName?: string | null;
/** (optional) whether to automatically notify tenant */
autoBillFwd?: boolean | null;
/** (optional) tenant email */
tenantEmail?: string | null;
/** (optional) bill forwarding strategy */
billFwdStrategy?: "when-payed" | "when-attached" | null;
/** (optional) whether to automatically send rent notification */
rentDueNotification?: boolean | null;
/** (optional) day of month when rent is due (1-31) */
rentDueDay?: number | null;
/** (optional) monthly rent amount in cents */
rentAmount?: number | null;
/** (optional) whether the location has been seen by tenant */
seenByTenant?: boolean | null;
};
export enum BilledTo {

View File

@@ -1,215 +0,0 @@
"use client";
import { FC, useState } from "react";
import { UserProfile } from "../lib/db-types";
import { updateUserProfile } from "../lib/actions/userProfileActions";
import { useFormState, useFormStatus } from "react-dom";
import { useLocale, useTranslations } from "next-intl";
import Link from "next/link";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import { formatIban } from "../lib/formatStrings";
export type AccountFormProps = {
profile: UserProfile | null;
}
type FormFieldsProps = {
profile: UserProfile | null;
errors: any;
message: string | null;
}
const FormFields: FC<FormFieldsProps> = ({ profile, errors, message }) => {
const { pending } = useFormStatus();
const t = useTranslations("account-form");
const locale = useLocale();
// Track current form values for real-time validation
const [formValues, setFormValues] = useState({
firstName: profile?.firstName ?? "",
lastName: profile?.lastName ?? "",
address: profile?.address ?? "",
iban: formatIban(profile?.iban) ?? "",
});
const handleInputChange = (field: keyof typeof formValues, value: string) => {
setFormValues(prev => ({ ...prev, [field]: value }));
};
// Check if any required field is missing (clean IBAN of spaces for validation)
const cleanedIban = formValues.iban.replace(/\s/g, '');
const hasMissingData = !formValues.firstName || !formValues.lastName || !formValues.address || !cleanedIban;
return (
<>
<div className="alert max-w-md flex flex-row items-start">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span className="text-left">{t("info-box-message")}</span>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("first-name-label")}</span>
</label>
<input
id="firstName"
name="firstName"
type="text"
placeholder={t("first-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={profile?.firstName ?? ""}
onChange={(e) => handleInputChange("firstName", e.target.value)}
disabled={pending}
/>
<div id="firstName-error" aria-live="polite" aria-atomic="true">
{errors?.firstName &&
errors.firstName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("last-name-label")}</span>
</label>
<input
id="lastName"
name="lastName"
type="text"
placeholder={t("last-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={profile?.lastName ?? ""}
onChange={(e) => handleInputChange("lastName", e.target.value)}
disabled={pending}
/>
<div id="lastName-error" aria-live="polite" aria-atomic="true">
{errors?.lastName &&
errors.lastName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("address-label")}</span>
</label>
<textarea
id="address"
name="address"
className="textarea textarea-bordered w-full placeholder:text-gray-600"
placeholder={t("address-placeholder")}
defaultValue={profile?.address ?? ""}
onChange={(e) => handleInputChange("address", e.target.value)}
disabled={pending}
></textarea>
<div id="address-error" aria-live="polite" aria-atomic="true">
{errors?.address &&
errors.address.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-label")}</span>
</label>
<input
id="iban"
name="iban"
type="text"
placeholder={t("iban-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formatIban(profile?.iban)}
onChange={(e) => handleInputChange("iban", e.target.value)}
disabled={pending}
/>
<div id="iban-error" aria-live="polite" aria-atomic="true">
{errors?.iban &&
errors.iban.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{hasMissingData && (
<div className="alert mt-4 max-w-md flex flex-row items-start">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-left">{t("warning-missing-data")}</span>
</div>
)}
<div id="general-error" aria-live="polite" aria-atomic="true">
{message && (
<p className="mt-2 text-sm text-red-500">
{message}
</p>
)}
</div>
<div className="pt-4">
<button className="btn btn-primary w-[5.5em]" disabled={pending}>
{pending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
t("save-button")
)}
</button>
<Link className={`btn btn-neutral w-[5.5em] ml-3 ${pending ? "btn-disabled" : ""}`} href={`/${locale}`}>
{t("cancel-button")}
</Link>
</div>
</>
);
};
export const AccountForm: FC<AccountFormProps> = ({ profile }) => {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(updateUserProfile, initialState);
const t = useTranslations("account-form");
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<h2 className="card-title"><AccountCircleIcon className="w-6 h-6" /> {t("title")}</h2>
<form action={dispatch}>
<FormFields
profile={profile}
errors={state.errors}
message={state.message ?? null}
/>
</form>
</div>
</div>
);
};
export const AccountFormSkeleton: FC = () => {
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<div className="h-8 w-32 skeleton mb-4"></div>
<div className="input w-full skeleton"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="textarea w-full h-24 skeleton mt-4"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="pt-4">
<div className="btn skeleton w-24"></div>
</div>
</div>
</div>
);
};

230
app/ui/AppSettingsForm.tsx Normal file
View File

@@ -0,0 +1,230 @@
"use client";
import { FC, useState } from "react";
import { UserSettings } from "../lib/db-types";
import { updateUserSettings } from "../lib/actions/userSettingsActions";
import { useFormState, useFormStatus } from "react-dom";
import { useLocale, useTranslations } from "next-intl";
import Link from "next/link";
import SettingsIcon from "@mui/icons-material/Settings";
import { formatIban } from "../lib/formatStrings";
import { InfoBox } from "./InfoBox";
export type UserSettingsFormProps = {
userSettings: UserSettings | null;
}
type FormFieldsProps = {
userSettings: UserSettings | null;
errors: any;
message: string | null;
}
const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
const { pending } = useFormStatus();
const t = useTranslations("user-settings-form");
const locale = useLocale();
// Track current form values for real-time validation
const [formValues, setFormValues] = useState({
firstName: userSettings?.firstName ?? "",
lastName: userSettings?.lastName ?? "",
address: userSettings?.address ?? "",
iban: formatIban(userSettings?.iban) ?? "",
});
const handleInputChange = (field: keyof typeof formValues, value: string) => {
setFormValues(prev => ({ ...prev, [field]: value }));
};
// Check if any required field is missing (clean IBAN of spaces for validation)
const cleanedIban = formValues.iban.replace(/\s/g, '');
const hasMissingData = !formValues.firstName || !formValues.lastName || !formValues.address || !cleanedIban;
// Track whether to generate 2D code for tenant (use persisted value from database)
const [show2dCodeInMonthlyStatement, setShow2dCodeInMonthlyStatement] = useState(
userSettings?.show2dCodeInMonthlyStatement ?? false
);
return (
<>
<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">{t("tenant-2d-code-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("info-box-message")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="generateTenantCode"
className="toggle toggle-primary"
checked={show2dCodeInMonthlyStatement}
onChange={(e) => setShow2dCodeInMonthlyStatement(e.target.checked)}
/>
<legend className="fieldset-legend">{t("tenant-2d-code-toggle-label")}</legend>
</label>
</fieldset>
{show2dCodeInMonthlyStatement && (
<>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("first-name-label")}</span>
</label>
<input
id="firstName"
name="firstName"
type="text"
placeholder={t("first-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.firstName ?? ""}
onChange={(e) => handleInputChange("firstName", e.target.value)}
disabled={pending}
/>
<div id="firstName-error" aria-live="polite" aria-atomic="true">
{errors?.firstName &&
errors.firstName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("last-name-label")}</span>
</label>
<input
id="lastName"
name="lastName"
type="text"
placeholder={t("last-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.lastName ?? ""}
onChange={(e) => handleInputChange("lastName", e.target.value)}
disabled={pending}
/>
<div id="lastName-error" aria-live="polite" aria-atomic="true">
{errors?.lastName &&
errors.lastName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("address-label")}</span>
</label>
<textarea
id="address"
name="address"
className="textarea textarea-bordered w-full placeholder:text-gray-600"
placeholder={t("address-placeholder")}
defaultValue={userSettings?.address ?? ""}
onChange={(e) => handleInputChange("address", e.target.value)}
disabled={pending}
></textarea>
<div id="address-error" aria-live="polite" aria-atomic="true">
{errors?.address &&
errors.address.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-label")}</span>
</label>
<input
id="iban"
name="iban"
type="text"
placeholder={t("iban-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formatIban(userSettings?.iban)}
onChange={(e) => handleInputChange("iban", e.target.value)}
disabled={pending}
/>
<div id="iban-error" aria-live="polite" aria-atomic="true">
{errors?.iban &&
errors.iban.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<InfoBox className="p-1 mt-1">{t("additional-notes")}</InfoBox>
</>
)}
</fieldset>
<div id="general-error" aria-live="polite" aria-atomic="true">
{message && (
<p className="mt-2 text-sm text-red-500">
{message}
</p>
)}
</div>
<div className="pt-4">
<button className="btn btn-primary w-[5.5em]" disabled={pending}>
{pending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
t("save-button")
)}
</button>
<Link className={`btn btn-neutral w-[5.5em] ml-3 ${pending ? "btn-disabled" : ""}`} href={`/${locale}`}>
{t("cancel-button")}
</Link>
</div>
</>
);
};
export const UserSettingsForm: FC<UserSettingsFormProps> = ({ userSettings }) => {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(updateUserSettings, initialState);
const t = useTranslations("user-settings-form");
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<h2 className="card-title"><SettingsIcon className="w-6 h-6" /> {t("title")}</h2>
<form action={dispatch}>
<FormFields
userSettings={userSettings}
errors={state.errors}
message={state.message ?? null}
/>
</form>
</div>
</div>
);
};
export const UserSettingsFormSkeleton: FC = () => {
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<div className="h-8 w-32 skeleton mb-4"></div>
<div className="input w-full skeleton"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="textarea w-full h-24 skeleton mt-4"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="pt-4">
<div className="btn skeleton w-24"></div>
</div>
</div>
</div>
);
};

9
app/ui/InfoBox.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { FC, ReactNode } from "react";
export const InfoBox: FC<{ children: ReactNode, className?: string }> = ({ children, className }) =>
<div className={`alert max-w-md flex flex-row items-start gap-[0.5rem] ${className}`}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span className="text-left">{children}</span>
</div>

View File

@@ -1,6 +1,6 @@
'use client';
import { Cog8ToothIcon, PlusCircleIcon, ShareIcon } from "@heroicons/react/24/outline";
import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon } from "@heroicons/react/24/outline";
import { FC } from "react";
import { BillBadge } from "./BillBadge";
import { BillingLocation } from "../lib/db-types";
@@ -14,7 +14,10 @@ export interface LocationCardProps {
location: BillingLocation
}
export const LocationCard:FC<LocationCardProps> = ({location: { _id, name, yearMonth, bills }}) => {
export const LocationCard:FC<LocationCardProps> = ({location}) => {
const { _id, name, yearMonth, bills, seenByTenant } = location;
console.log("seenByTenant:", seenByTenant);
const t = useTranslations("home-page.location-card");
const currentLocale = useLocale();
@@ -46,12 +49,27 @@ export const LocationCard:FC<LocationCardProps> = ({location: { _id, name, yearM
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-2xl inline-block" /><span className="text-xs ml-[0.2rem] mr-[3rem]">{t("add-bill-button-tooltip")}</span>
</Link>
</div>
{
monthlyExpense > 0 ?
<p>
{ t("payed-total-label") } <strong>${formatCurrency(monthlyExpense)}</strong>
</p>
: null
{ monthlyExpense > 0 || seenByTenant ?
<fieldset className="card card-compact card-bordered border-1 border-neutral p-3 mt-2 mr-20">
<legend className="fieldset-legend px-2 text-sm font-semibold uppercase">{t("monthly-statement-legend")}</legend>
{
monthlyExpense > 0 ?
<div className="flex items-center gap-2">
<BanknotesIcon className="h-5 w-5" />
{ t("payed-total-label") } <strong>${formatCurrency(monthlyExpense)}</strong>
</div>
: null
}
{seenByTenant && (
<div className="flex items-center gap-2 mt-2">
<CheckCircleIcon className="h-5 w-5 text-success" />
<span className="text-sm">{t("seen-by-tenant-label")}</span>
</div>
)}
</fieldset> : null
}
<ShareIcon className="h-[1em] w-[1em] cursor-pointer text-2xl inline-block hover:text-red-500" title="create sharable link" style={{ position: "absolute", bottom: ".6em", right: "1.2em" }} onClick={handleCopyLinkClick} />

View File

@@ -1,12 +1,13 @@
"use client";
import { TrashIcon } from "@heroicons/react/24/outline";
import { FC } from "react";
import { FC, useState } from "react";
import { BillingLocation, YearMonth } from "../lib/db-types";
import { updateOrAddLocation } from "../lib/actions/locationActions";
import { useFormState } from "react-dom";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { InfoBox } from "./InfoBox";
export type LocationEditFormProps = {
/** location which should be edited */
@@ -20,82 +21,271 @@ export type LocationEditFormProps = {
yearMonth: YearMonth
}
export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth }) =>
{
export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMonth }) => {
const initialState = { message: null, errors: {} };
const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth);
const [ state, dispatch ] = useFormState(handleAction, initialState);
const [state, dispatch] = useFormState(handleAction, initialState);
const t = useTranslations("location-edit-form");
const locale = useLocale();
// Track whether to generate 2D code for tenant (use persisted value from database)
const [generateTenantCode, setGenerateTenantCode] = useState(
location?.generateTenantCode ?? false
);
// Track whether to automatically notify tenant (use persisted value from database)
const [autoBillFwd, setautoBillFwd] = useState(
location?.autoBillFwd ?? false
);
// Track whether to automatically send rent notification (use persisted value from database)
const [rentDueNotification, setrentDueNotification] = useState(
location?.rentDueNotification ?? false
);
// Track tenant field values for real-time validation
const [tenantFields, setTenantFields] = useState({
tenantFirstName: location?.tenantFirstName ?? "",
tenantLastName: location?.tenantLastName ?? "",
tenantEmail: location?.tenantEmail ?? "",
});
const handleTenantFieldChange = (field: keyof typeof tenantFields, value: string) => {
setTenantFields(prev => ({ ...prev, [field]: value }));
};
let { year, month } = location ? location.yearMonth : yearMonth;
return(
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<form action={dispatch}>
{
location &&
<Link href={`/${locale}/location/${location._id}/delete`} className="absolute bottom-5 right-4 tooltip" data-tip={t("delete-tooltip")}>
<TrashIcon className="h-[1em] w-[1em] text-error text-2xl" />
</Link>
location &&
<Link href={`/${locale}/location/${location._id}/delete`} className="absolute bottom-5 right-4 tooltip" data-tip={t("delete-tooltip")}>
<TrashIcon className="h-[1em] w-[1em] text-error text-2xl" />
</Link>
}
<input id="locationName" name="locationName" type="text" placeholder={t("location-name-placeholder")} className="input input-bordered w-full" defaultValue={location?.name ?? ""} />
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.locationName &&
state.errors.locationName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend font-semibold uppercase">{t("location-name-legend")}</legend>
<input id="locationName" name="locationName" type="text" placeholder={t("location-name-placeholder")} className="input input-bordered w-full placeholder:text-gray-600" defaultValue={location?.name ?? ""} />
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.locationName &&
state.errors.locationName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</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">{t("tenant-2d-code-legend")}</legend>
<textarea id="locationNotes" name="locationNotes" className="textarea textarea-bordered my-1 w-full block h-[8em]" placeholder={t("notes-placeholder")} defaultValue={location?.notes ?? ""}></textarea>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.locationNotes &&
state.errors.locationNotes.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<InfoBox className="p-1 mb-1">{t("tenant-2d-code-info")}</InfoBox>
{/* Show different options for add vs edit operations */}
{!location ? (
<div className="form-control">
<label className="label cursor-pointer">
<span className="label-text">{t("add-to-subsequent-months")}</span>
<input type="checkbox" name="addToSubsequentMonths" className="toggle toggle-primary" />
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="generateTenantCode"
className="toggle toggle-primary"
checked={generateTenantCode}
onChange={(e) => setGenerateTenantCode(e.target.checked)}
/>
<legend className="fieldset-legend">{t("tenant-2d-code-toggle-label")}</legend>
</label>
</div>
) : (
<div className="form-control">
<div className="label">
<span className="label-text font-medium">{t("update-scope")}</span>
</fieldset>
{generateTenantCode && (
<>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("tenant-first-name-label")}</span>
</label>
<input
id="tenantFirstName"
name="tenantFirstName"
type="text"
placeholder={t("tenant-first-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantFirstName ?? ""}
onChange={(e) => handleTenantFieldChange("tenantFirstName", e.target.value)}
/>
<div id="tenantFirstName-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantFirstName &&
state.errors.tenantFirstName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full mb-4">
<label className="label">
<span className="label-text">{t("tenant-last-name-label")}</span>
</label>
<input
id="tenantLastName"
name="tenantLastName"
type="text"
placeholder={t("tenant-last-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantLastName ?? ""}
onChange={(e) => handleTenantFieldChange("tenantLastName", e.target.value)}
/>
<div id="tenantLastName-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantLastName &&
state.errors.tenantLastName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
</>
)}
</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">{t("auto-utility-bill-forwarding-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("auto-utility-bill-forwarding-info")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="autoBillFwd"
className="toggle toggle-primary"
checked={autoBillFwd}
onChange={(e) => setautoBillFwd(e.target.checked)}
/>
<legend className="fieldset-legend">{t("auto-utility-bill-forwarding-toggle-label")}</legend>
</label>
</fieldset>
{autoBillFwd && (
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("utility-bill-forwarding-strategy-label")}</legend>
<select defaultValue={location?.billFwdStrategy ?? "when-payed"} className="select input-bordered w-full" name="billFwdStrategy">
<option value="when-payed">{t("utility-bill-forwarding-when-payed")}</option>
<option value="when-attached">{t("utility-bill-forwarding-when-attached")}</option>
</select>
</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">{t("auto-rent-notification-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("auto-rent-notification-info")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="rentDueNotification"
className="toggle toggle-primary"
checked={rentDueNotification}
onChange={(e) => setrentDueNotification(e.target.checked)}
/>
<legend className="fieldset-legend">{t("auto-rent-notification-toggle-label")}</legend>
</label>
</fieldset>
{rentDueNotification && (
<>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("rent-due-day-label")}</legend>
<select defaultValue={location?.rentDueDay ?? 1} className="select input-bordered w-full" name="rentDueDay">
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}>{day}</option>
))}
</select>
</fieldset>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("rent-amount-label")}</legend>
<input
id="rentAmount"
name="rentAmount"
type="number"
min="1"
step="0.01"
placeholder={t("rent-amount-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600 text-right"
defaultValue={location?.rentAmount ?? ""}
/>
<div id="rentAmount-error" aria-live="polite" aria-atomic="true">
{state.errors?.rentAmount &&
state.errors.rentAmount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>
</>
)}
</fieldset>
{(autoBillFwd || rentDueNotification) && (
<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">{t("tenant-email-legend")}</legend>
<input
id="tenantEmail"
name="tenantEmail"
type="email"
placeholder={t("tenant-email-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantEmail ?? ""}
onChange={(e) => handleTenantFieldChange("tenantEmail", e.target.value)}
/>
<div id="tenantEmail-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantEmail &&
state.errors.tenantEmail.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<div className="flex flex-col gap-1 ml-4">
<label className="label cursor-pointer justify-start gap-3 py-1">
<input type="radio" name="updateScope" value="current" className="radio radio-primary" defaultChecked />
<span className="label-text">{t("update-current-month")}</span>
</label>
<label className="label cursor-pointer justify-start gap-3 py-1">
<input type="radio" name="updateScope" value="subsequent" className="radio radio-primary" />
<span className="label-text">{t("update-subsequent-months")}</span>
</label>
<label className="label cursor-pointer justify-start gap-3 py-1">
<input type="radio" name="updateScope" value="all" className="radio radio-primary" />
<span className="label-text">{t("update-all-months")}</span>
</label>
</div>
</div>
</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">{t("scope-legend")}</legend>
{!location ? (
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="addToSubsequentMonths"
className="toggle toggle-primary"
/>
<legend className="fieldset-legend">{t("add-to-subsequent-months")}</legend>
</label>
</fieldset>
) : (
<>
<InfoBox className="p-1 mb-1">{t("update-scope-info")}</InfoBox>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("update-scope-legend")}</legend>
<select defaultValue="current" className="select input-bordered w-full" name="updateScope">
<option value="current">{t("update-current-month")}</option>
<option value="subsequent">{t("update-subsequent-months")}</option>
<option value="all">{t("update-all-months")}</option>
</select>
</fieldset>
</>
)}
</fieldset>
<div id="status-error" aria-live="polite" aria-atomic="true">
{
state.message &&
<p className="mt-2 text-sm text-red-500">
{state.message}
</p>
<p className="mt-2 text-sm text-red-500">
{state.message}
</p>
}
</div>
<div className="pt-4">
@@ -108,13 +298,17 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth
)
}
export const LocationEditFormSkeleton:FC = () =>
{
return(
export const LocationEditFormSkeleton: FC = () => {
const t = useTranslations("location-edit-form");
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<div id="locationName" className="input w-full skeleton"></div>
<div id="locationNotes" className="textarea my-1 w-full block h-[8em] skeleton"></div>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend font-semibold uppercase">{t("location-name-legend")}</legend>
<div id="locationName" className="input w-full skeleton"></div>
</fieldset>
<div className="pt-4">
<div className="btn w-[5.5em] skeleton"></div>
<div className="btn w-[5.5em] ml-3 skeleton"></div>

View File

@@ -52,9 +52,9 @@ export const MonthLocationList:React.FC<MonthLocationListProps > = ({
const params = new URLSearchParams(search.toString());
let messageShown = false;
if (search.get('profileSaved') === 'true') {
toast.success(t("profile-saved-message"), { theme: "dark" });
params.delete('profileSaved');
if (search.get('userSettingsSaved') === 'true') {
toast.success(t("user-settings-saved-message"), { theme: "dark" });
params.delete('userSettingsSaved');
messageShown = true;
}

View File

@@ -1,7 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import { SelectLanguage } from "./SelectLanguage";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import Settings from "@mui/icons-material/Settings";
export const PageHeader = () =>
<div className="navbar bg-base-100 mb-6">
@@ -9,6 +9,6 @@ export const PageHeader = () =>
<span className="grow">&nbsp;</span>
<SelectLanguage />
<Link href="/account/" className="btn btn-ghost btn-circle">
<AccountCircleIcon className="w-6 h-6" />
<Settings className="w-6 h-6" />
</Link>
</div>

230
app/ui/UserSettingsForm.tsx Normal file
View File

@@ -0,0 +1,230 @@
"use client";
import { FC, useState } from "react";
import { UserSettings } from "../lib/db-types";
import { updateUserSettings } from "../lib/actions/userSettingsActions";
import { useFormState, useFormStatus } from "react-dom";
import { useLocale, useTranslations } from "next-intl";
import Link from "next/link";
import SettingsIcon from "@mui/icons-material/Settings";
import { formatIban } from "../lib/formatStrings";
import { InfoBox } from "./InfoBox";
export type UserSettingsFormProps = {
userSettings: UserSettings | null;
}
type FormFieldsProps = {
userSettings: UserSettings | null;
errors: any;
message: string | null;
}
const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
const { pending } = useFormStatus();
const t = useTranslations("user-settings-form");
const locale = useLocale();
// Track current form values for real-time validation
const [formValues, setFormValues] = useState({
firstName: userSettings?.firstName ?? "",
lastName: userSettings?.lastName ?? "",
address: userSettings?.address ?? "",
iban: formatIban(userSettings?.iban) ?? "",
});
const handleInputChange = (field: keyof typeof formValues, value: string) => {
setFormValues(prev => ({ ...prev, [field]: value }));
};
// Check if any required field is missing (clean IBAN of spaces for validation)
const cleanedIban = formValues.iban.replace(/\s/g, '');
const hasMissingData = !formValues.firstName || !formValues.lastName || !formValues.address || !cleanedIban;
// Track whether to generate 2D code for tenant (use persisted value from database)
const [show2dCodeInMonthlyStatement, setShow2dCodeInMonthlyStatement] = useState(
userSettings?.show2dCodeInMonthlyStatement ?? false
);
return (
<>
<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">{t("tenant-2d-code-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("info-box-message")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="generateTenantCode"
className="toggle toggle-primary"
checked={show2dCodeInMonthlyStatement}
onChange={(e) => setShow2dCodeInMonthlyStatement(e.target.checked)}
/>
<legend className="fieldset-legend">{t("tenant-2d-code-toggle-label")}</legend>
</label>
</fieldset>
{show2dCodeInMonthlyStatement && (
<>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("first-name-label")}</span>
</label>
<input
id="firstName"
name="firstName"
type="text"
placeholder={t("first-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.firstName ?? ""}
onChange={(e) => handleInputChange("firstName", e.target.value)}
disabled={pending}
/>
<div id="firstName-error" aria-live="polite" aria-atomic="true">
{errors?.firstName &&
errors.firstName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("last-name-label")}</span>
</label>
<input
id="lastName"
name="lastName"
type="text"
placeholder={t("last-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.lastName ?? ""}
onChange={(e) => handleInputChange("lastName", e.target.value)}
disabled={pending}
/>
<div id="lastName-error" aria-live="polite" aria-atomic="true">
{errors?.lastName &&
errors.lastName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("address-label")}</span>
</label>
<textarea
id="address"
name="address"
className="textarea textarea-bordered w-full placeholder:text-gray-600"
placeholder={t("address-placeholder")}
defaultValue={userSettings?.address ?? ""}
onChange={(e) => handleInputChange("address", e.target.value)}
disabled={pending}
></textarea>
<div id="address-error" aria-live="polite" aria-atomic="true">
{errors?.address &&
errors.address.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-label")}</span>
</label>
<input
id="iban"
name="iban"
type="text"
placeholder={t("iban-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formatIban(userSettings?.iban)}
onChange={(e) => handleInputChange("iban", e.target.value)}
disabled={pending}
/>
<div id="iban-error" aria-live="polite" aria-atomic="true">
{errors?.iban &&
errors.iban.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<InfoBox className="p-1 mt-1">{t("additional-notes")}</InfoBox>
</>
)}
</fieldset>
<div id="general-error" aria-live="polite" aria-atomic="true">
{message && (
<p className="mt-2 text-sm text-red-500">
{message}
</p>
)}
</div>
<div className="pt-4">
<button className="btn btn-primary w-[5.5em]" disabled={pending}>
{pending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
t("save-button")
)}
</button>
<Link className={`btn btn-neutral w-[5.5em] ml-3 ${pending ? "btn-disabled" : ""}`} href={`/${locale}`}>
{t("cancel-button")}
</Link>
</div>
</>
);
};
export const UserSettingsForm: FC<UserSettingsFormProps> = ({ userSettings }) => {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(updateUserSettings, initialState);
const t = useTranslations("user-settings-form");
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<h2 className="card-title"><SettingsIcon className="w-6 h-6" /> {t("title")}</h2>
<form action={dispatch}>
<FormFields
userSettings={userSettings}
errors={state.errors}
message={state.message ?? null}
/>
</form>
</div>
</div>
);
};
export const UserSettingsFormSkeleton: FC = () => {
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<div className="h-8 w-32 skeleton mb-4"></div>
<div className="input w-full skeleton"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="textarea w-full h-24 skeleton mt-4"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="pt-4">
<div className="btn skeleton w-24"></div>
</div>
</div>
</div>
);
};

View File

@@ -55,7 +55,9 @@
"edit-card-tooltip": "Edit realestate",
"add-bill-button-tooltip": "Add a new bill",
"payed-total-label": "Payed total:",
"link-copy-message": "Link copied to clipboard"
"link-copy-message": "Link copied to clipboard",
"monthly-statement-legend": "Monthly statement",
"seen-by-tenant-label": "Seen by tenant"
},
"month-card": {
"payed-total-label": "Total monthly expenditure:",
@@ -74,7 +76,7 @@
"empty-state-title": "No Barcode Data Found",
"empty-state-message": "No bills with 2D barcodes found for {yearMonth}"
},
"profile-saved-message": "Profile updated successfully",
"user-settings-saved-message": "User settings updated successfully",
"bill-saved-message": "Bill saved successfully",
"bill-deleted-message": "Bill deleted successfully",
"location-saved-message": "Location saved successfully",
@@ -122,24 +124,58 @@
"warning-message": "This operation cannot be undone and will delete the location in all future months!"
},
"location-edit-form": {
"location-name-placeholder": "Realestate name",
"location-name-legend": "Realestate name",
"location-name-placeholder": "enter realestate name",
"notes-placeholder": "Notes",
"tenant-2d-code-legend": "TENANT 2D CODE",
"tenant-2d-code-info": "2D barcode allows the tenant to quickly and easily pay the amount they owe you for paid utility bills to your IBAN. The barcode will be displayed when the tenant opens the link to the statement for the given month.",
"tenant-2d-code-toggle-label": "generate 2d code",
"tenant-first-name-label": "Tenant First Name",
"tenant-first-name-placeholder": "Enter tenant's first name",
"tenant-last-name-label": "Tenant Last Name",
"tenant-last-name-placeholder": "Enter tenant's last name",
"auto-utility-bill-forwarding-legend": "AUTOMATIC UTILITY BILL FORWARDING",
"auto-utility-bill-forwarding-info": "This option enables automatic forwarding of utility bills to the tenant via email according to the selected forwarding strategy.",
"auto-utility-bill-forwarding-toggle-label": "forward utility bills",
"utility-bill-forwarding-strategy-label": "Forward utility bills when ...",
"utility-bill-forwarding-when-payed": "all items are marked as paid",
"utility-bill-forwarding-when-attached": "a bill (PDF) is attached to all items",
"auto-rent-notification-legend": "AUTOMATIC RENT NOTIFICATION",
"auto-rent-notification-info": "This option enables automatic sending of monthly rent bill to the tenant via email on the specified day of the month.",
"auto-rent-notification-toggle-label": "send rent notification",
"rent-due-day-label": "Day of month when rent is due",
"rent-amount-label": "Monthly rent amount",
"rent-amount-placeholder": "Enter rent amount",
"tenant-email-legend": "TENANT EMAIL",
"tenant-email-placeholder": "Enter tenant's email",
"warning-missing-tenant-names": "Warning: Tenant first and last name are missing. The 2D barcode will not be displayed to the tenant when they open the shared link until both fields are filled in.",
"save-button": "Save",
"cancel-button": "Cancel",
"delete-tooltip": "Delete realestate",
"add-to-subsequent-months": "Add to all subsequent months",
"update-scope": "Update scope:",
"scope-legend": "Scope of changes",
"add-to-subsequent-months": "add to all subsequent months",
"update-scope-info": "Location records for each month are stored separately. Please choose which records you want to update.",
"update-scope-legend": "I want to update the following records:",
"update-current-month": "current month only",
"update-subsequent-months": "current and all future months",
"update-all-months": "all months",
"validation": {
"location-name-required": "Relaestate name is required"
"location-name-required": "Relaestate name is required",
"tenant-first-name-required": "tenant first name is missing",
"tenant-last-name-required": "tenant last name is missing",
"tenant-email-required": "tenant email is missing",
"tenant-email-invalid": "email address is invalid",
"rent-amount-required": "rent amount is required when rent notification is enabled",
"rent-amount-integer": "rent amount must be a whole number (no decimal places)",
"rent-amount-positive": "rent amount must be a positive number",
"validation-failed": "Validation failed. Please check the form and try again."
}
},
"account-form": {
"title": "Profile Information",
"info-box-message": "This information will be used to generate a 2D barcode displayed in the bill view, allowing tenants to scan it and refund the money you have spent on paying utility bills in their name.",
"warning-missing-data": "Warning: Some profile fields are missing. The 2D barcode will not be displayed to tenants on the shared bill view until all fields are filled in.",
"user-settings-form": {
"title": "User settings",
"info-box-message": "By activating this option, a 2D barcode will be included in the monthly statement sent to the tenant, allowing them to make a direct payment to your bank account.",
"tenant-2d-code-legend": "TENANT 2D CODE",
"tenant-2d-code-toggle-label": "include 2D code in monthly statements",
"first-name-label": "First Name",
"first-name-placeholder": "Enter your first name",
"last-name-label": "Last Name",
@@ -151,8 +187,13 @@
"save-button": "Save",
"cancel-button": "Cancel",
"validation": {
"first-name-required": "First name is mandatory",
"last-name-required": "Last name is mandatory",
"address-required": "Address is mandatory",
"iban-required": "Valid IBAN is mandatory",
"iban-invalid": "Invalid IBAN format. Please enter a valid IBAN",
"validation-failed": "Validation failed. Please check the form and try again."
}
},
"additional-notes": "Note: For the 2D code to be displayed, you must enter both the tenant's first and last names in the settings of each property for which you want to use this functionality."
}
}

View File

@@ -55,7 +55,9 @@
"edit-card-tooltip": "Izmjeni nekretninu",
"add-bill-button-tooltip": "Dodaj novi račun",
"payed-total-label": "Ukupno plaćeno:",
"link-copy-message": "Link kopiran na clipboard"
"link-copy-message": "Link kopiran na clipboard",
"monthly-statement-legend": "Obračun",
"seen-by-tenant-label": "Viđeno od strane podstanara"
},
"month-card": {
"payed-total-label": "Ukupni mjesečni trošak:",
@@ -74,7 +76,7 @@
"empty-state-title": "Nema Podataka o Barkodovima",
"empty-state-message": "Nema računa s 2D barkodovima za {yearMonth}"
},
"profile-saved-message": "Profil uspješno ažuriran",
"user-settings-saved-message": "Korisničke postavke uspješno ažurirane",
"bill-saved-message": "Račun uspješno spremljen",
"bill-deleted-message": "Račun uspješno obrisan",
"location-saved-message": "Nekretnina uspješno spremljena",
@@ -121,24 +123,58 @@
"warning-message": "Ova operacija je nepovratna i obrisat će lokaciju u svim mjesecima koji slijede!"
},
"location-edit-form": {
"location-name-placeholder": "Ime nekretnine",
"location-name-legend": "Realestate name",
"location-name-placeholder": "unesite naziv nekretnine",
"notes-placeholder": "Bilješke",
"tenant-2d-code-legend": "2D BARKOD ZA PODSTANARA",
"tenant-2d-code-info": "2D barkod omogućuje podstanaru da brzo i jednostavno na vaš IBAN uplati iznos koji vam duguje za plaćene režije. Barkod će biti prikazan kada podstanar otvori poveznicu na obračun za zadani mjesec.",
"tenant-2d-code-toggle-label": "generiraj 2D barkod",
"tenant-first-name-label": "Ime podstanara",
"tenant-first-name-placeholder": "Unesite ime podstanara",
"tenant-last-name-label": "Prezime podstanara",
"tenant-last-name-placeholder": "Unesite prezime podstanara",
"auto-utility-bill-forwarding-legend": "AUTOMATSKO PROSLJEĐIVANJE REŽIJA",
"auto-utility-bill-forwarding-info": "Ova opcija omogućuje automatsko prosljeđivanje režija podstanaru putem emaila u skladu s odabranom strategijom.",
"auto-utility-bill-forwarding-toggle-label": "proslijedi režije automatski",
"utility-bill-forwarding-strategy-label": "Režije proslijedi kada...",
"utility-bill-forwarding-when-payed": "sve stavke označim kao plaćene",
"utility-bill-forwarding-when-attached": "za sve stavke priložim račun (PDF)",
"auto-rent-notification-legend": "AUTOMATSKA OBAVIJEST O NAJAMNINI",
"auto-rent-notification-info": "Ova opcija omogućuje automatsko slanje mjesečnog računa za najamninu podstanaru putem emaila na zadani dan u mjesecu.",
"auto-rent-notification-toggle-label": "pošalji obavijest o najamnini",
"rent-due-day-label": "Dan u mjesecu kada dospijeva najamnina",
"rent-amount-label": "Iznos najamnine",
"rent-amount-placeholder": "Unesite iznos najamnine",
"tenant-email-legend": "EMAIL PODSTANARA",
"tenant-email-placeholder": "Unesite email podstanara",
"warning-missing-tenant-names": "Upozorenje: Ime i prezime podstanara nedostaju. 2D barkod neće biti prikazan podstanaru kada otvori podijeljenu poveznicu dok oba polja ne budu popunjena.",
"save-button": "Spremi",
"cancel-button": "Odbaci",
"delete-tooltip": "Brisanje nekretnine",
"add-to-subsequent-months": "Dodaj u sve mjesece koji slijede",
"update-scope": "Opseg ažuriranja:",
"scope-legend": "Opseg promjena",
"add-to-subsequent-months": "dodaj u sve mjesece koji slijede",
"update-scope-info": "Zapisi o lokaciji su za svaki mjesec pohranjeni zasebno. Molimo odaberite koje zapise želite ažurirati.",
"update-scope-legend": "Želim ažurirati sljedeće zapise:",
"update-current-month": "samo trenutni mjesec",
"update-subsequent-months": "trenutni i svi budući mjeseci",
"update-all-months": "svi mjeseci",
"update-subsequent-months": "trenutni i sve buduće mjesece",
"update-all-months": "sve mjesece",
"validation": {
"location-name-required": "Ime nekretnine je obavezno"
"location-name-required": "Ime nekretnine je obavezno",
"tenant-first-name-required": "nedostaje ime podstanara",
"tenant-last-name-required": "nedostaje prezime podstanara",
"tenant-email-required": "nedostaje email podstanara",
"tenant-email-invalid": "email adresa nije ispravna",
"rent-amount-required": "iznos najamnine je obavezan kada je uključena obavijest o najamnini",
"rent-amount-integer": "iznos najamnine mora biti cijeli broj (bez decimalnih mjesta)",
"rent-amount-positive": "iznos najamnine mora biti pozitivan broj",
"validation-failed": "Validacija nije uspjela. Molimo provjerite formu i pokušajte ponovno."
}
},
"account-form": {
"title": "Podaci o profilu",
"info-box-message": "Ovi podaci će se koristiti za generiranje 2D barkoda koji će biti prikazan u pregledu računa, omogućujući podstanarima da ga skeniraju i vrate novac koji ste potrošili na plaćanje režija u njihovo ime.",
"warning-missing-data": "Upozorenje: Neki podaci profila nedostaju. 2D barkod neće biti prikazan podstanarima u podijeljenom pregledu računa dok sva polja ne budu popunjena.",
"user-settings-form": {
"title": "Korisničke postavke",
"info-box-message": "Ako uključite ovu opciji na mjesečnom obračunu koji se šalje podstanaru biti će prikazan 2D bar kod, putem kojeg će moći izvršiti izravnu uplatu na vaš bankovni račun.",
"tenant-2d-code-legend": "2D BARKOD ZA PODSTANARA",
"tenant-2d-code-toggle-label": "prikazuj 2D barkod u mjesečnom obračunu",
"first-name-label": "Ime",
"first-name-placeholder": "Unesite svoje ime",
"last-name-label": "Prezime",
@@ -150,8 +186,13 @@
"save-button": "Spremi",
"cancel-button": "Odbaci",
"validation": {
"first-name-required": "Ime je obavezno",
"last-name-required": "Prezime je obavezno",
"address-required": "Adresa je obavezna",
"iban-required": "Ispravan IBAN je obavezan",
"iban-invalid": "Neispravan IBAN format. Molimo unesite ispravan IBAN.",
"validation-failed": "Validacija nije uspjela. Molimo provjerite formu i pokušajte ponovno."
}
},
"additional-notes": "Napomena: da bi 2D koda bio prikazan, morate unijeti i ime i prezime podstanara u postavkama svake nekretnine za koju želite koristiti ovu funkcionalnost."
}
}

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "evidencija-rezija",
"version": "1.59.0",
"version": "1.65.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"version": "1.59.0",
"version": "1.65.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -57,5 +57,5 @@
"engines": {
"node": ">=18.17.0"
},
"version": "1.59.0"
"version": "1.65.0"
}