Merge branch 'feature/revolute-link-support' into develop

This commit is contained in:
2025-11-24 17:02:38 +01:00
13 changed files with 488 additions and 212 deletions

View File

@@ -1,6 +1,8 @@
import { LocationEditForm } from '@/app/ui/LocationEditForm';
import { YearMonth } from '@/app/lib/db-types';
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
export default async function LocationAddPage({ yearMonth }: { yearMonth:YearMonth }) {
return (<LocationEditForm yearMonth={yearMonth} />);
const userSettings = await getUserSettings();
return (<LocationEditForm yearMonth={yearMonth} userSettings={userSettings} />);
}

View File

@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation';
import { LocationEditForm } from '@/app/ui/LocationEditForm';
import { fetchLocationById } from '@/app/lib/actions/locationActions';
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
export default async function LocationEditPage({ locationId }: { locationId:string }) {
@@ -10,7 +11,9 @@ export default async function LocationEditPage({ locationId }: { locationId:stri
return(notFound());
}
const result = <LocationEditForm location={location} />;
const userSettings = await getUserSettings();
const result = <LocationEditForm location={location} userSettings={userSettings} />;
return (result);
}

View File

@@ -14,7 +14,6 @@ import { getTranslations, getLocale } from "next-intl/server";
export type State = {
errors?: {
locationName?: string[];
generateTenantCode?: string[];
tenantName?: string[];
tenantStreet?: string[];
tenantTown?: string[];
@@ -35,7 +34,7 @@ export type State = {
const FormSchema = (t:IntlTemplateFn) => z.object({
_id: z.string(),
locationName: z.coerce.string().min(1, t("location-name-required")),
generateTenantCode: z.boolean().optional().nullable(),
tenantPaymentMethod: z.enum(["none", "iban", "revolut"]).optional().nullable(),
tenantName: z.string().max(30).optional().nullable(),
tenantStreet: z.string().max(27).optional().nullable(),
tenantTown: z.string().max(27).optional().nullable(),
@@ -50,9 +49,9 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
})
// dont include the _id field in the response
.omit({ _id: true })
// Add conditional validation: if generateTenantCode is true, tenant fields are required
// Add conditional validation: if `tenantPaymentMethod` is "iban", tenant fields are required
.refine((data) => {
if (data.generateTenantCode) {
if (data.tenantPaymentMethod === "iban") {
return !!data.tenantName && data.tenantName.trim().length > 0;
}
return true;
@@ -61,7 +60,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
path: ["tenantName"],
})
.refine((data) => {
if (data.generateTenantCode) {
if (data.tenantPaymentMethod === "iban") {
return !!data.tenantStreet && data.tenantStreet.trim().length > 0;
}
return true;
@@ -70,7 +69,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
path: ["tenantStreet"],
})
.refine((data) => {
if (data.generateTenantCode) {
if (data.tenantPaymentMethod === "iban") {
return !!data.tenantTown && data.tenantTown.trim().length > 0;
}
return true;
@@ -112,7 +111,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
const validatedFields = FormSchema(t).safeParse({
locationName: formData.get('locationName'),
generateTenantCode: formData.get('generateTenantCode') === 'on',
tenantPaymentMethod: formData.get('tenantPaymentMethod') as "none" | "iban" | "revolut" | undefined,
tenantName: formData.get('tenantName') || null,
tenantStreet: formData.get('tenantStreet') || null,
tenantTown: formData.get('tenantTown') || null,
@@ -136,7 +135,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
const {
locationName,
generateTenantCode,
tenantPaymentMethod,
tenantName,
tenantStreet,
tenantTown,
@@ -178,7 +177,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{
$set: {
name: locationName,
generateTenantCode: generateTenantCode || false,
tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
@@ -208,7 +207,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{
$set: {
name: locationName,
generateTenantCode: generateTenantCode || false,
tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
@@ -231,7 +230,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
{
$set: {
name: locationName,
generateTenantCode: generateTenantCode || false,
tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
@@ -253,7 +252,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
userEmail,
name: locationName,
notes: null,
generateTenantCode: generateTenantCode || false,
tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,
@@ -327,7 +326,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
userEmail,
name: locationName,
notes: null,
generateTenantCode: generateTenantCode || false,
tenantPaymentMethod: tenantPaymentMethod || "none",
tenantName: tenantName || null,
tenantStreet: tenantStreet || null,
tenantTown: tenantTown || null,

View File

@@ -19,7 +19,7 @@ export type State = {
ownerTown?: string[];
ownerIBAN?: string[];
currency?: string[];
show2dCodeInMonthlyStatement?: string[];
ownerRevolutProfileName?: string[];
};
message?: string | null;
success?: boolean;
@@ -29,6 +29,8 @@ export type State = {
* Schema for validating user settings form fields
*/
const FormSchema = (t: IntlTemplateFn) => z.object({
currency: z.string().optional(),
enableIbanPayment: z.boolean().optional(),
ownerName: z.string().max(25).optional(),
ownerStreet: z.string().max(25).optional(),
ownerTown: z.string().max(27).optional(),
@@ -43,11 +45,11 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
},
{ message: t("owner-iban-invalid") }
),
currency: z.string().optional(),
show2dCodeInMonthlyStatement: z.boolean().optional().nullable(),
enableRevolutPayment: z.boolean().optional(),
ownerRevolutProfileName: z.string().max(25).optional(),
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
if (data.enableIbanPayment) {
return !!data.ownerName && data.ownerName.trim().length > 0;
}
return true;
@@ -56,7 +58,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerName"],
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
if (data.enableIbanPayment) {
return !!data.ownerStreet && data.ownerStreet.trim().length > 0;
}
return true;
@@ -65,7 +67,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerStreet"],
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
if (data.enableIbanPayment) {
return !!data.ownerTown && data.ownerTown.trim().length > 0;
}
return true;
@@ -74,7 +76,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerTown"],
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
if (data.enableIbanPayment) {
if (!data.ownerIBAN || data.ownerIBAN.trim().length === 0) {
return false;
}
@@ -88,13 +90,33 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
path: ["ownerIBAN"],
})
.refine((data) => {
if (data.show2dCodeInMonthlyStatement) {
if (data.enableIbanPayment) {
return !!data.currency && data.currency.trim().length > 0;
}
return true;
}, {
message: t("currency-required"),
path: ["currency"],
})
.refine((data) => {
if (data.enableRevolutPayment) {
return !!data.ownerRevolutProfileName && data.ownerRevolutProfileName.trim().length > 0;
}
return true;
}, {
message: t("owner-revolut-profile-required"),
path: ["ownerRevolutProfileName"],
})
.refine((data) => {
if (data.enableRevolutPayment && data.ownerRevolutProfileName) {
const profileName = data.ownerRevolutProfileName.trim();
// Must start with @ and contain only English letters and numbers
return /^@[a-zA-Z0-9]+$/.test(profileName);
}
return true;
}, {
message: t("owner-revolut-profile-invalid"),
path: ["ownerRevolutProfileName"],
});
/**
@@ -141,7 +163,9 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS
ownerTown: formData.get('ownerTown') || undefined,
ownerIBAN: formData.get('ownerIBAN') || undefined,
currency: formData.get('currency') || undefined,
show2dCodeInMonthlyStatement: formData.get('generateTenantCode') === 'on',
enableIbanPayment: formData.get('enableIbanPayment') === 'on' ? true : false,
enableRevolutPayment: formData.get('enableRevolutPayment') === 'on' ? true : false,
ownerRevolutProfileName: formData.get('ownerRevolutProfileName') || undefined,
});
// If form validation fails, return errors early. Otherwise, continue...
@@ -153,7 +177,7 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS
};
}
const { ownerName, ownerStreet, ownerTown, ownerIBAN, currency, show2dCodeInMonthlyStatement } = validatedFields.data;
const { enableIbanPayment, ownerName, ownerStreet, ownerTown, ownerIBAN, currency, enableRevolutPayment, ownerRevolutProfileName } = validatedFields.data;
// Normalize IBAN: remove spaces and convert to uppercase
const normalizedOwnerIBAN = ownerIBAN ? ownerIBAN.replace(/\s/g, '').toUpperCase() : null;
@@ -164,12 +188,14 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS
const userSettings: UserSettings = {
userId,
ownerName: ownerName || null,
ownerStreet: ownerStreet || null,
ownerTown: ownerTown || null,
enableIbanPayment: enableIbanPayment ?? false,
ownerName: ownerName ?? undefined,
ownerStreet: ownerStreet ?? undefined,
ownerTown: ownerTown ?? undefined,
ownerIBAN: normalizedOwnerIBAN,
currency: currency || null,
show2dCodeInMonthlyStatement: show2dCodeInMonthlyStatement ?? false,
currency: currency ?? undefined,
enableRevolutPayment: enableRevolutPayment ?? false,
ownerRevolutProfileName: ownerRevolutProfileName ?? undefined,
};
await dbClient.collection<UserSettings>("userSettings")

View File

@@ -18,6 +18,8 @@ export interface YearMonth {
export interface UserSettings {
/** user's ID */
userId: string;
/** whether enableshow IBAN payment instructions in monthly statement */
enableIbanPayment?: boolean | null;
/** owner name */
ownerName?: string | null;
/** owner street */
@@ -28,8 +30,10 @@ export interface UserSettings {
ownerIBAN?: string | null;
/** currency (ISO 4217) */
currency?: string | null;
/** whether to show 2D code in monthly statement */
show2dCodeInMonthlyStatement?: boolean | null;
/** whether to enable Revolut payment instructions in monthly statement */
enableRevolutPayment?: boolean | null;
/** owner Revolut payment link */
ownerRevolutProfileName?: string | null;
};
/** bill object in the form returned by MongoDB */
@@ -47,8 +51,10 @@ export interface BillingLocation {
bills: Bill[];
/** (optional) notes */
notes: string|null;
/** (optional) whether to generate 2D code for tenant */
generateTenantCode?: boolean | null;
/** (optional) method for showing payment instructions to tenant */
tenantPaymentMethod?: "none" | "iban" | "revolut" | null;
/** (optional) tenant name */
tenantName?: string | null;
/** (optional) tenant street */

View File

@@ -2,7 +2,7 @@
import { TrashIcon } from "@heroicons/react/24/outline";
import { FC, useState } from "react";
import { BillingLocation, YearMonth } from "../lib/db-types";
import { BillingLocation, UserSettings, YearMonth } from "../lib/db-types";
import { updateOrAddLocation } from "../lib/actions/locationActions";
import { useFormState } from "react-dom";
import Link from "next/link";
@@ -13,46 +13,44 @@ export type LocationEditFormProps = {
/** location which should be edited */
location: BillingLocation,
/** year adn month at a new billing location should be assigned */
yearMonth?: undefined
yearMonth?: undefined,
/** user settings for payment configuration */
userSettings: UserSettings | null
} | {
/** location which should be edited */
location?: undefined,
/** year adn month at a new billing location should be assigned */
yearMonth: YearMonth
yearMonth: YearMonth,
/** user settings for payment configuration */
userSettings: UserSettings | null
}
export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMonth }) => {
export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMonth, userSettings }) => {
const initialState = { message: null, errors: {} };
const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth);
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({
const [formValues, setFormValues] = useState({
locationName: location?.name ?? "",
tenantName: location?.tenantName ?? "",
tenantStreet: location?.tenantStreet ?? "",
tenantTown: location?.tenantTown ?? "",
tenantEmail: location?.tenantEmail ?? "",
tenantPaymentMethod: location?.tenantPaymentMethod ?? "none",
autoBillFwd: location?.autoBillFwd ?? false,
billFwdStrategy: location?.billFwdStrategy ?? "when-payed",
rentDueNotification: location?.rentDueNotification ?? false,
rentAmount: location?.rentAmount ?? "",
rentDueDay: location?.rentDueDay ?? 1,
});
const handleTenantFieldChange = (field: keyof typeof tenantFields, value: string) => {
setTenantFields(prev => ({ ...prev, [field]: value }));
const handleInputChange = (field: keyof typeof formValues, value: string | boolean | number) => {
setFormValues(prev => ({ ...prev, [field]: value }));
};
let { year, month } = location ? location.yearMonth : yearMonth;
@@ -69,7 +67,14 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
}
<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 ?? ""} />
<input id="locationName"
name="locationName"
type="text"
placeholder={t("location-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
value={formValues.locationName}
onChange={(e) => handleInputChange("locationName", e.target.value)}
/>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.locationName &&
state.errors.locationName.map((error: string) => (
@@ -79,38 +84,54 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
))}
</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>
<InfoBox className="p-1 mb-1">{t("tenant-2d-code-info")}</InfoBox>
<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>
<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-payment-instructions-legend")}</legend>
<InfoBox className="p-1 pt-0 mb-1">{t("tenant-payment-instructions-code-info")}</InfoBox>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("tenant-payment-instructions-method--legend")}</legend>
<select
value={(!userSettings?.enableIbanPayment && !userSettings?.enableRevolutPayment) ? "none" : formValues.tenantPaymentMethod}
className="select input-bordered w-full"
name="tenantPaymentMethod"
onChange={(e) => handleInputChange("tenantPaymentMethod", e.target.value)}
>
<option value="none">{t("tenant-payment-instructions-method--none")}</option>
<option value="iban" disabled={!userSettings?.enableIbanPayment}>
{
userSettings?.enableIbanPayment ?
t("tenant-payment-instructions-method--iban") :
t("tenant-payment-instructions-method--iban-disabled")
}
</option>
<option value="revolut" disabled={!userSettings?.enableRevolutPayment}>
{
userSettings?.enableRevolutPayment ?
t("tenant-payment-instructions-method--revolut") :
t("tenant-payment-instructions-method--revolut-disabled")
}
</option>
</select>
</fieldset>
{generateTenantCode && (
{ formValues.tenantPaymentMethod === "iban" && userSettings?.enableIbanPayment ? (
<>
<div className="divider mt-4 mb-2 font-bold uppercase">{t("iban-payment--form-title")}</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("tenant-name-label")}</span>
<span className="label-text">{t("iban-payment--tenant-name-label")}</span>
</label>
<input
id="tenantName"
name="tenantName"
type="text"
maxLength={30}
placeholder={t("tenant-name-placeholder")}
placeholder={t("iban-payment--tenant-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantName ?? ""}
onChange={(e) => handleTenantFieldChange("tenantName", e.target.value)}
defaultValue={formValues.tenantName}
onChange={(e) => handleInputChange("tenantName", e.target.value)}
/>
<div id="tenantName-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantName &&
@@ -124,17 +145,17 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("tenant-street-label")}</span>
<span className="label-text">{t("iban-payment--tenant-street-label")}</span>
</label>
<input
id="tenantStreet"
name="tenantStreet"
type="text"
maxLength={27}
placeholder={t("tenant-street-placeholder")}
placeholder={t("iban-payment--tenant-street-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantStreet ?? ""}
onChange={(e) => handleTenantFieldChange("tenantStreet", e.target.value)}
defaultValue={formValues.tenantStreet}
onChange={(e) => handleInputChange("tenantStreet", e.target.value)}
/>
<div id="tenantStreet-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantStreet &&
@@ -148,17 +169,17 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
<div className="form-control w-full mb-4">
<label className="label">
<span className="label-text">{t("tenant-town-label")}</span>
<span className="label-text">{t("iban-payment--tenant-town-label")}</span>
</label>
<input
id="tenantTown"
name="tenantTown"
type="text"
maxLength={27}
placeholder={t("tenant-town-placeholder")}
placeholder={t("iban-payment--tenant-town-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={location?.tenantTown ?? ""}
onChange={(e) => handleTenantFieldChange("tenantTown", e.target.value)}
defaultValue={formValues.tenantTown}
onChange={(e) => handleInputChange("tenantTown", e.target.value)}
/>
<div id="tenantTown-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantTown &&
@@ -169,12 +190,32 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
))}
</div>
</div>
<InfoBox className="p-1 mb-1">{t("tenant-2d-code-note")}</InfoBox>
</>
)}
) : // ELSE include hidden inputs to preserve existing values
<>
<input
id="tenantName"
name="tenantName"
type="hidden"
maxLength={30}
defaultValue={formValues.tenantName}
/>
<input
id="tenantStreet"
name="tenantStreet"
type="hidden"
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.tenantStreet}
/>
<input
id="tenantTown"
name="tenantTown"
type="hidden"
defaultValue={formValues.tenantTown}
/>
</>
}
</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>
@@ -186,17 +227,17 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
type="checkbox"
name="autoBillFwd"
className="toggle toggle-primary"
checked={autoBillFwd}
onChange={(e) => setautoBillFwd(e.target.checked)}
checked={formValues.autoBillFwd}
onChange={(e) => handleInputChange("autoBillFwd", e.target.checked)}
/>
<legend className="fieldset-legend">{t("auto-utility-bill-forwarding-toggle-label")}</legend>
</label>
</fieldset>
{autoBillFwd && (
{formValues.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">
<select defaultValue={formValues.billFwdStrategy} 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>
@@ -215,18 +256,22 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
type="checkbox"
name="rentDueNotification"
className="toggle toggle-primary"
checked={rentDueNotification}
onChange={(e) => setrentDueNotification(e.target.checked)}
checked={formValues.rentDueNotification}
onChange={(e) => handleInputChange("rentDueNotification", e.target.checked)}
/>
<legend className="fieldset-legend">{t("auto-rent-notification-toggle-label")}</legend>
</label>
</fieldset>
{rentDueNotification && (
{formValues.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">
<select defaultValue={formValues.rentDueDay}
className="select input-bordered w-full"
name="rentDueDay"
onChange={(e) => handleInputChange("rentDueDay", parseInt(e.target.value,10))
}>
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}>{day}</option>
))}
@@ -242,7 +287,8 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
step="0.01"
placeholder={t("rent-amount-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600 text-right"
defaultValue={location?.rentAmount ?? ""}
defaultValue={formValues.rentAmount}
onChange={(e) => handleInputChange("rentAmount", parseFloat(e.target.value))}
/>
<div id="rentAmount-error" aria-live="polite" aria-atomic="true">
{state.errors?.rentAmount &&
@@ -257,7 +303,7 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
)}
</fieldset>
{(autoBillFwd || rentDueNotification) && (
{(formValues.autoBillFwd || formValues.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
@@ -266,8 +312,8 @@ export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMont
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)}
defaultValue={formValues.tenantEmail}
onChange={(e) => handleInputChange("tenantEmail", e.target.value)}
/>
<div id="tenantEmail-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantEmail &&

7
app/ui/NoteBox.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { FC, ReactNode } from "react";
export const NoteBox: FC<{ children: ReactNode, className?: string }> = ({ children, className }) =>
<div className={`alert max-w-md flex flex-row items-start gap-[.8rem] ml-1 ${className}`}>
<span className="w-6 h-6 text-xl"></span>
<span className="text-left">{children}</span>
</div>

View File

@@ -9,6 +9,8 @@ import Link from "next/link";
import SettingsIcon from "@mui/icons-material/Settings";
import { formatIban } from "../lib/formatStrings";
import { InfoBox } from "./InfoBox";
import { NoteBox } from "./NoteBox";
import { LinkIcon } from "@heroicons/react/24/outline";
export type UserSettingsFormProps = {
userSettings: UserSettings | null;
@@ -27,26 +29,23 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
// Track current form values for real-time validation
const [formValues, setFormValues] = useState({
enableIbanPayment: userSettings?.enableIbanPayment ?? false,
ownerName: userSettings?.ownerName ?? "",
ownerStreet: userSettings?.ownerStreet ?? "",
ownerTown: userSettings?.ownerTown ?? "",
ownerIBAN: formatIban(userSettings?.ownerIBAN) ?? "",
currency: userSettings?.currency ?? "EUR",
enableRevolutPayment: userSettings?.enableRevolutPayment ?? false,
ownerRevolutProfileName: userSettings?.ownerRevolutProfileName ?? "",
});
const handleInputChange = (field: keyof typeof formValues, value: string) => {
// https://revolut.me/aderezic?currency=EUR&amount=70000
const handleInputChange = (field: keyof typeof formValues, value: string | boolean) => {
setFormValues(prev => ({ ...prev, [field]: value }));
};
// Check if any required field is missing (clean IBAN of spaces for validation)
const cleanedOwnerIBAN = formValues.ownerIBAN.replace(/\s/g, '');
const hasMissingData = !formValues.ownerName || !formValues.ownerStreet || !formValues.ownerTown || !cleanedOwnerIBAN || !formValues.currency;
// 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 pt-1 pb-2 mt-4">
@@ -60,7 +59,7 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
id="currency"
name="currency"
className="select select-bordered w-full"
defaultValue={userSettings?.currency ?? "EUR"}
defaultValue={formValues.currency}
onChange={(e) => handleInputChange("currency", e.target.value)}
disabled={pending}
>
@@ -110,38 +109,40 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
</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("tenant-2d-code-legend")}</legend>
<InfoBox className="p-1 mb-1">{t("info-box-message")}</InfoBox>
<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("iban-payment-instructions--legend")}</legend>
<InfoBox className="p-1 mb-1">{t("iban-payment-instructions--intro-message")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="generateTenantCode"
name="enableIbanPayment"
className="toggle toggle-primary"
checked={show2dCodeInMonthlyStatement}
onChange={(e) => setShow2dCodeInMonthlyStatement(e.target.checked)}
checked={formValues.enableIbanPayment}
onChange={(e) => handleInputChange("enableIbanPayment", e.target.checked)}
/>
<legend className="fieldset-legend">{t("tenant-2d-code-toggle-label")}</legend>
<legend className="fieldset-legend">{t("iban-payment-instructions--toggle-label")}</legend>
</label>
</fieldset>
{show2dCodeInMonthlyStatement && (
{ formValues.enableIbanPayment ? (
<>
<div className="divider mt-2 mb-2 font-bold uppercase">{t("iban-form-title")}</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("owner-name-label")}</span>
<span className="label-text">{t("iban-owner-name-label")}</span>
</label>
<input
id="ownerName"
name="ownerName"
type="text"
maxLength={25}
placeholder={t("owner-name-placeholder")}
placeholder={t("iban-owner-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.ownerName ?? ""}
defaultValue={formValues.ownerName}
onChange={(e) => handleInputChange("ownerName", e.target.value)}
disabled={pending}
/>
@@ -157,16 +158,16 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("owner-street-label")} </span>
<span className="label-text">{t("iban-owner-street-label")} </span>
</label>
<input
id="ownerStreet"
name="ownerStreet"
type="text"
maxLength={25}
placeholder={t("owner-street-placeholder")}
placeholder={t("iban-owner-street-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.ownerStreet ?? ""}
defaultValue={formValues.ownerStreet}
onChange={(e) => handleInputChange("ownerStreet", e.target.value)}
disabled={pending}
/>
@@ -182,16 +183,16 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("owner-town-label")}</span>
<span className="label-text">{t("iban-owner-town-label")}</span>
</label>
<input
id="ownerTown"
name="ownerTown"
type="text"
maxLength={27}
placeholder={t("owner-town-placeholder")}
placeholder={t("iban-owner-town-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={userSettings?.ownerTown ?? ""}
defaultValue={formValues.ownerTown}
onChange={(e) => handleInputChange("ownerTown", e.target.value)}
disabled={pending}
/>
@@ -207,15 +208,15 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("owner-iban-label")}</span>
<span className="label-text">{t("iban-owner-iban-label")}</span>
</label>
<input
id="ownerIBAN"
name="ownerIBAN"
type="text"
placeholder={t("owner-iban-placeholder")}
placeholder={t("iban-owner-iban-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formatIban(userSettings?.ownerIBAN)}
defaultValue={formValues.ownerIBAN}
onChange={(e) => handleInputChange("ownerIBAN", e.target.value)}
disabled={pending}
/>
@@ -229,9 +230,114 @@ const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
</div>
</div>
<InfoBox className="p-1 mt-1">{t("additional-notes")}</InfoBox>
</>
)}
<NoteBox className="p-1 mt-1">{t("payment-additional-notes")}</NoteBox>
</>
) : // ELSE include hidden inputs to preserve existing values
<>
<input
id="ownerName"
name="ownerName"
type="hidden"
value={formValues.ownerName}
/>
<input
id="ownerStreet"
name="ownerStreet"
type="hidden"
value={formValues.ownerStreet}
/>
<input
id="ownerTown"
name="ownerTown"
type="hidden"
defaultValue={formValues.ownerTown}
/>
<input
id="ownerIBAN"
name="ownerIBAN"
type="hidden"
defaultValue={formValues.ownerIBAN}
/>
</>
}
</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("revolut-payment-instructions--legend")}</legend>
<InfoBox className="p-1 mb-1">{t("revolut-payment-instructions--intro-message")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enableRevolutPayment"
className="toggle toggle-primary"
checked={formValues.enableRevolutPayment}
onChange={(e) => handleInputChange("enableRevolutPayment", e.target.checked)}
/>
<legend className="fieldset-legend">{t("revolut-payment-instructions--toggle-label")}</legend>
</label>
</fieldset>
{ formValues.enableRevolutPayment ? (
<>
<div className="divider mt-2 mb-2 font-bold uppercase">{t("revolut-form-title")}</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("revolut-profile-label")}</span>
</label>
<input
id="ownerRevolutProfileName"
name="ownerRevolutProfileName"
type="text"
maxLength={25}
placeholder={t("revolut-profile-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.ownerRevolutProfileName}
onChange={(e) => handleInputChange("ownerRevolutProfileName", e.target.value)}
disabled={pending}
/>
<label className="label">
<span className="label-text-alt text-gray-500 max-w-[25rem]">{t("revolut-profile-tooltip")}</span>
</label>
<div id="ownerRevolutProfileName-error" aria-live="polite" aria-atomic="true">
{errors?.ownerRevolutProfileName &&
errors.ownerRevolutProfileName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
{
!errors?.ownerRevolutProfileName && formValues.ownerRevolutProfileName.length > 5 ? (
<p className="p-2 text-center">
{t("revolut-profile--test-link-label")} {' '}
<LinkIcon className="h-[1.2em] w-[1.2em] inline-block ml-1 mr-1"/>
<Link
href={`https://revolut.me/${formValues.ownerRevolutProfileName?.replace('@', '')}?amount=100&currency=${formValues.currency}`}
target="_blank"
className="underline"
>
{t("revolut-profile--test-link-text")}
</Link>
</p>
) : null
}
</div>
<NoteBox className="p-1 mt-1">{t("payment-additional-notes")}</NoteBox>
</>
)
: // ELSE include hidden input to preserve existing value
<>
<input
id="ownerRevolutProfileName"
name="ownerRevolutProfileName"
type="hidden"
value={formValues.ownerRevolutProfileName}
/>
</>
}
</fieldset>
<div id="general-error" aria-live="polite" aria-atomic="true">

View File

@@ -9,8 +9,9 @@ import { ViewBillBadge } from "./ViewBillBadge";
import { Pdf417Barcode } from "./Pdf417Barcode";
import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder";
import Link from "next/link";
import { DocumentIcon } from "@heroicons/react/24/outline";
import { DocumentIcon, LinkIcon } from "@heroicons/react/24/outline";
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
import QRCode from "react-qr-code";
export interface ViewLocationCardProps {
location: BillingLocation;
@@ -27,7 +28,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
tenantName,
tenantStreet,
tenantTown,
generateTenantCode,
tenantPaymentMethod,
// NOTE: only the fileName is projected from the DB to reduce data transfer
utilBillsProofOfPaymentAttachment,
utilBillsProofOfPaymentUploadedAt,
@@ -79,7 +80,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
const { hub3aText, paymentParams } = useMemo(() => {
if(!userSettings?.show2dCodeInMonthlyStatement || !generateTenantCode) {
if(!userSettings?.enableIbanPayment || tenantPaymentMethod !== "iban") {
return {
hub3aText: "",
paymentParams: {} as PaymentParams
@@ -107,7 +108,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
hub3aText: EncodePayment(paymentParams),
paymentParams
});
}, [userSettings?.show2dCodeInMonthlyStatement, generateTenantCode, locationName, tenantName, tenantStreet, tenantTown, userSettings, monthlyExpense, yearMonth]);
}, []);
return(
<div data-key={_id } className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1">
@@ -126,7 +127,7 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
: null
}
{
userSettings?.show2dCodeInMonthlyStatement && generateTenantCode ?
userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ?
<>
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
<ul className="ml-4 mb-3">
@@ -145,6 +146,30 @@ export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettin
</>
: null
}
{
userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => {
const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(monthlyExpense).toFixed(0)}&currency=${userSettings.currency}`;
return (
<>
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
<div className="flex justify-center">
<QRCode value={revolutPaymentUrl} size={200} className="p-4 bg-white border border-gray-300 rounded-box" />
</div>
<p className="text-center mt-1 mb-3">
<LinkIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 ml-[-.5em]"/>
<Link
href={revolutPaymentUrl}
target="_blank"
className="underline"
>
{t("revolut-link-text")}
</Link>
</p>
</>
);
})()
: null
}
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend>
{

View File

@@ -70,7 +70,8 @@
"payment-purpose-code-label": "Purpose code:",
"payment-description-label": "Payment description:",
"upload-proof-of-payment-legend": "Proof of payment",
"upload-proof-of-payment-label": "Here you can upload proof of payment:"
"upload-proof-of-payment-label": "Here you can upload proof of payment:",
"revolut-link-text": "Pay with Revolut"
},
"month-card": {
"payed-total-label": "Total monthly expenditure:",
@@ -141,23 +142,33 @@
"location-name-legend": "Realestate name",
"location-name-placeholder": "enter realestate name",
"notes-placeholder": "notes",
"tenant-2d-code-legend": "PAYMENT INSTRUCTIONS",
"tenant-2d-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.",
"tenant-2d-code-toggle-label": "show payment instructions to the tenant",
"tenant-2d-code-note": "IMPORTANT: for this to work you will also need to go into app settings and enter your name and IBAN.",
"tenant-name-label": "Tenant First and Last Name",
"tenant-name-placeholder": "enter tenant's first and last name",
"tenant-street-label": "Tenant Street and House Number",
"tenant-street-placeholder": "enter tenant's street",
"tenant-town-label": "Tenant Postal Code and Town",
"tenant-town-placeholder": "enter tenant's town",
"auto-utility-bill-forwarding-legend": "AUTOMATIC UTILITY BILL FORWARDING",
"tenant-payment-instructions-legend": "PAYMENT INSTRUCTIONS",
"tenant-payment-instructions-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.",
"tenant-payment-instructions-method--legend": "Show payment instructions to tenant:",
"tenant-payment-instructions-method--none": "do not show payment instructions",
"tenant-payment-instructions-method--iban": "payment via IBAN",
"tenant-payment-instructions-method--iban-disabled": "payment via IBAN - disabled in app settings",
"tenant-payment-instructions-method--revolut": "payment via Revolut",
"tenant-payment-instructions-method--revolut-disabled": "payment via Revolut - disabled in app settings",
"iban-payment--tenant-name-label": "Tenant First and Last Name",
"iban-payment--tenant-name-placeholder": "enter tenant's first and last name",
"iban-payment--tenant-street-label": "Tenant Street and House Number",
"iban-payment--tenant-street-placeholder": "enter tenant's street",
"iban-payment--tenant-town-label": "Tenant Postal Code and Town",
"iban-payment--tenant-town-placeholder": "enter tenant's town",
"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-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",
@@ -191,17 +202,35 @@
},
"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",
"owner-name-label": "Your First and Last Name",
"owner-name-placeholder": "enter your first and last name",
"owner-street-label": "Your Street and House Number",
"owner-street-placeholder": "enter your street and house number",
"owner-town-label": "Your Postal Code and Town",
"owner-town-placeholder": "enter your postal code and town",
"owner-iban-label": "IBAN",
"owner-iban-placeholder": "enter your IBAN",
"iban-payment-instructions--legend": "Payment to Your IBAN",
"iban-payment-instructions--intro-message": "By activating this option, payment instructions will be included in the monthly statement sent to the tenant, allowing a direct payment via IBAN to be made to your bank account.",
"iban-payment-instructions--toggle-label": "enable IBAN payment instructions",
"iban-form-title": "Payment Information for IBAN",
"iban-owner-name-label": "Your First and Last Name",
"iban-owner-name-placeholder": "enter your first and last name",
"iban-owner-street-label": "Your Street and House Number",
"iban-owner-street-placeholder": "enter your street and house number",
"iban-owner-town-label": "Your Postal Code and Town",
"iban-owner-town-placeholder": "enter your postal code and town",
"iban-owner-iban-label": "IBAN",
"iban-owner-iban-placeholder": "enter your IBAN for receiving payments",
"revolut-form-title": "Payment Information for Revolut",
"revolut-payment-instructions--legend": "Payment to Your Revolut Profile",
"revolut-payment-instructions--intro-message": "By activating this option, payment instructions will be included in the monthly statement sent to the tenant, allowing a direct payment via Revolut to be made to your Revolut profile.",
"revolut-payment-instructions--toggle-label": "enable Revolut payment instructions",
"revolut-profile-label": "Revolut profile name",
"revolut-profile-placeholder": "enter your Revolut profile name for receiving payments",
"revolut-profile-tooltip": "You can find your Revolut profile name in the Revolut app under your user profile. It is displayed below your name and starts with the '@' symbol (e.g., '@john123').",
"revolut-profile--test-link-label": "Test your Revolut link:",
"revolut-profile--test-link-text": "Pay with Revolut",
"payment-additional-notes": "IMPORTANT: For the payment instructions to be displayed to the tenant, you must also enable this option in the property's settings.",
"general-settings-legend": "General Settings",
"currency-label": "Currency",
"save-button": "Save",
@@ -213,8 +242,9 @@
"owner-iban-required": "Valid IBAN is mandatory",
"owner-iban-invalid": "Invalid IBAN format. Please enter a valid IBAN",
"currency-required": "Currency is mandatory",
"owner-revolut-profile-required": "Revolut profile name is mandatory",
"owner-revolut-profile-invalid": "Invalid Revolut profile format. Must start with '@' and contain only English letters and numbers (e.g., '@john123')",
"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

@@ -70,7 +70,8 @@
"payment-purpose-code-label": "Šifra namjene:",
"payment-description-label": "Opis plaćanja:",
"upload-proof-of-payment-legend": "Potvrda o uplati",
"upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:"
"upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:",
"revolut-link-text": "Plati pomoću Revoluta"
},
"month-card": {
"payed-total-label": "Ukupni mjesečni trošak:",
@@ -140,25 +141,35 @@
"location-name-legend": "Realestate name",
"location-name-placeholder": "unesite naziv nekretnine",
"notes-placeholder": "bilješke",
"tenant-2d-code-legend": "UPUTE ZA UPLATU",
"tenant-2d-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN, kao i 2D koji može skenirati.",
"tenant-2d-code-toggle-label": "podstanaru prikaži upute za uplatu",
"tenant-2d-code-note": "VAŽNO: za ovu funkcionalnost potrebno je otvoriti postavke aplikacije, te unijeti vaše ime i IBAN.",
"tenant-name-label": "Ime i prezime podstanara",
"tenant-name-placeholder": "unesite ime i prezime podstanara",
"tenant-street-label": "Ulica podstanara i kućni broj",
"tenant-street-placeholder": "unesite ulicu podstanara",
"tenant-town-label": "Poštanski broj i Grad podstanara",
"tenant-town-placeholder": "unesite poštanski broj i grad podstanara",
"tenant-payment-instructions-legend": "UPUTE ZA UPLATU",
"tenant-payment-instructions-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN ili Revolut.",
"tenant-payment-instructions-method--legend": "Podstanaru prikaži upute za uplatu:",
"tenant-payment-instructions-method--none": "ne prikazuj upute za uplatu",
"tenant-payment-instructions-method--iban": "uplata na IBAN",
"tenant-payment-instructions-method--iban-disabled": "uplata na IBAN - onemogućeno u app postavkama",
"tenant-payment-instructions-method--revolut": "uplata na Revolut",
"tenant-payment-instructions-method--revolut-disabled": "uplata na Revolut - onemogućeno u app postavkama",
"tenant-payment-instructions-method--disabled-message": "Ova opcija je nedostupna zato što nije omogućena u postavkama aplikacije.",
"iban-payment--form-title": "Informacije za uplatu na IBAN",
"iban-payment--tenant-name-label": "Ime i prezime podstanara",
"iban-payment--tenant-name-placeholder": "unesite ime i prezime podstanara",
"iban-payment--tenant-street-label": "Ulica podstanara i kućni broj",
"iban-payment--tenant-street-placeholder": "unesite ulicu podstanara",
"iban-payment--tenant-town-label": "Poštanski broj i Grad podstanara",
"iban-payment--tenant-town-placeholder": "unesite poštanski broj i grad 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-legend": "Automatska obavjest 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",
"auto-rent-notification-toggle-label": "pošalji obavjest o najamnini",
"rent-due-day-label": "Dan u mjesecu kada dospijeva najamnina",
"rent-amount-label": "Iznos najamnine",
"rent-amount-placeholder": "unesite iznos najamnine",
@@ -190,17 +201,32 @@
},
"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",
"owner-name-label": "Vaše ime i prezime",
"owner-name-placeholder": "unesite svoje ime i prezime",
"owner-street-label": "Ulica i kućni broj",
"owner-street-placeholder": "unesite ulicu i kućni broj",
"owner-town-label": "Poštanski broj i Grad",
"owner-town-placeholder": "unesite poštanski broj i grad",
"owner-iban-label": "IBAN",
"owner-iban-placeholder": "unesite svoj IBAN",
"iban-payment-instructions--legend": "Uplata na vaš IBAN",
"iban-payment-instructions--intro-message": "Aktiviranjem ove opcije, upute za uplatu bit će uključene u mjesečni izvještaj poslan podstanaru, omogućujući im da izvrše izravnu uplatu putem IBAN-a na vaš bankovni račun.",
"iban-payment-instructions--toggle-label": "uključi IBAN uplatu",
"iban-form-title": "Informacije za uplatu na IBAN",
"iban-owner-name-label": "Vaše ime i prezime",
"iban-owner-name-placeholder": "unesite svoje ime i prezime",
"iban-owner-street-label": "Ulica i kućni broj",
"iban-owner-street-placeholder": "unesite ulicu i kućni broj",
"iban-owner-town-label": "Poštanski broj i Grad",
"iban-owner-town-placeholder": "unesite poštanski broj i grad",
"iban-owner-iban-label": "IBAN",
"iban-owner-iban-placeholder": "IBAN putem kojeg ćete primate uplate",
"revolut-payment-instructions--legend": "Uplata na vaš Revolut profil",
"revolut-payment-instructions--intro-message": "Aktiviranjem ove opcije, upute za uplatu bit će uključene u mjesečni izvještaj poslan podstanaru, omogućujući im da izvrše izravnu uplatu putem Revolut-a na vaš profil.",
"revolut-payment-instructions--toggle-label": "uključi Revolut uplatu",
"revolut-form-title": "Informacije za uplatu na Revolut",
"revolut-profile-label": "Naziv vašeg Revolut profila",
"revolut-profile-placeholder": "profil putem kojeg ćete primati uplate",
"revolut-profile-tooltip": "Naziv vašeg Revolut profila možete pronaći u aplikaciji Revolut u korisničkom profilu. Prikazan je ispod vašeg imena i prezimena - počinje sa znakom '@' (npr: '@ivan123').",
"revolut-profile--test-link-label": "Testiraj svoju Revolut poveznicu:",
"revolut-profile--test-link-text": "Plati pomoću Revoluta",
"general-settings-legend": "Opće postavke",
"currency-label": "Valuta",
"save-button": "Spremi",
@@ -212,8 +238,10 @@
"owner-iban-required": "Ispravan IBAN je obavezan",
"owner-iban-invalid": "Neispravan IBAN format. Molimo unesite ispravan IBAN.",
"currency-required": "Valuta je obavezna",
"owner-revolut-profile-required": "Naziv Revolut profila je obavezan",
"owner-revolut-profile-invalid": "Neispravan format Revolut profila. Mora počinjati sa '@' i sadržavati samo slova i brojeve (npr. '@ivan123')",
"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."
"payment-additional-notes": "VAŽNO: da bi upute za uplatu bile prikazane podstanaru, morate tu ovu opciju uključiti i u postavkama pripadajuće nekretnine."
}
}

39
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-infinite-scroll-component": "^6.1.0",
"react-qr-code": "^2.0.18",
"react-toastify": "^10.0.6",
"tailwindcss": "^3.4.0",
"typescript": "5.2.2",
@@ -147,7 +148,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@@ -501,7 +501,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -545,7 +544,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1072,7 +1070,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -1473,7 +1470,6 @@
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.1.0.tgz",
"integrity": "sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==",
"dev": true,
"peer": true,
"dependencies": {
"glob": "10.3.10"
}
@@ -1804,7 +1800,6 @@
"version": "18.2.21",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz",
"integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -1926,7 +1921,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -2226,7 +2220,6 @@
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.20.0.tgz",
"integrity": "sha512-6Ev6rcqVjMakZFIDvbUf0dtpPGeZMTfyxYg4HkVWioWeN7cRcnUWT3bU6sdohc82O1nPXcjq6WiGfXX2Pnit6A==",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.2.1"
},
@@ -2253,7 +2246,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2673,7 +2665,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001587",
"electron-to-chromium": "^1.4.668",
@@ -3352,7 +3343,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3548,7 +3538,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz",
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
"dev": true,
"peer": true,
"dependencies": {
"array-includes": "^3.1.7",
"array.prototype.findlastindex": "^1.2.3",
@@ -5988,7 +5977,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-14.2.33.tgz",
"integrity": "sha512-GiKHLsD00t4ACm1p00VgrI0rUFAC9cRDGReKyERlM57aeEZkOQGcZTpIbsGn0b562FTPJWmYfKwplfO9EaT6ng==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "14.2.33",
"@swc/helpers": "0.5.5",
@@ -6506,7 +6494,6 @@
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz",
"integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==",
"peer": true,
"dependencies": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
@@ -6708,7 +6695,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
@@ -6894,7 +6880,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -6923,7 +6908,6 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true,
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -7042,6 +7026,11 @@
"node": ">=6"
}
},
"node_modules/qr.js": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
"integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -7065,7 +7054,6 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -7077,7 +7065,6 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
@@ -7102,6 +7089,18 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-qr-code": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz",
"integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==",
"dependencies": {
"prop-types": "^15.8.1",
"qr.js": "0.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-toastify": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz",
@@ -8002,7 +8001,6 @@
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -8308,7 +8306,6 @@
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@@ -36,6 +36,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-infinite-scroll-component": "^6.1.0",
"react-qr-code": "^2.0.18",
"react-toastify": "^10.0.6",
"tailwindcss": "^3.4.0",
"typescript": "5.2.2",