From 9ae023cc94cb55409a97ca67feb18d06bb6c04c5 Mon Sep 17 00:00:00 2001 From: Knee Cola Date: Tue, 18 Nov 2025 08:23:08 +0100 Subject: [PATCH 01/20] Add tenant information fields to LocationEditForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added optional tenant fields (first name, last name, email) to billing locations with a toggle to enable/disable 2D barcode generation for tenants. Changes: - Added generateTenantCode, tenantFirstName, tenantLastName, and tenantEmail fields to BillingLocation interface - Updated LocationEditForm with toggle control and conditional tenant fields - Implemented conditional validation: tenant names required when generateTenantCode is true - Updated updateOrAddLocation action to persist tenant data across all update operations - Added localization strings for tenant fields and validation messages (Croatian/English) The generateTenantCode flag is persisted in the database and controls visibility of tenant name fields. When enabled, both first and last names become mandatory. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/lib/actions/locationActions.ts | 67 +++++++++++-- app/lib/db-types.ts | 8 ++ app/ui/LocationEditForm.tsx | 152 ++++++++++++++++++++++++----- messages/en.json | 13 ++- messages/hr.json | 13 ++- 5 files changed, 219 insertions(+), 34 deletions(-) diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index b8c956e..2c10ffa 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -14,7 +14,11 @@ import { getTranslations, getLocale } from "next-intl/server"; export type State = { errors?: { locationName?: string[]; - locationNotes?: string[], + locationNotes?: string[]; + generateTenantCode?: string[]; + tenantFirstName?: string[]; + tenantLastName?: string[]; + tenantEmail?: string[]; }; message?:string | null; }; @@ -27,11 +31,34 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ _id: z.string(), locationName: z.coerce.string().min(1, t("location-name-required")), locationNotes: z.string(), + generateTenantCode: z.boolean().optional().nullable(), + tenantFirstName: z.string().optional().nullable(), + tenantLastName: z.string().optional().nullable(), + tenantEmail: z.string().optional().nullable(), addToSubsequentMonths: z.boolean().optional().nullable(), updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(), }) // dont include the _id field in the response - .omit({ _id: true }); + .omit({ _id: true }) + // Add conditional validation: if generateTenantCode is true, tenant names are required + .refine((data) => { + if (data.generateTenantCode) { + return !!data.tenantFirstName && data.tenantFirstName.trim().length > 0; + } + return true; + }, { + message: t("tenant-first-name-required"), + path: ["tenantFirstName"], + }) + .refine((data) => { + if (data.generateTenantCode) { + return !!data.tenantLastName && data.tenantLastName.trim().length > 0; + } + return true; + }, { + message: t("tenant-last-name-required"), + path: ["tenantLastName"], + }); /** * Server-side action which adds or updates a bill @@ -49,6 +76,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat const validatedFields = FormSchema(t).safeParse({ locationName: formData.get('locationName'), locationNotes: formData.get('locationNotes'), + generateTenantCode: formData.get('generateTenantCode') === 'on', + tenantFirstName: formData.get('tenantFirstName') || null, + tenantLastName: formData.get('tenantLastName') || null, + tenantEmail: formData.get('tenantEmail') || null, addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on', updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined, }); @@ -57,13 +88,17 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat if(!validatedFields.success) { return({ errors: validatedFields.error.flatten().fieldErrors, - message: "Missing Fields", + message: t("validation-failed"), }); } const { locationName, locationNotes, + generateTenantCode, + tenantFirstName, + tenantLastName, + tenantEmail, addToSubsequentMonths, updateScope, } = validatedFields.data; @@ -97,6 +132,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat $set: { name: locationName, notes: locationNotes, + generateTenantCode: generateTenantCode || false, + tenantFirstName: tenantFirstName || null, + tenantLastName: tenantLastName || null, + tenantEmail: tenantEmail || null, } } ); @@ -108,9 +147,9 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat name: currentLocation.name, $or: [ { "yearMonth.year": { $gt: currentLocation.yearMonth.year } }, - { - "yearMonth.year": currentLocation.yearMonth.year, - "yearMonth.month": { $gte: currentLocation.yearMonth.month } + { + "yearMonth.year": currentLocation.yearMonth.year, + "yearMonth.month": { $gte: currentLocation.yearMonth.month } } ] }, @@ -118,6 +157,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat $set: { name: locationName, notes: locationNotes, + generateTenantCode: generateTenantCode || false, + tenantFirstName: tenantFirstName || null, + tenantLastName: tenantLastName || null, + tenantEmail: tenantEmail || null, } } ); @@ -132,6 +175,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat $set: { name: locationName, notes: locationNotes, + generateTenantCode: generateTenantCode || false, + tenantFirstName: tenantFirstName || null, + tenantLastName: tenantLastName || null, + tenantEmail: tenantEmail || null, } } ); @@ -144,6 +191,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat userEmail, name: locationName, notes: locationNotes, + generateTenantCode: generateTenantCode || false, + tenantFirstName: tenantFirstName || null, + tenantLastName: tenantLastName || null, + tenantEmail: tenantEmail || null, yearMonth: yearMonth, bills: [], }); @@ -208,6 +259,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat userEmail, name: locationName, notes: locationNotes, + generateTenantCode: generateTenantCode || false, + tenantFirstName: tenantFirstName || null, + tenantLastName: tenantLastName || null, + tenantEmail: tenantEmail || null, yearMonth: { year: monthData.year, month: monthData.month }, bills: [], }); diff --git a/app/lib/db-types.ts b/app/lib/db-types.ts index c0c7528..e920b37 100644 --- a/app/lib/db-types.ts +++ b/app/lib/db-types.ts @@ -43,6 +43,14 @@ export interface BillingLocation { bills: Bill[]; /** (optional) notes */ notes: string|null; + /** (optional) whether to generate 2D code for tenant */ + generateTenantCode?: boolean | null; + /** (optional) tenant first name */ + tenantFirstName?: string | null; + /** (optional) tenant last name */ + tenantLastName?: string | null; + /** (optional) tenant email */ + tenantEmail?: string | null; }; export enum BilledTo { diff --git a/app/ui/LocationEditForm.tsx b/app/ui/LocationEditForm.tsx index cf95c0c..6e0b3a7 100644 --- a/app/ui/LocationEditForm.tsx +++ b/app/ui/LocationEditForm.tsx @@ -1,7 +1,7 @@ "use client"; import { TrashIcon } from "@heroicons/react/24/outline"; -import { FC } from "react"; +import { FC, useState } from "react"; import { BillingLocation, YearMonth } from "../lib/db-types"; import { updateOrAddLocation } from "../lib/actions/locationActions"; import { useFormState } from "react-dom"; @@ -20,51 +20,152 @@ export type LocationEditFormProps = { yearMonth: YearMonth } -export const LocationEditForm:FC = ({ location, yearMonth }) => -{ +export const LocationEditForm: FC = ({ location, yearMonth }) => { const initialState = { message: null, errors: {} }; const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth); - const [ state, dispatch ] = useFormState(handleAction, initialState); + const [state, dispatch] = useFormState(handleAction, initialState); const t = useTranslations("location-edit-form"); const locale = useLocale(); + // Track whether to generate 2D code for tenant (use persisted value from database) + const [generateTenantCode, setGenerateTenantCode] = useState( + location?.generateTenantCode ?? false + ); + + // Track tenant field values for real-time validation + const [tenantFields, setTenantFields] = useState({ + tenantFirstName: location?.tenantFirstName ?? "", + tenantLastName: location?.tenantLastName ?? "", + tenantEmail: location?.tenantEmail ?? "", + }); + + const handleTenantFieldChange = (field: keyof typeof tenantFields, value: string) => { + setTenantFields(prev => ({ ...prev, [field]: value })); + }; + let { year, month } = location ? location.yearMonth : yearMonth; - return( + return (
{ - location && - - - + location && + + + }
{state.errors?.locationName && - state.errors.locationName.map((error: string) => ( -

- {error} -

- ))} + state.errors.locationName.map((error: string) => ( +

+ {error} +

+ ))}
{state.errors?.locationNotes && - state.errors.locationNotes.map((error: string) => ( -

- {error} -

- ))} + state.errors.locationNotes.map((error: string) => ( +

+ {error} +

+ ))} +
+ +
+ +
+ + {generateTenantCode && ( + <> +
+ + handleTenantFieldChange("tenantFirstName", e.target.value)} + /> +
+ {state.errors?.tenantFirstName && + state.errors.tenantFirstName.map((error: string) => ( +

+ {error} +

+ ))} +
+
+ +
+ + handleTenantFieldChange("tenantLastName", e.target.value)} + /> +
+ {state.errors?.tenantLastName && + state.errors.tenantLastName.map((error: string) => ( +

+ {error} +

+ ))} +
+
+ + )} + +
+ + handleTenantFieldChange("tenantEmail", e.target.value)} + /> +
+ {state.errors?.tenantEmail && + state.errors.tenantEmail.map((error: string) => ( +

+ {error} +

+ ))} +
{/* Show different options for add vs edit operations */} {!location ? (
@@ -93,9 +194,9 @@ export const LocationEditForm:FC = ({ location, yearMonth
{ state.message && -

- {state.message} -

+

+ {state.message} +

}
@@ -108,9 +209,8 @@ export const LocationEditForm:FC = ({ location, yearMonth ) } -export const LocationEditFormSkeleton:FC = () => -{ - return( +export const LocationEditFormSkeleton: FC = () => { + return (
diff --git a/messages/en.json b/messages/en.json index 4cb5892..8ef72a1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -124,6 +124,14 @@ "location-edit-form": { "location-name-placeholder": "Realestate name", "notes-placeholder": "Notes", + "generate-tenant-code": "Generate 2D code for tenant", + "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", + "tenant-email-label": "Tenant Email", + "tenant-email-placeholder": "Enter tenant's email", + "warning-missing-tenant-names": "Warning: Tenant first and last name are missing. The 2D barcode will not be displayed to the tenant when they open the shared link until both fields are filled in.", "save-button": "Save", "cancel-button": "Cancel", "delete-tooltip": "Delete realestate", @@ -133,7 +141,10 @@ "update-subsequent-months": "current and all future months", "update-all-months": "all months", "validation": { - "location-name-required": "Relaestate name is required" + "location-name-required": "Relaestate name is required", + "tenant-first-name-required": "tenant first name is missing", + "tenant-last-name-required": "tenant last name is missing", + "validation-failed": "Validation failed. Please check the form and try again." } }, "account-form": { diff --git a/messages/hr.json b/messages/hr.json index abca82d..3bb18be 100644 --- a/messages/hr.json +++ b/messages/hr.json @@ -123,6 +123,14 @@ "location-edit-form": { "location-name-placeholder": "Ime nekretnine", "notes-placeholder": "Bilješke", + "generate-tenant-code": "Generiraj 2D barkod za podstanara", + "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", + "tenant-email-label": "Email podstanara", + "tenant-email-placeholder": "Unesite email podstanara", + "warning-missing-tenant-names": "Upozorenje: Ime i prezime podstanara nedostaju. 2D barkod neće biti prikazan podstanaru kada otvori podijeljenu poveznicu dok oba polja ne budu popunjena.", "save-button": "Spremi", "cancel-button": "Odbaci", "delete-tooltip": "Brisanje nekretnine", @@ -132,7 +140,10 @@ "update-subsequent-months": "trenutni i svi budući mjeseci", "update-all-months": "svi mjeseci", "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", + "validation-failed": "Validacija nije uspjela. Molimo provjerite formu i pokušajte ponovno." } }, "account-form": { From 5dd7d40edf88a9426504de49b3be8cd81214e4db Mon Sep 17 00:00:00 2001 From: Knee Cola Date: Tue, 18 Nov 2025 09:05:25 +0100 Subject: [PATCH 02/20] Update navigation to preserve year/month context in redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modified gotoHomeWithMessage to accept optional yearMonth parameter and updated location actions to redirect with year/month context after save/delete. Changes: - Updated gotoHomeWithMessage to accept yearMonth parameter - Modified redirect URLs to include year and month query params - updateOrAddLocation now redirects to /${locale}?year=${year}&month=${month}&locationSaved=true - deleteLocationById now redirects to /${locale}?year=${year}&month=${month}&locationDeleted=true - Removed unused gotoHome import from locationActions This ensures users return to the same month view after location operations, maintaining context and providing success feedback via URL parameters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/lib/actions/locationActions.ts | 7 ++++--- app/lib/actions/navigationActions.ts | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 2c10ffa..539b0d0 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -6,7 +6,7 @@ import { BillingLocation, YearMonth } from '../db-types'; import { ObjectId } from 'mongodb'; import { withUser } from '@/app/lib/auth'; import { AuthenticatedUser } from '../types/next-auth'; -import { gotoHome, gotoHomeWithMessage } from './navigationActions'; +import { gotoHomeWithMessage } from './navigationActions'; import { unstable_noStore as noStore } from 'next/cache'; import { IntlTemplateFn } from '@/app/i18n'; import { getTranslations, getLocale } from "next-intl/server"; @@ -276,9 +276,10 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat } } - if(yearMonth) { + // Redirect to home page with year and month parameters, including success message + if (yearMonth) { const locale = await getLocale(); - await gotoHomeWithMessage(locale, 'locationSaved'); + await gotoHomeWithMessage(locale, 'locationSaved', yearMonth); } // This return is needed for TypeScript, but won't be reached due to redirect diff --git a/app/lib/actions/navigationActions.ts b/app/lib/actions/navigationActions.ts index 37506c8..376e267 100644 --- a/app/lib/actions/navigationActions.ts +++ b/app/lib/actions/navigationActions.ts @@ -9,8 +9,10 @@ export async function gotoHome({year, month}: YearMonth) { await gotoUrl(path); } -export async function gotoHomeWithMessage(locale: string, message: string) { - const path = `/${locale}?${message}=true`; +export async function gotoHomeWithMessage(locale: string, message: string, yearMonth?: YearMonth) { + const path = yearMonth + ? `/${locale}?year=${yearMonth.year}&month=${yearMonth.month}&${message}=true` + : `/${locale}?${message}=true`; await gotoUrl(path); } From 93cf159c4494b9178e7ad89bdd1c793f1eec3619 Mon Sep 17 00:00:00 2001 From: Knee Cola Date: Tue, 18 Nov 2025 09:24:18 +0100 Subject: [PATCH 03/20] Add auto tenant notification toggle to LocationEditForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added toggle to control automatic tenant notifications with conditional email field visibility based on the toggle state. Changes: - Added autoTenantNotification field to BillingLocation interface - Updated LocationEditForm with "Notify tenant automatically" toggle - Email field now only visible when autoTenantNotification is enabled - Toggle appears after tenant name fields (when generateTenantCode is active) - Updated updateOrAddLocation action to persist autoTenantNotification flag - Added localization strings for toggle (Croatian/English) Field visibility hierarchy: 1. Generate 2D code toggle (always visible) 2. Tenant name fields (visible when #1 is ON) 3. Auto notification toggle (visible when #1 is ON) 4. Email field (visible when #1 AND #3 are ON) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/lib/actions/locationActions.ts | 9 +++++ app/lib/db-types.ts | 2 + app/ui/LocationEditForm.tsx | 62 ++++++++++++++++++++---------- messages/en.json | 1 + messages/hr.json | 1 + 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/app/lib/actions/locationActions.ts b/app/lib/actions/locationActions.ts index 539b0d0..b0fb218 100644 --- a/app/lib/actions/locationActions.ts +++ b/app/lib/actions/locationActions.ts @@ -18,6 +18,7 @@ export type State = { generateTenantCode?: string[]; tenantFirstName?: string[]; tenantLastName?: string[]; + autoTenantNotification?: string[]; tenantEmail?: string[]; }; message?:string | null; @@ -34,6 +35,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({ generateTenantCode: z.boolean().optional().nullable(), tenantFirstName: z.string().optional().nullable(), tenantLastName: z.string().optional().nullable(), + autoTenantNotification: z.boolean().optional().nullable(), tenantEmail: z.string().optional().nullable(), addToSubsequentMonths: z.boolean().optional().nullable(), updateScope: z.enum(["current", "subsequent", "all"]).optional().nullable(), @@ -79,6 +81,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat generateTenantCode: formData.get('generateTenantCode') === 'on', tenantFirstName: formData.get('tenantFirstName') || null, tenantLastName: formData.get('tenantLastName') || null, + autoTenantNotification: formData.get('autoTenantNotification') === 'on', tenantEmail: formData.get('tenantEmail') || null, addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on', updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined, @@ -98,6 +101,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat generateTenantCode, tenantFirstName, tenantLastName, + autoTenantNotification, tenantEmail, addToSubsequentMonths, updateScope, @@ -135,6 +139,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat generateTenantCode: generateTenantCode || false, tenantFirstName: tenantFirstName || null, tenantLastName: tenantLastName || null, + autoTenantNotification: autoTenantNotification || false, tenantEmail: tenantEmail || null, } } @@ -160,6 +165,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat generateTenantCode: generateTenantCode || false, tenantFirstName: tenantFirstName || null, tenantLastName: tenantLastName || null, + autoTenantNotification: autoTenantNotification || false, tenantEmail: tenantEmail || null, } } @@ -178,6 +184,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat generateTenantCode: generateTenantCode || false, tenantFirstName: tenantFirstName || null, tenantLastName: tenantLastName || null, + autoTenantNotification: autoTenantNotification || false, tenantEmail: tenantEmail || null, } } @@ -194,6 +201,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat generateTenantCode: generateTenantCode || false, tenantFirstName: tenantFirstName || null, tenantLastName: tenantLastName || null, + autoTenantNotification: autoTenantNotification || false, tenantEmail: tenantEmail || null, yearMonth: yearMonth, bills: [], @@ -262,6 +270,7 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat generateTenantCode: generateTenantCode || false, tenantFirstName: tenantFirstName || null, tenantLastName: tenantLastName || null, + autoTenantNotification: autoTenantNotification || false, tenantEmail: tenantEmail || null, yearMonth: { year: monthData.year, month: monthData.month }, bills: [], diff --git a/app/lib/db-types.ts b/app/lib/db-types.ts index e920b37..e932b8f 100644 --- a/app/lib/db-types.ts +++ b/app/lib/db-types.ts @@ -49,6 +49,8 @@ export interface BillingLocation { tenantFirstName?: string | null; /** (optional) tenant last name */ tenantLastName?: string | null; + /** (optional) whether to automatically notify tenant */ + autoTenantNotification?: boolean | null; /** (optional) tenant email */ tenantEmail?: string | null; }; diff --git a/app/ui/LocationEditForm.tsx b/app/ui/LocationEditForm.tsx index 6e0b3a7..ae6bf85 100644 --- a/app/ui/LocationEditForm.tsx +++ b/app/ui/LocationEditForm.tsx @@ -32,6 +32,11 @@ export const LocationEditForm: FC = ({ location, yearMont location?.generateTenantCode ?? false ); + // Track whether to automatically notify tenant (use persisted value from database) + const [autoTenantNotification, setAutoTenantNotification] = useState( + location?.autoTenantNotification ?? false + ); + // Track tenant field values for real-time validation const [tenantFields, setTenantFields] = useState({ tenantFirstName: location?.tenantFirstName ?? "", @@ -113,7 +118,7 @@ export const LocationEditForm: FC = ({ location, yearMont
-
+
@@ -138,29 +143,44 @@ export const LocationEditForm: FC = ({ location, yearMont )} -
-