diff --git a/app/[locale]/location/[id]/add/LocationAddPage.tsx b/app/[locale]/location/[id]/add/LocationAddPage.tsx index 9556980..93c65ec 100644 --- a/app/[locale]/location/[id]/add/LocationAddPage.tsx +++ b/app/[locale]/location/[id]/add/LocationAddPage.tsx @@ -1,6 +1,8 @@ import { LocationEditForm } from '@/app/ui/LocationEditForm'; import { YearMonth } from '@/app/lib/db-types'; +import { getUserSettings } from '@/app/lib/actions/userSettingsActions'; export default async function LocationAddPage({ yearMonth }: { yearMonth:YearMonth }) { - return (); + const userSettings = await getUserSettings(); + return (); } \ No newline at end of file diff --git a/app/[locale]/location/[id]/edit/LocationEditPage.tsx b/app/[locale]/location/[id]/edit/LocationEditPage.tsx index 505f634..b99203e 100644 --- a/app/[locale]/location/[id]/edit/LocationEditPage.tsx +++ b/app/[locale]/location/[id]/edit/LocationEditPage.tsx @@ -1,6 +1,7 @@ import { notFound } from 'next/navigation'; import { LocationEditForm } from '@/app/ui/LocationEditForm'; import { fetchLocationById } from '@/app/lib/actions/locationActions'; +import { getUserSettings } from '@/app/lib/actions/userSettingsActions'; export default async function LocationEditPage({ locationId }: { locationId:string }) { @@ -10,7 +11,9 @@ export default async function LocationEditPage({ locationId }: { locationId:stri return(notFound()); } - const result = ; - + const userSettings = await getUserSettings(); + + const result = ; + return (result); } \ No newline at end of file diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 369f178..cbe0e17 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -14,7 +14,6 @@ import { getTranslations, getLocale } from "next-intl/server"; export type State = { errors?: { locationName?: string[]; - generateTenantCode?: string[]; tenantName?: string[]; tenantStreet?: string[]; tenantTown?: string[]; @@ -35,7 +34,7 @@ export type State = { const FormSchema = (t:IntlTemplateFn) => z.object({ _id: z.string(), locationName: z.coerce.string().min(1, t("location-name-required")), - generateTenantCode: z.boolean().optional().nullable(), + tenantPaymentMethod: z.enum(["none", "iban", "revolut"]).optional().nullable(), tenantName: z.string().max(30).optional().nullable(), tenantStreet: z.string().max(27).optional().nullable(), tenantTown: z.string().max(27).optional().nullable(), @@ -50,9 +49,9 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ }) // dont include the _id field in the response .omit({ _id: true }) - // Add conditional validation: if generateTenantCode is true, tenant fields are required + // Add conditional validation: if `tenantPaymentMethod` is "iban", tenant fields are required .refine((data) => { - if (data.generateTenantCode) { + if (data.tenantPaymentMethod === "iban") { return !!data.tenantName && data.tenantName.trim().length > 0; } return true; @@ -61,7 +60,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ path: ["tenantName"], }) .refine((data) => { - if (data.generateTenantCode) { + if (data.tenantPaymentMethod === "iban") { return !!data.tenantStreet && data.tenantStreet.trim().length > 0; } return true; @@ -70,7 +69,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ path: ["tenantStreet"], }) .refine((data) => { - if (data.generateTenantCode) { + if (data.tenantPaymentMethod === "iban") { return !!data.tenantTown && data.tenantTown.trim().length > 0; } return true; @@ -112,7 +111,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat const validatedFields = FormSchema(t).safeParse({ locationName: formData.get('locationName'), - generateTenantCode: formData.get('generateTenantCode') === 'on', + tenantPaymentMethod: formData.get('tenantPaymentMethod') as "none" | "iban" | "revolut" | undefined, tenantName: formData.get('tenantName') || null, tenantStreet: formData.get('tenantStreet') || null, tenantTown: formData.get('tenantTown') || null, @@ -136,7 +135,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat const { locationName, - generateTenantCode, + tenantPaymentMethod, tenantName, tenantStreet, tenantTown, @@ -178,7 +177,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat { $set: { name: locationName, - generateTenantCode: generateTenantCode || false, + tenantPaymentMethod: tenantPaymentMethod || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -208,7 +207,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat { $set: { name: locationName, - generateTenantCode: generateTenantCode || false, + tenantPaymentMethod: tenantPaymentMethod || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -231,7 +230,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat { $set: { name: locationName, - generateTenantCode: generateTenantCode || false, + tenantPaymentMethod: tenantPaymentMethod || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -253,7 +252,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat userEmail, name: locationName, notes: null, - generateTenantCode: generateTenantCode || false, + tenantPaymentMethod: tenantPaymentMethod || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, @@ -327,7 +326,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat userEmail, name: locationName, notes: null, - generateTenantCode: generateTenantCode || false, + tenantPaymentMethod: tenantPaymentMethod || "none", tenantName: tenantName || null, tenantStreet: tenantStreet || null, tenantTown: tenantTown || null, diff --git a/app/lib/actions/userSettingsActions.ts b/app/lib/actions/userSettingsActions.ts index b767e79..96560b8 100644 --- a/app/lib/actions/userSettingsActions.ts +++ b/app/lib/actions/userSettingsActions.ts @@ -19,7 +19,7 @@ export type State = { ownerTown?: string[]; ownerIBAN?: string[]; currency?: string[]; - show2dCodeInMonthlyStatement?: string[]; + ownerRevolutProfileName?: string[]; }; message?: string | null; success?: boolean; @@ -29,6 +29,8 @@ export type State = { * Schema for validating user settings form fields */ const FormSchema = (t: IntlTemplateFn) => z.object({ + currency: z.string().optional(), + enableIbanPayment: z.boolean().optional(), ownerName: z.string().max(25).optional(), ownerStreet: z.string().max(25).optional(), ownerTown: z.string().max(27).optional(), @@ -43,11 +45,11 @@ const FormSchema = (t: IntlTemplateFn) => z.object({ }, { message: t("owner-iban-invalid") } ), - currency: z.string().optional(), - show2dCodeInMonthlyStatement: z.boolean().optional().nullable(), + enableRevolutPayment: z.boolean().optional(), + ownerRevolutProfileName: z.string().max(25).optional(), }) .refine((data) => { - if (data.show2dCodeInMonthlyStatement) { + if (data.enableIbanPayment) { return !!data.ownerName && data.ownerName.trim().length > 0; } return true; @@ -56,7 +58,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({ path: ["ownerName"], }) .refine((data) => { - if (data.show2dCodeInMonthlyStatement) { + if (data.enableIbanPayment) { return !!data.ownerStreet && data.ownerStreet.trim().length > 0; } return true; @@ -65,7 +67,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({ path: ["ownerStreet"], }) .refine((data) => { - if (data.show2dCodeInMonthlyStatement) { + if (data.enableIbanPayment) { return !!data.ownerTown && data.ownerTown.trim().length > 0; } return true; @@ -74,7 +76,7 @@ const FormSchema = (t: IntlTemplateFn) => z.object({ path: ["ownerTown"], }) .refine((data) => { - if (data.show2dCodeInMonthlyStatement) { + if (data.enableIbanPayment) { if (!data.ownerIBAN || data.ownerIBAN.trim().length === 0) { return false; } @@ -88,13 +90,33 @@ const FormSchema = (t: IntlTemplateFn) => z.object({ path: ["ownerIBAN"], }) .refine((data) => { - if (data.show2dCodeInMonthlyStatement) { + if (data.enableIbanPayment) { return !!data.currency && data.currency.trim().length > 0; } return true; }, { message: t("currency-required"), path: ["currency"], +}) +.refine((data) => { + if (data.enableRevolutPayment) { + return !!data.ownerRevolutProfileName && data.ownerRevolutProfileName.trim().length > 0; + } + return true; +}, { + message: t("owner-revolut-profile-required"), + path: ["ownerRevolutProfileName"], +}) +.refine((data) => { + if (data.enableRevolutPayment && data.ownerRevolutProfileName) { + const profileName = data.ownerRevolutProfileName.trim(); + // Must start with @ and contain only English letters and numbers + return /^@[a-zA-Z0-9]+$/.test(profileName); + } + return true; +}, { + message: t("owner-revolut-profile-invalid"), + path: ["ownerRevolutProfileName"], }); /** @@ -141,7 +163,9 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS ownerTown: formData.get('ownerTown') || undefined, ownerIBAN: formData.get('ownerIBAN') || undefined, currency: formData.get('currency') || undefined, - show2dCodeInMonthlyStatement: formData.get('generateTenantCode') === 'on', + enableIbanPayment: formData.get('enableIbanPayment') === 'on' ? true : false, + enableRevolutPayment: formData.get('enableRevolutPayment') === 'on' ? true : false, + ownerRevolutProfileName: formData.get('ownerRevolutProfileName') || undefined, }); // If form validation fails, return errors early. Otherwise, continue... @@ -153,7 +177,7 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS }; } - const { ownerName, ownerStreet, ownerTown, ownerIBAN, currency, show2dCodeInMonthlyStatement } = validatedFields.data; + const { enableIbanPayment, ownerName, ownerStreet, ownerTown, ownerIBAN, currency, enableRevolutPayment, ownerRevolutProfileName } = validatedFields.data; // Normalize IBAN: remove spaces and convert to uppercase const normalizedOwnerIBAN = ownerIBAN ? ownerIBAN.replace(/\s/g, '').toUpperCase() : null; @@ -164,12 +188,14 @@ export const updateUserSettings = withUser(async (user: AuthenticatedUser, prevS const userSettings: UserSettings = { userId, - ownerName: ownerName || null, - ownerStreet: ownerStreet || null, - ownerTown: ownerTown || null, + enableIbanPayment: enableIbanPayment ?? false, + ownerName: ownerName ?? undefined, + ownerStreet: ownerStreet ?? undefined, + ownerTown: ownerTown ?? undefined, ownerIBAN: normalizedOwnerIBAN, - currency: currency || null, - show2dCodeInMonthlyStatement: show2dCodeInMonthlyStatement ?? false, + currency: currency ?? undefined, + enableRevolutPayment: enableRevolutPayment ?? false, + ownerRevolutProfileName: ownerRevolutProfileName ?? undefined, }; await dbClient.collection("userSettings") diff --git a/app/lib/db-types.ts b/app/lib/db-types.ts index db92e21..330c832 100644 --- a/app/lib/db-types.ts +++ b/app/lib/db-types.ts @@ -18,6 +18,8 @@ export interface YearMonth { export interface UserSettings { /** user's ID */ userId: string; + /** whether enableshow IBAN payment instructions in monthly statement */ + enableIbanPayment?: boolean | null; /** owner name */ ownerName?: string | null; /** owner street */ @@ -28,8 +30,10 @@ export interface UserSettings { ownerIBAN?: string | null; /** currency (ISO 4217) */ currency?: string | null; - /** whether to show 2D code in monthly statement */ - show2dCodeInMonthlyStatement?: boolean | null; + /** whether to enable Revolut payment instructions in monthly statement */ + enableRevolutPayment?: boolean | null; + /** owner Revolut payment link */ + ownerRevolutProfileName?: string | null; }; /** bill object in the form returned by MongoDB */ @@ -47,8 +51,10 @@ export interface BillingLocation { bills: Bill[]; /** (optional) notes */ notes: string|null; - /** (optional) whether to generate 2D code for tenant */ - generateTenantCode?: boolean | null; + + /** (optional) method for showing payment instructions to tenant */ + tenantPaymentMethod?: "none" | "iban" | "revolut" | null; + /** (optional) tenant name */ tenantName?: string | null; /** (optional) tenant street */ diff --git a/app/ui/LocationEditForm.tsx b/app/ui/LocationEditForm.tsx index 9d21193..185bc37 100644 --- a/app/ui/LocationEditForm.tsx +++ b/app/ui/LocationEditForm.tsx @@ -2,7 +2,7 @@ import { TrashIcon } from "@heroicons/react/24/outline"; import { FC, useState } from "react"; -import { BillingLocation, YearMonth } from "../lib/db-types"; +import { BillingLocation, UserSettings, YearMonth } from "../lib/db-types"; import { updateOrAddLocation } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; import Link from "next/link"; @@ -13,46 +13,44 @@ export type LocationEditFormProps = { /** location which should be edited */ location: BillingLocation, /** year adn month at a new billing location should be assigned */ - yearMonth?: undefined + yearMonth?: undefined, + /** user settings for payment configuration */ + userSettings: UserSettings | null } | { /** location which should be edited */ location?: undefined, /** year adn month at a new billing location should be assigned */ - yearMonth: YearMonth + yearMonth: YearMonth, + /** user settings for payment configuration */ + userSettings: UserSettings | null } -export const LocationEditForm: FC = ({ location, yearMonth }) => { +export const LocationEditForm: FC = ({ location, yearMonth, userSettings }) => { const initialState = { message: null, errors: {} }; + const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth); + const [state, dispatch] = useFormState(handleAction, initialState); const t = useTranslations("location-edit-form"); const locale = useLocale(); - // Track whether to generate 2D code for tenant (use persisted value from database) - const [generateTenantCode, setGenerateTenantCode] = useState( - location?.generateTenantCode ?? false - ); - - // Track whether to automatically notify tenant (use persisted value from database) - const [autoBillFwd, setautoBillFwd] = useState( - location?.autoBillFwd ?? false - ); - - // Track whether to automatically send rent notification (use persisted value from database) - const [rentDueNotification, setrentDueNotification] = useState( - location?.rentDueNotification ?? false - ); - // Track tenant field values for real-time validation - const [tenantFields, setTenantFields] = useState({ + const [formValues, setFormValues] = useState({ + locationName: location?.name ?? "", tenantName: location?.tenantName ?? "", tenantStreet: location?.tenantStreet ?? "", tenantTown: location?.tenantTown ?? "", tenantEmail: location?.tenantEmail ?? "", + tenantPaymentMethod: location?.tenantPaymentMethod ?? "none", + autoBillFwd: location?.autoBillFwd ?? false, + billFwdStrategy: location?.billFwdStrategy ?? "when-payed", + rentDueNotification: location?.rentDueNotification ?? false, + rentAmount: location?.rentAmount ?? "", + rentDueDay: location?.rentDueDay ?? 1, }); - const handleTenantFieldChange = (field: keyof typeof tenantFields, value: string) => { - setTenantFields(prev => ({ ...prev, [field]: value })); + const handleInputChange = (field: keyof typeof formValues, value: string | boolean | number) => { + setFormValues(prev => ({ ...prev, [field]: value })); }; let { year, month } = location ? location.yearMonth : yearMonth; @@ -69,7 +67,14 @@ export const LocationEditForm: FC = ({ location, yearMont }
{t("location-name-legend")} - + handleInputChange("locationName", e.target.value)} + />
{state.errors?.locationName && state.errors.locationName.map((error: string) => ( @@ -79,38 +84,54 @@ export const LocationEditForm: FC = ({ location, yearMont ))}
-
- {t("tenant-2d-code-legend")} - {t("tenant-2d-code-info")} -
- +
+ {t("tenant-payment-instructions-legend")} + + {t("tenant-payment-instructions-code-info")} + +
+ {t("tenant-payment-instructions-method--legend")} +
- {generateTenantCode && ( + { formValues.tenantPaymentMethod === "iban" && userSettings?.enableIbanPayment ? ( <> +
{t("iban-payment--form-title")}
handleTenantFieldChange("tenantName", e.target.value)} + defaultValue={formValues.tenantName} + onChange={(e) => handleInputChange("tenantName", e.target.value)} />
{state.errors?.tenantName && @@ -124,17 +145,17 @@ export const LocationEditForm: FC = ({ location, yearMont
handleTenantFieldChange("tenantStreet", e.target.value)} + defaultValue={formValues.tenantStreet} + onChange={(e) => handleInputChange("tenantStreet", e.target.value)} />
{state.errors?.tenantStreet && @@ -148,17 +169,17 @@ export const LocationEditForm: FC = ({ location, yearMont
handleTenantFieldChange("tenantTown", e.target.value)} + defaultValue={formValues.tenantTown} + onChange={(e) => handleInputChange("tenantTown", e.target.value)} />
{state.errors?.tenantTown && @@ -169,12 +190,32 @@ export const LocationEditForm: FC = ({ location, yearMont ))}
- {t("tenant-2d-code-note")} - )} + ) : // ELSE include hidden inputs to preserve existing values + <> + + + + + }
- -
{t("auto-utility-bill-forwarding-legend")} {t("auto-utility-bill-forwarding-info")} @@ -186,17 +227,17 @@ export const LocationEditForm: FC = ({ location, yearMont type="checkbox" name="autoBillFwd" className="toggle toggle-primary" - checked={autoBillFwd} - onChange={(e) => setautoBillFwd(e.target.checked)} + checked={formValues.autoBillFwd} + onChange={(e) => handleInputChange("autoBillFwd", e.target.checked)} /> {t("auto-utility-bill-forwarding-toggle-label")}
- {autoBillFwd && ( + {formValues.autoBillFwd && (
{t("utility-bill-forwarding-strategy-label")} - @@ -215,18 +256,22 @@ export const LocationEditForm: FC = ({ location, yearMont type="checkbox" name="rentDueNotification" className="toggle toggle-primary" - checked={rentDueNotification} - onChange={(e) => setrentDueNotification(e.target.checked)} + checked={formValues.rentDueNotification} + onChange={(e) => handleInputChange("rentDueNotification", e.target.checked)} /> {t("auto-rent-notification-toggle-label")}
- {rentDueNotification && ( + {formValues.rentDueNotification && ( <>
{t("rent-due-day-label")} - handleInputChange("rentDueDay", parseInt(e.target.value,10)) + }> {Array.from({ length: 28 }, (_, i) => i + 1).map(day => ( ))} @@ -242,7 +287,8 @@ export const LocationEditForm: FC = ({ location, yearMont step="0.01" placeholder={t("rent-amount-placeholder")} className="input input-bordered w-full placeholder:text-gray-600 text-right" - defaultValue={location?.rentAmount ?? ""} + defaultValue={formValues.rentAmount} + onChange={(e) => handleInputChange("rentAmount", parseFloat(e.target.value))} />
{state.errors?.rentAmount && @@ -257,7 +303,7 @@ export const LocationEditForm: FC = ({ location, yearMont )}
- {(autoBillFwd || rentDueNotification) && ( + {(formValues.autoBillFwd || formValues.rentDueNotification) && (
{t("tenant-email-legend")} = ({ location, yearMont type="email" placeholder={t("tenant-email-placeholder")} className="input input-bordered w-full placeholder:text-gray-600" - defaultValue={location?.tenantEmail ?? ""} - onChange={(e) => handleTenantFieldChange("tenantEmail", e.target.value)} + defaultValue={formValues.tenantEmail} + onChange={(e) => handleInputChange("tenantEmail", e.target.value)} />
{state.errors?.tenantEmail && diff --git a/app/ui/NoteBox.tsx b/app/ui/NoteBox.tsx new file mode 100644 index 0000000..ce60aa8 --- /dev/null +++ b/app/ui/NoteBox.tsx @@ -0,0 +1,7 @@ +import { FC, ReactNode } from "react"; + +export const NoteBox: FC<{ children: ReactNode, className?: string }> = ({ children, className }) => +
+ ⚠️ + {children} +
\ No newline at end of file diff --git a/app/ui/UserSettingsForm.tsx b/app/ui/UserSettingsForm.tsx index 95a12fa..42f310a 100644 --- a/app/ui/UserSettingsForm.tsx +++ b/app/ui/UserSettingsForm.tsx @@ -9,6 +9,8 @@ import Link from "next/link"; import SettingsIcon from "@mui/icons-material/Settings"; import { formatIban } from "../lib/formatStrings"; import { InfoBox } from "./InfoBox"; +import { NoteBox } from "./NoteBox"; +import { LinkIcon } from "@heroicons/react/24/outline"; export type UserSettingsFormProps = { userSettings: UserSettings | null; @@ -27,26 +29,23 @@ const FormFields: FC = ({ userSettings, errors, message }) => { // Track current form values for real-time validation const [formValues, setFormValues] = useState({ + enableIbanPayment: userSettings?.enableIbanPayment ?? false, ownerName: userSettings?.ownerName ?? "", ownerStreet: userSettings?.ownerStreet ?? "", ownerTown: userSettings?.ownerTown ?? "", ownerIBAN: formatIban(userSettings?.ownerIBAN) ?? "", currency: userSettings?.currency ?? "EUR", + + enableRevolutPayment: userSettings?.enableRevolutPayment ?? false, + ownerRevolutProfileName: userSettings?.ownerRevolutProfileName ?? "", }); - const handleInputChange = (field: keyof typeof formValues, value: string) => { + // https://revolut.me/aderezic?currency=EUR&amount=70000 + + const handleInputChange = (field: keyof typeof formValues, value: string | boolean) => { setFormValues(prev => ({ ...prev, [field]: value })); }; - // Check if any required field is missing (clean IBAN of spaces for validation) - const cleanedOwnerIBAN = formValues.ownerIBAN.replace(/\s/g, ''); - const hasMissingData = !formValues.ownerName || !formValues.ownerStreet || !formValues.ownerTown || !cleanedOwnerIBAN || !formValues.currency; - - // Track whether to generate 2D code for tenant (use persisted value from database) - const [show2dCodeInMonthlyStatement, setShow2dCodeInMonthlyStatement] = useState( - userSettings?.show2dCodeInMonthlyStatement ?? false - ); - return ( <>
@@ -60,7 +59,7 @@ const FormFields: FC = ({ userSettings, errors, message }) => { id="currency" name="currency" className="select select-bordered w-full" - defaultValue={userSettings?.currency ?? "EUR"} + defaultValue={formValues.currency} onChange={(e) => handleInputChange("currency", e.target.value)} disabled={pending} > @@ -110,38 +109,40 @@ const FormFields: FC = ({ userSettings, errors, message }) => {
-
- {t("tenant-2d-code-legend")} - {t("info-box-message")} +
+ {t("iban-payment-instructions--legend")} + + {t("iban-payment-instructions--intro-message")}
- {show2dCodeInMonthlyStatement && ( + { formValues.enableIbanPayment ? ( <> +
{t("iban-form-title")}
handleInputChange("ownerName", e.target.value)} disabled={pending} /> @@ -157,16 +158,16 @@ const FormFields: FC = ({ userSettings, errors, message }) => {
handleInputChange("ownerStreet", e.target.value)} disabled={pending} /> @@ -182,16 +183,16 @@ const FormFields: FC = ({ userSettings, errors, message }) => {
handleInputChange("ownerTown", e.target.value)} disabled={pending} /> @@ -207,15 +208,15 @@ const FormFields: FC = ({ userSettings, errors, message }) => {
handleInputChange("ownerIBAN", e.target.value)} disabled={pending} /> @@ -229,9 +230,114 @@ const FormFields: FC = ({ userSettings, errors, message }) => {
- {t("additional-notes")} - - )} + {t("payment-additional-notes")} + + ) : // ELSE include hidden inputs to preserve existing values + <> + + + + + + } +
+ +
+ {t("revolut-payment-instructions--legend")} + + {t("revolut-payment-instructions--intro-message")} + +
+ +
+ + { formValues.enableRevolutPayment ? ( + <> +
{t("revolut-form-title")}
+
+ + handleInputChange("ownerRevolutProfileName", e.target.value)} + disabled={pending} + /> + +
+ {errors?.ownerRevolutProfileName && + errors.ownerRevolutProfileName.map((error: string) => ( +

+ {error} +

+ ))} +
+ { + !errors?.ownerRevolutProfileName && formValues.ownerRevolutProfileName.length > 5 ? ( +

+ {t("revolut-profile--test-link-label")} {' '} + + + {t("revolut-profile--test-link-text")} + +

+ ) : null + } +
+ {t("payment-additional-notes")} + + ) + : // ELSE include hidden input to preserve existing value + <> + + + }
diff --git a/app/ui/ViewLocationCard.tsx b/app/ui/ViewLocationCard.tsx index bede467..7afc03c 100644 --- a/app/ui/ViewLocationCard.tsx +++ b/app/ui/ViewLocationCard.tsx @@ -9,8 +9,9 @@ import { ViewBillBadge } from "./ViewBillBadge"; import { Pdf417Barcode } from "./Pdf417Barcode"; import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder"; import Link from "next/link"; -import { DocumentIcon } from "@heroicons/react/24/outline"; +import { DocumentIcon, LinkIcon } from "@heroicons/react/24/outline"; import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions"; +import QRCode from "react-qr-code"; export interface ViewLocationCardProps { location: BillingLocation; @@ -27,7 +28,7 @@ export const ViewLocationCard:FC = ({location, userSettin tenantName, tenantStreet, tenantTown, - generateTenantCode, + tenantPaymentMethod, // NOTE: only the fileName is projected from the DB to reduce data transfer utilBillsProofOfPaymentAttachment, utilBillsProofOfPaymentUploadedAt, @@ -79,7 +80,7 @@ export const ViewLocationCard:FC = ({location, userSettin const { hub3aText, paymentParams } = useMemo(() => { - if(!userSettings?.show2dCodeInMonthlyStatement || !generateTenantCode) { + if(!userSettings?.enableIbanPayment || tenantPaymentMethod !== "iban") { return { hub3aText: "", paymentParams: {} as PaymentParams @@ -107,7 +108,7 @@ export const ViewLocationCard:FC = ({location, userSettin hub3aText: EncodePayment(paymentParams), paymentParams }); - }, [userSettings?.show2dCodeInMonthlyStatement, generateTenantCode, locationName, tenantName, tenantStreet, tenantTown, userSettings, monthlyExpense, yearMonth]); + }, []); return(
@@ -126,7 +127,7 @@ export const ViewLocationCard:FC = ({location, userSettin : null } { - userSettings?.show2dCodeInMonthlyStatement && generateTenantCode ? + userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ? <>

{t("payment-info-header")}

    @@ -145,6 +146,30 @@ export const ViewLocationCard:FC = ({location, userSettin : null } + { + userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => { + const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(monthlyExpense).toFixed(0)}¤cy=${userSettings.currency}`; + return ( + <> +

    {t("payment-info-header")}

    +
    + +
    +

    + + + {t("revolut-link-text")} + +

    + + ); + })() + : null + }
    {t("upload-proof-of-payment-legend")} { diff --git a/messages/en.json b/messages/en.json index 087e2fd..29be3d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -70,7 +70,8 @@ "payment-purpose-code-label": "Purpose code:", "payment-description-label": "Payment description:", "upload-proof-of-payment-legend": "Proof of payment", - "upload-proof-of-payment-label": "Here you can upload proof of payment:" + "upload-proof-of-payment-label": "Here you can upload proof of payment:", + "revolut-link-text": "Pay with Revolut" }, "month-card": { "payed-total-label": "Total monthly expenditure:", @@ -141,23 +142,33 @@ "location-name-legend": "Realestate name", "location-name-placeholder": "enter realestate name", "notes-placeholder": "notes", - "tenant-2d-code-legend": "PAYMENT INSTRUCTIONS", - "tenant-2d-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.", - "tenant-2d-code-toggle-label": "show payment instructions to the tenant", - "tenant-2d-code-note": "IMPORTANT: for this to work you will also need to go into app settings and enter your name and IBAN.", - "tenant-name-label": "Tenant First and Last Name", - "tenant-name-placeholder": "enter tenant's first and last name", - "tenant-street-label": "Tenant Street and House Number", - "tenant-street-placeholder": "enter tenant's street", - "tenant-town-label": "Tenant Postal Code and Town", - "tenant-town-placeholder": "enter tenant's town", - "auto-utility-bill-forwarding-legend": "AUTOMATIC UTILITY BILL FORWARDING", + + "tenant-payment-instructions-legend": "PAYMENT INSTRUCTIONS", + "tenant-payment-instructions-code-info": "When the tenant opens the link to the statement for the given month, the application can show payment instructions for utility costs to your IBAN, as well as a 2D code they can scan.", + + "tenant-payment-instructions-method--legend": "Show payment instructions to tenant:", + "tenant-payment-instructions-method--none": "do not show payment instructions", + "tenant-payment-instructions-method--iban": "payment via IBAN", + "tenant-payment-instructions-method--iban-disabled": "payment via IBAN - disabled in app settings", + "tenant-payment-instructions-method--revolut": "payment via Revolut", + "tenant-payment-instructions-method--revolut-disabled": "payment via Revolut - disabled in app settings", + + + + "iban-payment--tenant-name-label": "Tenant First and Last Name", + "iban-payment--tenant-name-placeholder": "enter tenant's first and last name", + "iban-payment--tenant-street-label": "Tenant Street and House Number", + "iban-payment--tenant-street-placeholder": "enter tenant's street", + "iban-payment--tenant-town-label": "Tenant Postal Code and Town", + "iban-payment--tenant-town-placeholder": "enter tenant's town", + + "auto-utility-bill-forwarding-legend": "Automatic utility bill forwarding", "auto-utility-bill-forwarding-info": "This option enables automatic forwarding of utility bills to the tenant via email according to the selected forwarding strategy.", "auto-utility-bill-forwarding-toggle-label": "forward utility bills", "utility-bill-forwarding-strategy-label": "Forward utility bills when ...", "utility-bill-forwarding-when-payed": "all items are marked as paid", "utility-bill-forwarding-when-attached": "a bill (PDF) is attached to all items", - "auto-rent-notification-legend": "AUTOMATIC RENT NOTIFICATION", + "auto-rent-notification-legend": "Automatic rent notification", "auto-rent-notification-info": "This option enables automatic sending of monthly rent bill to the tenant via email on the specified day of the month.", "auto-rent-notification-toggle-label": "send rent notification", "rent-due-day-label": "Day of month when rent is due", @@ -191,17 +202,35 @@ }, "user-settings-form": { "title": "User settings", - "info-box-message": "By activating this option, a 2D barcode will be included in the monthly statement sent to the tenant, allowing them to make a direct payment to your bank account.", - "tenant-2d-code-legend": "TENANT 2D CODE", - "tenant-2d-code-toggle-label": "include 2D code in monthly statements", - "owner-name-label": "Your First and Last Name", - "owner-name-placeholder": "enter your first and last name", - "owner-street-label": "Your Street and House Number", - "owner-street-placeholder": "enter your street and house number", - "owner-town-label": "Your Postal Code and Town", - "owner-town-placeholder": "enter your postal code and town", - "owner-iban-label": "IBAN", - "owner-iban-placeholder": "enter your IBAN", + + "iban-payment-instructions--legend": "Payment to Your IBAN", + "iban-payment-instructions--intro-message": "By activating this option, payment instructions will be included in the monthly statement sent to the tenant, allowing a direct payment via IBAN to be made to your bank account.", + "iban-payment-instructions--toggle-label": "enable IBAN payment instructions", + + "iban-form-title": "Payment Information for IBAN", + "iban-owner-name-label": "Your First and Last Name", + "iban-owner-name-placeholder": "enter your first and last name", + "iban-owner-street-label": "Your Street and House Number", + "iban-owner-street-placeholder": "enter your street and house number", + "iban-owner-town-label": "Your Postal Code and Town", + "iban-owner-town-placeholder": "enter your postal code and town", + "iban-owner-iban-label": "IBAN", + "iban-owner-iban-placeholder": "enter your IBAN for receiving payments", + + + "revolut-form-title": "Payment Information for Revolut", + "revolut-payment-instructions--legend": "Payment to Your Revolut Profile", + "revolut-payment-instructions--intro-message": "By activating this option, payment instructions will be included in the monthly statement sent to the tenant, allowing a direct payment via Revolut to be made to your Revolut profile.", + "revolut-payment-instructions--toggle-label": "enable Revolut payment instructions", + + "revolut-profile-label": "Revolut profile name", + "revolut-profile-placeholder": "enter your Revolut profile name for receiving payments", + "revolut-profile-tooltip": "You can find your Revolut profile name in the Revolut app under your user profile. It is displayed below your name and starts with the '@' symbol (e.g., '@john123').", + "revolut-profile--test-link-label": "Test your Revolut link:", + "revolut-profile--test-link-text": "Pay with Revolut", + + "payment-additional-notes": "IMPORTANT: For the payment instructions to be displayed to the tenant, you must also enable this option in the property's settings.", + "general-settings-legend": "General Settings", "currency-label": "Currency", "save-button": "Save", @@ -213,8 +242,9 @@ "owner-iban-required": "Valid IBAN is mandatory", "owner-iban-invalid": "Invalid IBAN format. Please enter a valid IBAN", "currency-required": "Currency is mandatory", + "owner-revolut-profile-required": "Revolut profile name is mandatory", + "owner-revolut-profile-invalid": "Invalid Revolut profile format. Must start with '@' and contain only English letters and numbers (e.g., '@john123')", "validation-failed": "Validation failed. Please check the form and try again." - }, - "additional-notes": "Note: For the 2D code to be displayed, you must enter both the tenant's first and last names in the settings of each property for which you want to use this functionality." + } } } \ No newline at end of file diff --git a/messages/hr.json b/messages/hr.json index 047c1e7..f9b549b 100644 --- a/messages/hr.json +++ b/messages/hr.json @@ -70,7 +70,8 @@ "payment-purpose-code-label": "Šifra namjene:", "payment-description-label": "Opis plaćanja:", "upload-proof-of-payment-legend": "Potvrda o uplati", - "upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:" + "upload-proof-of-payment-label": "Ovdje možete priložiti potvrdu o uplati:", + "revolut-link-text": "Plati pomoću Revoluta" }, "month-card": { "payed-total-label": "Ukupni mjesečni trošak:", @@ -140,25 +141,35 @@ "location-name-legend": "Realestate name", "location-name-placeholder": "unesite naziv nekretnine", "notes-placeholder": "bilješke", - "tenant-2d-code-legend": "UPUTE ZA UPLATU", - "tenant-2d-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN, kao i 2D koji može skenirati.", - "tenant-2d-code-toggle-label": "podstanaru prikaži upute za uplatu", - "tenant-2d-code-note": "VAŽNO: za ovu funkcionalnost potrebno je otvoriti postavke aplikacije, te unijeti vaše ime i IBAN.", - "tenant-name-label": "Ime i prezime podstanara", - "tenant-name-placeholder": "unesite ime i prezime podstanara", - "tenant-street-label": "Ulica podstanara i kućni broj", - "tenant-street-placeholder": "unesite ulicu podstanara", - "tenant-town-label": "Poštanski broj i Grad podstanara", - "tenant-town-placeholder": "unesite poštanski broj i grad podstanara", + + "tenant-payment-instructions-legend": "UPUTE ZA UPLATU", + "tenant-payment-instructions-code-info": "Kada podstanar otvori poveznicu na obračun za zadani mjesec aplikacija mu može prikazati upute za uplatu troškova režija na vaš IBAN ili Revolut.", + + "tenant-payment-instructions-method--legend": "Podstanaru prikaži upute za uplatu:", + "tenant-payment-instructions-method--none": "ne prikazuj upute za uplatu", + "tenant-payment-instructions-method--iban": "uplata na IBAN", + "tenant-payment-instructions-method--iban-disabled": "uplata na IBAN - onemogućeno u app postavkama", + "tenant-payment-instructions-method--revolut": "uplata na Revolut", + "tenant-payment-instructions-method--revolut-disabled": "uplata na Revolut - onemogućeno u app postavkama", + "tenant-payment-instructions-method--disabled-message": "Ova opcija je nedostupna zato što nije omogućena u postavkama aplikacije.", + + "iban-payment--form-title": "Informacije za uplatu na IBAN", + "iban-payment--tenant-name-label": "Ime i prezime podstanara", + "iban-payment--tenant-name-placeholder": "unesite ime i prezime podstanara", + "iban-payment--tenant-street-label": "Ulica podstanara i kućni broj", + "iban-payment--tenant-street-placeholder": "unesite ulicu podstanara", + "iban-payment--tenant-town-label": "Poštanski broj i Grad podstanara", + "iban-payment--tenant-town-placeholder": "unesite poštanski broj i grad podstanara", + "auto-utility-bill-forwarding-legend": "AUTOMATSKO PROSLJEĐIVANJE REŽIJA", "auto-utility-bill-forwarding-info": "Ova opcija omogućuje automatsko prosljeđivanje režija podstanaru putem emaila u skladu s odabranom strategijom.", "auto-utility-bill-forwarding-toggle-label": "proslijedi režije automatski", "utility-bill-forwarding-strategy-label": "Režije proslijedi kada...", "utility-bill-forwarding-when-payed": "sve stavke označim kao plaćene", "utility-bill-forwarding-when-attached": "za sve stavke priložim račun (PDF)", - "auto-rent-notification-legend": "AUTOMATSKA OBAVIJEST O NAJAMNINI", + "auto-rent-notification-legend": "Automatska obavjest O najamnini", "auto-rent-notification-info": "Ova opcija omogućuje automatsko slanje mjesečnog računa za najamninu podstanaru putem emaila na zadani dan u mjesecu.", - "auto-rent-notification-toggle-label": "pošalji obavijest o najamnini", + "auto-rent-notification-toggle-label": "pošalji obavjest o najamnini", "rent-due-day-label": "Dan u mjesecu kada dospijeva najamnina", "rent-amount-label": "Iznos najamnine", "rent-amount-placeholder": "unesite iznos najamnine", @@ -190,17 +201,32 @@ }, "user-settings-form": { "title": "Korisničke postavke", - "info-box-message": "Ako uključite ovu opciji na mjesečnom obračunu koji se šalje podstanaru biti će prikazan 2D bar kod, putem kojeg će moći izvršiti izravnu uplatu na vaš bankovni račun.", - "tenant-2d-code-legend": "2D BARKOD ZA PODSTANARA", - "tenant-2d-code-toggle-label": "prikazuj 2D barkod u mjesečnom obračunu", - "owner-name-label": "Vaše ime i prezime", - "owner-name-placeholder": "unesite svoje ime i prezime", - "owner-street-label": "Ulica i kućni broj", - "owner-street-placeholder": "unesite ulicu i kućni broj", - "owner-town-label": "Poštanski broj i Grad", - "owner-town-placeholder": "unesite poštanski broj i grad", - "owner-iban-label": "IBAN", - "owner-iban-placeholder": "unesite svoj IBAN", + + "iban-payment-instructions--legend": "Uplata na vaš IBAN", + "iban-payment-instructions--intro-message": "Aktiviranjem ove opcije, upute za uplatu bit će uključene u mjesečni izvještaj poslan podstanaru, omogućujući im da izvrše izravnu uplatu putem IBAN-a na vaš bankovni račun.", + "iban-payment-instructions--toggle-label": "uključi IBAN uplatu", + + "iban-form-title": "Informacije za uplatu na IBAN", + "iban-owner-name-label": "Vaše ime i prezime", + "iban-owner-name-placeholder": "unesite svoje ime i prezime", + "iban-owner-street-label": "Ulica i kućni broj", + "iban-owner-street-placeholder": "unesite ulicu i kućni broj", + "iban-owner-town-label": "Poštanski broj i Grad", + "iban-owner-town-placeholder": "unesite poštanski broj i grad", + "iban-owner-iban-label": "IBAN", + "iban-owner-iban-placeholder": "IBAN putem kojeg ćete primate uplate", + + "revolut-payment-instructions--legend": "Uplata na vaš Revolut profil", + "revolut-payment-instructions--intro-message": "Aktiviranjem ove opcije, upute za uplatu bit će uključene u mjesečni izvještaj poslan podstanaru, omogućujući im da izvrše izravnu uplatu putem Revolut-a na vaš profil.", + "revolut-payment-instructions--toggle-label": "uključi Revolut uplatu", + + "revolut-form-title": "Informacije za uplatu na Revolut", + "revolut-profile-label": "Naziv vašeg Revolut profila", + "revolut-profile-placeholder": "profil putem kojeg ćete primati uplate", + "revolut-profile-tooltip": "Naziv vašeg Revolut profila možete pronaći u aplikaciji Revolut u korisničkom profilu. Prikazan je ispod vašeg imena i prezimena - počinje sa znakom '@' (npr: '@ivan123').", + "revolut-profile--test-link-label": "Testiraj svoju Revolut poveznicu:", + "revolut-profile--test-link-text": "Plati pomoću Revoluta", + "general-settings-legend": "Opće postavke", "currency-label": "Valuta", "save-button": "Spremi", @@ -212,8 +238,10 @@ "owner-iban-required": "Ispravan IBAN je obavezan", "owner-iban-invalid": "Neispravan IBAN format. Molimo unesite ispravan IBAN.", "currency-required": "Valuta je obavezna", + "owner-revolut-profile-required": "Naziv Revolut profila je obavezan", + "owner-revolut-profile-invalid": "Neispravan format Revolut profila. Mora počinjati sa '@' i sadržavati samo slova i brojeve (npr. '@ivan123')", "validation-failed": "Validacija nije uspjela. Molimo provjerite formu i pokušajte ponovno." }, - "additional-notes": "Napomena: da bi 2D koda bio prikazan, morate unijeti i ime i prezime podstanara u postavkama svake nekretnine za koju želite koristiti ovu funkcionalnost." + "payment-additional-notes": "VAŽNO: da bi upute za uplatu bile prikazane podstanaru, morate tu ovu opciju uključiti i u postavkama pripadajuće nekretnine." } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1a94b78..9de178a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "evidencija-rezija", - "version": "2.1.2", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "version": "2.1.2", + "version": "2.2.0", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -34,6 +34,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-infinite-scroll-component": "^6.1.0", + "react-qr-code": "^2.0.18", "react-toastify": "^10.0.6", "tailwindcss": "^3.4.0", "typescript": "5.2.2", @@ -7042,6 +7043,11 @@ "node": ">=6" } }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7102,6 +7108,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-qr-code": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", + "integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-toastify": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz", diff --git a/package.json b/package.json index e5d84ae..c56fd5e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-infinite-scroll-component": "^6.1.0", + "react-qr-code": "^2.0.18", "react-toastify": "^10.0.6", "tailwindcss": "^3.4.0", "typescript": "5.2.2", @@ -58,5 +59,5 @@ "engines": { "node": ">=18.17.0" }, - "version": "2.1.2" + "version": "2.2.0" }