Merge branch 'feature/tenant-info-form' into develop
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"mcp__serena__replace_regex",
|
"mcp__serena__replace_regex",
|
||||||
"Bash(npm install:*)"
|
"Bash(npm install:*)",
|
||||||
|
"mcp__ide__getDiagnostics"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": true,
|
"enableAllProjectMcpServers": true,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { FC, Suspense } from 'react';
|
import { FC, Suspense } from 'react';
|
||||||
import { Main } from '@/app/ui/Main';
|
import { Main } from '@/app/ui/Main';
|
||||||
import { AccountForm, AccountFormSkeleton } from '@/app/ui/AccountForm';
|
import { UserSettingsForm as UserSettingsForm, UserSettingsFormSkeleton } from '@/app/ui/AppSettingsForm';
|
||||||
import { getUserProfile } from '@/app/lib/actions/userProfileActions';
|
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
|
||||||
|
|
||||||
const AccountPage: FC = async () => {
|
const AccountPage: FC = async () => {
|
||||||
const profile = await getUserProfile();
|
const userSettings = await getUserSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Main>
|
<Main>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<AccountForm profile={profile} />
|
<UserSettingsForm userSettings={userSettings} />
|
||||||
</div>
|
</div>
|
||||||
</Main>
|
</Main>
|
||||||
);
|
);
|
||||||
@@ -21,7 +21,7 @@ const Page: FC = () => {
|
|||||||
<Main>
|
<Main>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="h-8 w-48 skeleton mb-4"></div>
|
<div className="h-8 w-48 skeleton mb-4"></div>
|
||||||
<AccountFormSkeleton />
|
<UserSettingsFormSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</Main>
|
</Main>
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ViewLocationCard } from '@/app/ui/ViewLocationCard';
|
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 { notFound } from 'next/navigation';
|
||||||
|
import { myAuth } from '@/app/lib/auth';
|
||||||
|
|
||||||
export default async function LocationViewPage({ locationId }: { locationId:string }) {
|
export default async function LocationViewPage({ locationId }: { locationId:string }) {
|
||||||
const location = await fetchLocationById(locationId);
|
const location = await fetchLocationById(locationId);
|
||||||
@@ -9,5 +10,14 @@ export default async function LocationViewPage({ locationId }: { locationId:stri
|
|||||||
return(notFound());
|
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} />);
|
return (<ViewLocationCard location={location} />);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { BillingLocation, YearMonth } from '../db-types';
|
|||||||
import { ObjectId } from 'mongodb';
|
import { ObjectId } from 'mongodb';
|
||||||
import { withUser } from '@/app/lib/auth';
|
import { withUser } from '@/app/lib/auth';
|
||||||
import { AuthenticatedUser } from '../types/next-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 { unstable_noStore as noStore } from 'next/cache';
|
||||||
import { IntlTemplateFn } from '@/app/i18n';
|
import { IntlTemplateFn } from '@/app/i18n';
|
||||||
import { getTranslations, getLocale } from "next-intl/server";
|
import { getTranslations, getLocale } from "next-intl/server";
|
||||||
@@ -14,7 +14,15 @@ import { getTranslations, getLocale } from "next-intl/server";
|
|||||||
export type State = {
|
export type State = {
|
||||||
errors?: {
|
errors?: {
|
||||||
locationName?: string[];
|
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;
|
message?:string | null;
|
||||||
};
|
};
|
||||||
@@ -26,12 +34,57 @@ export type State = {
|
|||||||
const FormSchema = (t:IntlTemplateFn) => z.object({
|
const FormSchema = (t:IntlTemplateFn) => z.object({
|
||||||
_id: z.string(),
|
_id: z.string(),
|
||||||
locationName: z.coerce.string().min(1, t("location-name-required")),
|
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(),
|
addToSubsequentMonths: z.boolean().optional().nullable(),
|
||||||
updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(),
|
updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(),
|
||||||
})
|
})
|
||||||
// dont include the _id field in the response
|
// 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
|
* 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({
|
const validatedFields = FormSchema(t).safeParse({
|
||||||
locationName: formData.get('locationName'),
|
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',
|
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
|
||||||
updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined,
|
updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined,
|
||||||
});
|
});
|
||||||
@@ -57,13 +118,21 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
|||||||
if(!validatedFields.success) {
|
if(!validatedFields.success) {
|
||||||
return({
|
return({
|
||||||
errors: validatedFields.error.flatten().fieldErrors,
|
errors: validatedFields.error.flatten().fieldErrors,
|
||||||
message: "Missing Fields",
|
message: t("validation-failed"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
locationName,
|
locationName,
|
||||||
locationNotes,
|
generateTenantCode,
|
||||||
|
tenantFirstName,
|
||||||
|
tenantLastName,
|
||||||
|
autoBillFwd,
|
||||||
|
tenantEmail,
|
||||||
|
billFwdStrategy,
|
||||||
|
rentDueNotification,
|
||||||
|
rentDueDay,
|
||||||
|
rentAmount,
|
||||||
addToSubsequentMonths,
|
addToSubsequentMonths,
|
||||||
updateScope,
|
updateScope,
|
||||||
} = validatedFields.data;
|
} = validatedFields.data;
|
||||||
@@ -96,7 +165,15 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
|||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
name: locationName,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -117,7 +194,15 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
|||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
name: locationName,
|
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: {
|
$set: {
|
||||||
name: locationName,
|
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,
|
userId,
|
||||||
userEmail,
|
userEmail,
|
||||||
name: locationName,
|
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,
|
yearMonth: yearMonth,
|
||||||
bills: [],
|
bills: [],
|
||||||
});
|
});
|
||||||
@@ -207,7 +309,16 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
|||||||
userId,
|
userId,
|
||||||
userEmail,
|
userEmail,
|
||||||
name: locationName,
|
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 },
|
yearMonth: { year: monthData.year, month: monthData.month },
|
||||||
bills: [],
|
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();
|
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
|
// 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.year": 1,
|
||||||
"yearMonth.month": 1,
|
"yearMonth.month": 1,
|
||||||
"bills": 1,
|
"bills": 1,
|
||||||
|
"seenByTenant": 1,
|
||||||
// "bills.attachment": 0,
|
// "bills.attachment": 0,
|
||||||
// "bills.notes": 0,
|
// "bills.notes": 0,
|
||||||
// "bills.barcodeImage": 1,
|
// "bills.barcodeImage": 1,
|
||||||
@@ -418,3 +531,35 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati
|
|||||||
errors: undefined,
|
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 } }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ export async function gotoHome({year, month}: YearMonth) {
|
|||||||
await gotoUrl(path);
|
await gotoUrl(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function gotoHomeWithMessage(locale: string, message: string) {
|
export async function gotoHomeWithMessage(locale: string, message: string, yearMonth?: YearMonth) {
|
||||||
const path = `/${locale}?${message}=true`;
|
const path = yearMonth
|
||||||
|
? `/${locale}?year=${yearMonth.year}&month=${yearMonth.month}&${message}=true`
|
||||||
|
: `/${locale}?${message}=true`;
|
||||||
await gotoUrl(path);
|
await gotoUrl(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getDbClient } from '../dbClient';
|
import { getDbClient } from '../dbClient';
|
||||||
import { UserProfile } from '../db-types';
|
import { UserSettings } from '../db-types';
|
||||||
import { withUser } from '@/app/lib/auth';
|
import { withUser } from '@/app/lib/auth';
|
||||||
import { AuthenticatedUser } from '../types/next-auth';
|
import { AuthenticatedUser } from '../types/next-auth';
|
||||||
import { unstable_noStore as noStore } from 'next/cache';
|
import { unstable_noStore as noStore } from 'next/cache';
|
||||||
@@ -18,13 +18,14 @@ export type State = {
|
|||||||
lastName?: string[];
|
lastName?: string[];
|
||||||
address?: string[];
|
address?: string[];
|
||||||
iban?: string[];
|
iban?: string[];
|
||||||
|
show2dCodeInMonthlyStatement?: string[];
|
||||||
};
|
};
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for validating user profile form fields
|
* Schema for validating user settings form fields
|
||||||
*/
|
*/
|
||||||
const FormSchema = (t: IntlTemplateFn) => z.object({
|
const FormSchema = (t: IntlTemplateFn) => z.object({
|
||||||
firstName: z.string().optional(),
|
firstName: z.string().optional(),
|
||||||
@@ -41,36 +42,79 @@ const FormSchema = (t: IntlTemplateFn) => z.object({
|
|||||||
},
|
},
|
||||||
{ message: t("iban-invalid") }
|
{ 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();
|
noStore();
|
||||||
|
|
||||||
const dbClient = await getDbClient();
|
const dbClient = await getDbClient();
|
||||||
const { id: userId } = user;
|
const { id: userId } = user;
|
||||||
|
|
||||||
const profile = await dbClient.collection<UserProfile>("users")
|
const userSettings = await dbClient.collection<UserSettings>("userSettings")
|
||||||
.findOne({ userId });
|
.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();
|
noStore();
|
||||||
|
|
||||||
const t = await getTranslations("account-form.validation");
|
const t = await getTranslations("user-settings-form.validation");
|
||||||
|
|
||||||
const validatedFields = FormSchema(t).safeParse({
|
const validatedFields = FormSchema(t).safeParse({
|
||||||
firstName: formData.get('firstName') || undefined,
|
firstName: formData.get('firstName') || undefined,
|
||||||
lastName: formData.get('lastName') || undefined,
|
lastName: formData.get('lastName') || undefined,
|
||||||
address: formData.get('address') || undefined,
|
address: formData.get('address') || undefined,
|
||||||
iban: formData.get('iban') || undefined,
|
iban: formData.get('iban') || undefined,
|
||||||
|
show2dCodeInMonthlyStatement: formData.get('generateTenantCode') === 'on',
|
||||||
});
|
});
|
||||||
|
|
||||||
// If form validation fails, return errors early. Otherwise, continue...
|
// 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
|
// Normalize IBAN: remove spaces and convert to uppercase
|
||||||
const normalizedIban = iban ? iban.replace(/\s/g, '').toUpperCase() : null;
|
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 dbClient = await getDbClient();
|
||||||
const { id: userId } = user;
|
const { id: userId } = user;
|
||||||
|
|
||||||
const userProfile: UserProfile = {
|
const userSettings: UserSettings = {
|
||||||
userId,
|
userId,
|
||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
address: address || null,
|
address: address || null,
|
||||||
iban: normalizedIban,
|
iban: normalizedIban,
|
||||||
|
show2dCodeInMonthlyStatement: show2dCodeInMonthlyStatement ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
await dbClient.collection<UserProfile>("users")
|
await dbClient.collection<UserSettings>("userSettings")
|
||||||
.updateOne(
|
.updateOne(
|
||||||
{ userId },
|
{ userId },
|
||||||
{ $set: userProfile },
|
{ $set: userSettings },
|
||||||
{ upsert: true }
|
{ upsert: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidatePath('/account');
|
revalidatePath('/settings');
|
||||||
|
|
||||||
// Get current locale and redirect to home with success message
|
// Get current locale and redirect to home with success message
|
||||||
const locale = await getLocale();
|
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
|
// This return is needed for TypeScript, but won't be reached due to redirect
|
||||||
return {
|
return {
|
||||||
@@ -14,8 +14,8 @@ export interface YearMonth {
|
|||||||
month: number;
|
month: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** User profile data */
|
/** User settings data */
|
||||||
export interface UserProfile {
|
export interface UserSettings {
|
||||||
/** user's ID */
|
/** user's ID */
|
||||||
userId: string;
|
userId: string;
|
||||||
/** first name */
|
/** first name */
|
||||||
@@ -26,6 +26,8 @@ export interface UserProfile {
|
|||||||
address?: string | null;
|
address?: string | null;
|
||||||
/** IBAN */
|
/** IBAN */
|
||||||
iban?: string | null;
|
iban?: string | null;
|
||||||
|
/** whether to show 2D code in monthly statement */
|
||||||
|
show2dCodeInMonthlyStatement?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** bill object in the form returned by MongoDB */
|
/** bill object in the form returned by MongoDB */
|
||||||
@@ -43,6 +45,26 @@ export interface BillingLocation {
|
|||||||
bills: Bill[];
|
bills: Bill[];
|
||||||
/** (optional) notes */
|
/** (optional) notes */
|
||||||
notes: string|null;
|
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 {
|
export enum BilledTo {
|
||||||
|
|||||||
@@ -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
230
app/ui/AppSettingsForm.tsx
Normal 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
9
app/ui/InfoBox.tsx
Normal 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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { FC } from "react";
|
||||||
import { BillBadge } from "./BillBadge";
|
import { BillBadge } from "./BillBadge";
|
||||||
import { BillingLocation } from "../lib/db-types";
|
import { BillingLocation } from "../lib/db-types";
|
||||||
@@ -14,7 +14,10 @@ export interface LocationCardProps {
|
|||||||
location: BillingLocation
|
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 t = useTranslations("home-page.location-card");
|
||||||
const currentLocale = useLocale();
|
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>
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{
|
|
||||||
monthlyExpense > 0 ?
|
|
||||||
<p>
|
{ monthlyExpense > 0 || seenByTenant ?
|
||||||
{ t("payed-total-label") } <strong>${formatCurrency(monthlyExpense)}</strong>
|
|
||||||
</p>
|
<fieldset className="card card-compact card-bordered border-1 border-neutral p-3 mt-2 mr-20">
|
||||||
: null
|
<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} />
|
<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} />
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||||
import { FC } from "react";
|
import { FC, useState } from "react";
|
||||||
import { BillingLocation, YearMonth } from "../lib/db-types";
|
import { BillingLocation, YearMonth } from "../lib/db-types";
|
||||||
import { updateOrAddLocation } from "../lib/actions/locationActions";
|
import { updateOrAddLocation } from "../lib/actions/locationActions";
|
||||||
import { useFormState } from "react-dom";
|
import { useFormState } from "react-dom";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { InfoBox } from "./InfoBox";
|
||||||
|
|
||||||
export type LocationEditFormProps = {
|
export type LocationEditFormProps = {
|
||||||
/** location which should be edited */
|
/** location which should be edited */
|
||||||
@@ -20,82 +21,271 @@ export type LocationEditFormProps = {
|
|||||||
yearMonth: YearMonth
|
yearMonth: YearMonth
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth }) =>
|
export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMonth }) => {
|
||||||
{
|
|
||||||
const initialState = { message: null, errors: {} };
|
const initialState = { message: null, errors: {} };
|
||||||
const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth);
|
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 t = useTranslations("location-edit-form");
|
||||||
const locale = useLocale();
|
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;
|
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 card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form action={dispatch}>
|
<form action={dispatch}>
|
||||||
{
|
{
|
||||||
location &&
|
location &&
|
||||||
<Link href={`/${locale}/location/${location._id}/delete`} className="absolute bottom-5 right-4 tooltip" data-tip={t("delete-tooltip")}>
|
<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" />
|
<TrashIcon className="h-[1em] w-[1em] text-error text-2xl" />
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
<input id="locationName" name="locationName" type="text" placeholder={t("location-name-placeholder")} className="input input-bordered w-full" defaultValue={location?.name ?? ""} />
|
<fieldset className="fieldset mt-2 p-2">
|
||||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
<legend className="fieldset-legend font-semibold uppercase">{t("location-name-legend")}</legend>
|
||||||
{state.errors?.locationName &&
|
<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 ?? ""} />
|
||||||
state.errors.locationName.map((error: string) => (
|
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||||
<p className="mt-2 text-sm text-red-500" key={error}>
|
{state.errors?.locationName &&
|
||||||
{error}
|
state.errors.locationName.map((error: string) => (
|
||||||
</p>
|
<p className="mt-2 text-sm text-red-500" key={error}>
|
||||||
))}
|
{error}
|
||||||
</div>
|
</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>
|
<InfoBox className="p-1 mb-1">{t("tenant-2d-code-info")}</InfoBox>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Show different options for add vs edit operations */}
|
<fieldset className="fieldset">
|
||||||
{!location ? (
|
<label className="label cursor-pointer justify-start gap-3">
|
||||||
<div className="form-control">
|
<input
|
||||||
<label className="label cursor-pointer">
|
type="checkbox"
|
||||||
<span className="label-text">{t("add-to-subsequent-months")}</span>
|
name="generateTenantCode"
|
||||||
<input type="checkbox" name="addToSubsequentMonths" className="toggle toggle-primary" />
|
className="toggle toggle-primary"
|
||||||
|
checked={generateTenantCode}
|
||||||
|
onChange={(e) => setGenerateTenantCode(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<legend className="fieldset-legend">{t("tenant-2d-code-toggle-label")}</legend>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</fieldset>
|
||||||
) : (
|
|
||||||
<div className="form-control">
|
{generateTenantCode && (
|
||||||
<div className="label">
|
<>
|
||||||
<span className="label-text font-medium">{t("update-scope")}</span>
|
<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>
|
||||||
<div className="flex flex-col gap-1 ml-4">
|
</fieldset>
|
||||||
<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 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">
|
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||||
{
|
{
|
||||||
state.message &&
|
state.message &&
|
||||||
<p className="mt-2 text-sm text-red-500">
|
<p className="mt-2 text-sm text-red-500">
|
||||||
{state.message}
|
{state.message}
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
@@ -108,13 +298,17 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LocationEditFormSkeleton:FC = () =>
|
export const LocationEditFormSkeleton: FC = () => {
|
||||||
{
|
const t = useTranslations("location-edit-form");
|
||||||
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 card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
|
||||||
|
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div id="locationName" className="input w-full skeleton"></div>
|
<fieldset className="fieldset mt-2 p-2">
|
||||||
<div id="locationNotes" className="textarea my-1 w-full block h-[8em] skeleton"></div>
|
<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="pt-4">
|
||||||
<div className="btn w-[5.5em] skeleton"></div>
|
<div className="btn w-[5.5em] skeleton"></div>
|
||||||
<div className="btn w-[5.5em] ml-3 skeleton"></div>
|
<div className="btn w-[5.5em] ml-3 skeleton"></div>
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export const MonthLocationList:React.FC<MonthLocationListProps > = ({
|
|||||||
const params = new URLSearchParams(search.toString());
|
const params = new URLSearchParams(search.toString());
|
||||||
let messageShown = false;
|
let messageShown = false;
|
||||||
|
|
||||||
if (search.get('profileSaved') === 'true') {
|
if (search.get('userSettingsSaved') === 'true') {
|
||||||
toast.success(t("profile-saved-message"), { theme: "dark" });
|
toast.success(t("user-settings-saved-message"), { theme: "dark" });
|
||||||
params.delete('profileSaved');
|
params.delete('userSettingsSaved');
|
||||||
messageShown = true;
|
messageShown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SelectLanguage } from "./SelectLanguage";
|
import { SelectLanguage } from "./SelectLanguage";
|
||||||
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
import Settings from "@mui/icons-material/Settings";
|
||||||
|
|
||||||
export const PageHeader = () =>
|
export const PageHeader = () =>
|
||||||
<div className="navbar bg-base-100 mb-6">
|
<div className="navbar bg-base-100 mb-6">
|
||||||
@@ -9,6 +9,6 @@ export const PageHeader = () =>
|
|||||||
<span className="grow"> </span>
|
<span className="grow"> </span>
|
||||||
<SelectLanguage />
|
<SelectLanguage />
|
||||||
<Link href="/account/" className="btn btn-ghost btn-circle">
|
<Link href="/account/" className="btn btn-ghost btn-circle">
|
||||||
<AccountCircleIcon className="w-6 h-6" />
|
<Settings className="w-6 h-6" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
230
app/ui/UserSettingsForm.tsx
Normal file
230
app/ui/UserSettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -55,7 +55,9 @@
|
|||||||
"edit-card-tooltip": "Edit realestate",
|
"edit-card-tooltip": "Edit realestate",
|
||||||
"add-bill-button-tooltip": "Add a new bill",
|
"add-bill-button-tooltip": "Add a new bill",
|
||||||
"payed-total-label": "Payed total:",
|
"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": {
|
"month-card": {
|
||||||
"payed-total-label": "Total monthly expenditure:",
|
"payed-total-label": "Total monthly expenditure:",
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
"empty-state-title": "No Barcode Data Found",
|
"empty-state-title": "No Barcode Data Found",
|
||||||
"empty-state-message": "No bills with 2D barcodes found for {yearMonth}"
|
"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-saved-message": "Bill saved successfully",
|
||||||
"bill-deleted-message": "Bill deleted successfully",
|
"bill-deleted-message": "Bill deleted successfully",
|
||||||
"location-saved-message": "Location saved 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!"
|
"warning-message": "This operation cannot be undone and will delete the location in all future months!"
|
||||||
},
|
},
|
||||||
"location-edit-form": {
|
"location-edit-form": {
|
||||||
"location-name-placeholder": "Realestate name",
|
"location-name-legend": "Realestate name",
|
||||||
|
"location-name-placeholder": "enter realestate name",
|
||||||
"notes-placeholder": "Notes",
|
"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",
|
"save-button": "Save",
|
||||||
"cancel-button": "Cancel",
|
"cancel-button": "Cancel",
|
||||||
"delete-tooltip": "Delete realestate",
|
"delete-tooltip": "Delete realestate",
|
||||||
"add-to-subsequent-months": "Add to all subsequent months",
|
"scope-legend": "Scope of changes",
|
||||||
"update-scope": "Update scope:",
|
"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-current-month": "current month only",
|
||||||
"update-subsequent-months": "current and all future months",
|
"update-subsequent-months": "current and all future months",
|
||||||
"update-all-months": "all months",
|
"update-all-months": "all months",
|
||||||
"validation": {
|
"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": {
|
"user-settings-form": {
|
||||||
"title": "Profile Information",
|
"title": "User settings",
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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-label": "First Name",
|
||||||
"first-name-placeholder": "Enter your first name",
|
"first-name-placeholder": "Enter your first name",
|
||||||
"last-name-label": "Last Name",
|
"last-name-label": "Last Name",
|
||||||
@@ -151,8 +187,13 @@
|
|||||||
"save-button": "Save",
|
"save-button": "Save",
|
||||||
"cancel-button": "Cancel",
|
"cancel-button": "Cancel",
|
||||||
"validation": {
|
"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",
|
"iban-invalid": "Invalid IBAN format. Please enter a valid IBAN",
|
||||||
"validation-failed": "Validation failed. Please check the form and try again."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,9 @@
|
|||||||
"edit-card-tooltip": "Izmjeni nekretninu",
|
"edit-card-tooltip": "Izmjeni nekretninu",
|
||||||
"add-bill-button-tooltip": "Dodaj novi račun",
|
"add-bill-button-tooltip": "Dodaj novi račun",
|
||||||
"payed-total-label": "Ukupno plaćeno:",
|
"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": {
|
"month-card": {
|
||||||
"payed-total-label": "Ukupni mjesečni trošak:",
|
"payed-total-label": "Ukupni mjesečni trošak:",
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
"empty-state-title": "Nema Podataka o Barkodovima",
|
"empty-state-title": "Nema Podataka o Barkodovima",
|
||||||
"empty-state-message": "Nema računa s 2D barkodovima za {yearMonth}"
|
"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-saved-message": "Račun uspješno spremljen",
|
||||||
"bill-deleted-message": "Račun uspješno obrisan",
|
"bill-deleted-message": "Račun uspješno obrisan",
|
||||||
"location-saved-message": "Nekretnina uspješno spremljena",
|
"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!"
|
"warning-message": "Ova operacija je nepovratna i obrisat će lokaciju u svim mjesecima koji slijede!"
|
||||||
},
|
},
|
||||||
"location-edit-form": {
|
"location-edit-form": {
|
||||||
"location-name-placeholder": "Ime nekretnine",
|
"location-name-legend": "Realestate name",
|
||||||
|
"location-name-placeholder": "unesite naziv nekretnine",
|
||||||
"notes-placeholder": "Bilješke",
|
"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",
|
"save-button": "Spremi",
|
||||||
"cancel-button": "Odbaci",
|
"cancel-button": "Odbaci",
|
||||||
"delete-tooltip": "Brisanje nekretnine",
|
"delete-tooltip": "Brisanje nekretnine",
|
||||||
"add-to-subsequent-months": "Dodaj u sve mjesece koji slijede",
|
"scope-legend": "Opseg promjena",
|
||||||
"update-scope": "Opseg ažuriranja:",
|
"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-current-month": "samo trenutni mjesec",
|
||||||
"update-subsequent-months": "trenutni i svi budući mjeseci",
|
"update-subsequent-months": "trenutni i sve buduće mjesece",
|
||||||
"update-all-months": "svi mjeseci",
|
"update-all-months": "sve mjesece",
|
||||||
"validation": {
|
"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": {
|
"user-settings-form": {
|
||||||
"title": "Podaci o profilu",
|
"title": "Korisničke postavke",
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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-label": "Ime",
|
||||||
"first-name-placeholder": "Unesite svoje ime",
|
"first-name-placeholder": "Unesite svoje ime",
|
||||||
"last-name-label": "Prezime",
|
"last-name-label": "Prezime",
|
||||||
@@ -150,8 +186,13 @@
|
|||||||
"save-button": "Spremi",
|
"save-button": "Spremi",
|
||||||
"cancel-button": "Odbaci",
|
"cancel-button": "Odbaci",
|
||||||
"validation": {
|
"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.",
|
"iban-invalid": "Neispravan IBAN format. Molimo unesite ispravan IBAN.",
|
||||||
"validation-failed": "Validacija nije uspjela. Molimo provjerite formu i pokušajte ponovno."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user