Merge branch 'release/1.36.0'

This commit is contained in:
2025-08-11 12:59:08 +02:00
13 changed files with 559 additions and 77 deletions

5
.vscode/launch.json vendored
View File

@@ -9,6 +9,11 @@
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"envFile": "${workspaceFolder}/.env", "envFile": "${workspaceFolder}/.env",
"env": {
"USE_MOCK_AUTH": "true",
"MOCK_USER_ID": "109754742613069927799",
"MOCK_USER_NAME": "Nikola Derežić"
},
"runtimeArgs": [ "runtimeArgs": [
"run", // this is `run` from `npm run` "run", // this is `run` from `npm run`
"dev" // this is `dev` from `npm run dev` "dev" // this is `dev` from `npm run dev`

72
CLAUDE.md Normal file
View File

@@ -0,0 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
- `npm run dev` - Start development server (Next.js)
- `npm run build` - Build production version
- `npm start` - Start production server
- `npm run prettier` - Format code with Prettier
- `npm run prettier:check` - Check code formatting
- `npm run seed` - Seed database with initial data
## Deployment Commands
- `./build.sh` - Build Docker image for deployment
- `./deploy.sh` - Deploy Docker service to production
- `./debug-deploy.sh` - Deploy with debug configuration
## Architecture Overview
This is a Next.js 14 utility bills tracking application ("Evidencija Režija") with the following key components:
### Tech Stack
- **Framework**: Next.js 14 with App Router and standalone output for Docker
- **Authentication**: NextAuth v5 with Google OAuth
- **Database**: MongoDB with connection pooling
- **Internationalization**: next-intl (Croatian/English)
- **Styling**: Tailwind CSS with DaisyUI components
- **Deployment**: Docker with MongoDB 4.4.27 (AVX compatibility)
### Core Architecture Patterns
**Multi-user Data Isolation**: All database operations use the `withUser` higher-order function from `app/lib/auth.ts:102` to automatically inject authenticated user ID into queries, ensuring data isolation between users.
**Server Actions Pattern**: Form handling uses Next.js Server Actions with Zod validation. Actions are defined in `app/lib/actions/` and follow the pattern:
```typescript
export const actionName = withUser(async (user: AuthenticatedUser, ...args) => {
// Server action implementation with automatic user context
});
```
**Internationalization**: Uses next-intl with locale-based routing. Messages are in `messages/` directory. The middleware handles both auth and i18n routing.
### Key Files & Responsibilities
- `middleware.ts` - Handles authentication and i18n routing, defines public pages
- `app/lib/auth.ts` - NextAuth configuration, `withUser` HOF for user context
- `app/lib/dbClient.ts` - MongoDB connection with development/production handling
- `app/lib/actions/` - Server actions for data mutations (locations, bills, months)
- `app/i18n.ts` - Internationalization configuration (Croatian default)
- `next.config.js` - Standalone build config with `serverActions.allowedOrigins` for Docker deployment
### Database Schema
- **Collections**: Locations, Bills, Months (year-month periods)
- **User Association**: All documents include `userId` field for multi-tenant isolation
- **Database Name**: "utility-bills"
### Docker Deployment Notes
- Uses standalone Next.js build for Docker optimization
- MongoDB 4.4.27 required for older CPU compatibility (no AVX instructions)
- `HOSTNAME=0.0.0.0` with `serverActions.allowedOrigins` config to handle reverse proxy headers
- Environment variables: `MONGODB_URI`, `GOOGLE_ID`, `GOOGLE_SECRET`, `AUTH_SECRET`
### Barcode/QR Code Feature
- Uses `@zxing/library` and `@zxing/browser` for PDF document scanning
- Heavy barcode decoding operations should be moved to background threads if performance issues arise
- PDF processing with `pdfjs-dist` for utility bill scanning
### Testing & Code Quality
- ESLint with Next.js and Prettier configurations
- No specific test framework configured - check with user before assuming testing approach

View File

@@ -1,6 +1,6 @@
# This file is inspired by https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile # This file is inspired by https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
FROM node:18-alpine AS base FROM node:24-alpine AS base
#----------------------------------------- #-----------------------------------------
# STAGE 1: Build the Next.js project # STAGE 1: Build the Next.js project
@@ -27,11 +27,11 @@ RUN npm run build
#----------------------------------------- #-----------------------------------------
# STAGE 3: Run the Next.js server # STAGE 3: Run the Next.js server
#----------------------------------------- #-----------------------------------------
FROM base as production FROM base AS production
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@@ -54,7 +54,7 @@ USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT=3000
# server.js is created by next build from the standalone output # server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output # https://nextjs.org/docs/pages/api-reference/next-config-js/output

View File

@@ -28,6 +28,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
_id: z.string(), _id: z.string(),
billName: z.coerce.string().min(1, t("bill-name-required")), billName: z.coerce.string().min(1, t("bill-name-required")),
billNotes: z.string(), billNotes: z.string(),
addToSubsequentMonths: z.boolean().optional(),
payedAmount: z.string().nullable().transform((val, ctx) => { payedAmount: z.string().nullable().transform((val, ctx) => {
if(!val || val === '') { if(!val || val === '') {
@@ -123,6 +124,7 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI
.safeParse({ .safeParse({
billName: formData.get('billName'), billName: formData.get('billName'),
billNotes: formData.get('billNotes'), billNotes: formData.get('billNotes'),
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
payedAmount: formData.get('payedAmount'), payedAmount: formData.get('payedAmount'),
}); });
@@ -138,6 +140,7 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI
const { const {
billName, billName,
billNotes, billNotes,
addToSubsequentMonths,
payedAmount, payedAmount,
} = validatedFields.data; } = validatedFields.data;
@@ -183,25 +186,95 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI
] ]
}); });
} else { } else {
// find a location with the given locationID // Create new bill - add to current location first
const post = await dbClient.collection<BillingLocation>("lokacije").updateOne( const newBill = {
_id: (new ObjectId()).toHexString(),
name: billName,
paid: billPaid,
attachment: billAttachment,
notes: billNotes,
payedAmount,
barcodeImage,
};
// Add to current location
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{ {
_id: locationId, // find a location with the given locationID _id: locationId, // find a location with the given locationID
userId // make sure that the location belongs to the user userId // make sure that the location belongs to the user
}, },
{ {
$push: { $push: {
bills: { bills: newBill
_id: (new ObjectId()).toHexString(),
name: billName,
paid: billPaid,
attachment: billAttachment,
notes: billNotes,
payedAmount,
barcodeImage,
}
} }
}); });
// If addToSubsequentMonths is enabled, add to subsequent months
if (addToSubsequentMonths && billYear && billMonth) {
// Get the current location to find its name
const currentLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationId, userId }, { projection: { bills: 0 } });
if (currentLocation) {
// Find all subsequent months that have the same location name
const subsequentLocations = await dbClient.collection<BillingLocation>("lokacije")
.find({
userId,
name: currentLocation.name,
$or: [
{ "yearMonth.year": { $gt: billYear } },
{
"yearMonth.year": billYear,
"yearMonth.month": { $gt: billMonth }
}
]
}, { projection: { bills: 0 } })
.toArray();
// For each subsequent location, check if bill with same name already exists
const updateOperations = [];
for (const location of subsequentLocations) {
const existingBill = await dbClient.collection<BillingLocation>("lokacije")
.findOne({
_id: location._id,
"bills.name": billName
}, {
projection: {
"bills.$": 1,
"bills.attachment": 0,
"bills.barcodeImage": 0
}
});
// Only add if bill with same name doesn't already exist
if (!existingBill) {
updateOperations.push({
updateOne: {
filter: { _id: location._id, userId },
update: {
$push: {
bills: {
_id: (new ObjectId()).toHexString(),
name: billName,
paid: false, // New bills in subsequent months are unpaid
attachment: null, // No attachment for subsequent months
notes: billNotes,
payedAmount: null,
barcodeImage: undefined,
}
}
}
}
});
}
}
// Execute all update operations at once if any
if (updateOperations.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").bulkWrite(updateOperations);
}
}
}
} }
if(billYear && billMonth ) { if(billYear && billMonth ) {
await gotoHome({ year: billYear, month: billMonth }); await gotoHome({ year: billYear, month: billMonth });
@@ -285,27 +358,91 @@ export const fetchBillById = async (locationID:string, billID:string, includeAtt
return([billLocation, bill] as [BillingLocation, Bill]); return([billLocation, bill] as [BillingLocation, Bill]);
}; };
export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number, month:number) => { export const deleteBillById = withUser(async (user:AuthenticatedUser, locationID:string, billID:string, year:number, month:number, _prevState:any, formData?: FormData) => {
const { id: userId } = user; const { id: userId } = user;
const dbClient = await getDbClient(); const dbClient = await getDbClient();
// find a location with the given locationID const deleteInSubsequentMonths = formData?.get('deleteInSubsequentMonths') === 'on';
const post = await dbClient.collection<BillingLocation>("lokacije").updateOne(
{ if (deleteInSubsequentMonths) {
_id: locationID, // find a location with the given locationID // Get the current location and bill to find the bill name and location name
userId // make sure that the location belongs to the user const location = await dbClient.collection<BillingLocation>("lokacije")
}, .findOne({ _id: locationID, userId }, {
{ projection: {
// remove the bill with the given billID "bills.attachment.fileContentsBase64": 0,
$pull: { "bills.barcodeImage": 0
bills: { }
_id: billID });
if (location) {
const bill = location.bills.find(b => b._id === billID);
if (bill) {
// Find all subsequent locations with the same name that have the same bill
const subsequentLocations = await dbClient.collection<BillingLocation>("lokacije")
.find({
userId,
name: location.name,
$or: [
{ "yearMonth.year": { $gt: year } },
{
"yearMonth.year": year,
"yearMonth.month": { $gt: month }
}
],
"bills.name": bill.name
}, { projection: { bills: 0 } })
.toArray();
// Delete the bill from all subsequent locations (by name)
const updateOperations = subsequentLocations.map(loc => ({
updateOne: {
filter: { _id: loc._id, userId },
update: {
$pull: {
bills: { name: bill.name } as Partial<Bill>
}
}
}
}));
// Also delete from current location (by ID for precision)
updateOperations.push({
updateOne: {
filter: { _id: locationID, userId },
update: {
$pull: {
bills: { _id: billID }
}
}
}
});
// Execute all delete operations
if (updateOperations.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").bulkWrite(updateOperations);
} }
} }
}); }
} else {
// Delete only from current location (original behavior)
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationID, // find a location with the given locationID
userId // make sure that the location belongs to the user
},
{
// remove the bill with the given billID
$pull: {
bills: {
_id: billID
}
}
});
}
await gotoHome({year, month}); await gotoHome({year, month});
return(post.modifiedCount); return { message: null };
}); });

View File

@@ -27,6 +27,8 @@ 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(), locationNotes: z.string(),
addToSubsequentMonths: z.boolean().optional(),
updateScope: z.enum(["current", "subsequent", "all"]).optional(),
}) })
// dont include the _id field in the response // dont include the _id field in the response
.omit({ _id: true }); .omit({ _id: true });
@@ -47,6 +49,8 @@ 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'), locationNotes: formData.get('locationNotes'),
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
updateScope: formData.get('updateScope') as "current" | "subsequent" | "all" | undefined,
}); });
// If form validation fails, return errors early. Otherwise, continue... // If form validation fails, return errors early. Otherwise, continue...
@@ -60,6 +64,8 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
const { const {
locationName, locationName,
locationNotes, locationNotes,
addToSubsequentMonths,
updateScope,
} = validatedFields.data; } = validatedFields.data;
// update the bill in the mongodb // update the bill in the mongodb
@@ -68,18 +74,70 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
const { id: userId, email: userEmail } = user; const { id: userId, email: userEmail } = user;
if(locationId) { if(locationId) {
await dbClient.collection<BillingLocation>("lokacije").updateOne( // Get the current location first to find its name
{ const currentLocation = await dbClient.collection<BillingLocation>("lokacije")
_id: locationId, // find a location with the given locationID .findOne({ _id: locationId, userId }, { projection: { bills: 0 } });
userId // make sure the location belongs to the user
}, if (!currentLocation) {
{ return {
$set: { message: "Location not found",
name: locationName, errors: undefined,
notes: locationNotes, };
}
// Handle different update scopes
if (updateScope === "current" || !updateScope) {
// Update only the current location (default behavior)
await dbClient.collection<BillingLocation>("lokacije").updateOne(
{
_id: locationId,
userId
},
{
$set: {
name: locationName,
notes: locationNotes,
}
} }
}); );
} else if (updateScope === "subsequent") {
// Update current and all subsequent months
await dbClient.collection<BillingLocation>("lokacije").updateMany(
{
userId,
name: currentLocation.name,
$or: [
{ "yearMonth.year": { $gt: currentLocation.yearMonth.year } },
{
"yearMonth.year": currentLocation.yearMonth.year,
"yearMonth.month": { $gte: currentLocation.yearMonth.month }
}
]
},
{
$set: {
name: locationName,
notes: locationNotes,
}
}
);
} else if (updateScope === "all") {
// Update all locations with the same name across all months
await dbClient.collection<BillingLocation>("lokacije").updateMany(
{
userId,
name: currentLocation.name
},
{
$set: {
name: locationName,
notes: locationNotes,
}
}
);
}
} else if(yearMonth) { } else if(yearMonth) {
// Always add location to the specified month
await dbClient.collection<BillingLocation>("lokacije").insertOne({ await dbClient.collection<BillingLocation>("lokacije").insertOne({
_id: (new ObjectId()).toHexString(), _id: (new ObjectId()).toHexString(),
userId, userId,
@@ -89,6 +147,78 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
yearMonth: yearMonth, yearMonth: yearMonth,
bills: [], bills: [],
}); });
// If addToSubsequentMonths is enabled, add to all subsequent months
if (addToSubsequentMonths) {
// Find all subsequent months that exist in the database
const subsequentMonths = await dbClient.collection<BillingLocation>("lokacije")
.aggregate([
{
$match: {
userId,
$or: [
{ "yearMonth.year": { $gt: yearMonth.year } },
{
"yearMonth.year": yearMonth.year,
"yearMonth.month": { $gt: yearMonth.month }
}
]
}
},
{
$group: {
_id: {
year: "$yearMonth.year",
month: "$yearMonth.month"
}
}
},
{
$project: {
_id: 0,
year: "$_id.year",
month: "$_id.month"
}
},
{
$sort: {
year: 1,
month: 1
}
}
])
.toArray();
// For each subsequent month, check if location with same name already exists
const locationsToInsert = [];
for (const monthData of subsequentMonths) {
const existingLocation = await dbClient.collection<BillingLocation>("lokacije")
.findOne({
userId,
name: locationName,
"yearMonth.year": monthData.year,
"yearMonth.month": monthData.month
}, { projection: { bills: 0 } });
// Only add if location with same name doesn't already exist in that month
if (!existingLocation) {
locationsToInsert.push({
_id: (new ObjectId()).toHexString(),
userId,
userEmail,
name: locationName,
notes: locationNotes,
yearMonth: { year: monthData.year, month: monthData.month },
bills: [],
});
}
}
// Insert all new locations at once if any
if (locationsToInsert.length > 0) {
await dbClient.collection<BillingLocation>("lokacije").insertMany(locationsToInsert);
}
}
} }
if(yearMonth) await gotoHome(yearMonth); if(yearMonth) await gotoHome(yearMonth);
@@ -226,7 +356,7 @@ export const fetchLocationById = async (locationID:string) => {
return(billLocation); return(billLocation);
}; };
export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string, yearMonth:YearMonth) => { export const deleteLocationById = withUser(async (user:AuthenticatedUser, locationID:string, yearMonth:YearMonth, _prevState:any, formData: FormData) => {
noStore(); noStore();
@@ -234,8 +364,35 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati
const { id: userId } = user; const { id: userId } = user;
// find a location with the given locationID const deleteInSubsequentMonths = formData.get('deleteInSubsequentMonths') === 'on';
const post = await dbClient.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
await gotoHome(yearMonth) if (deleteInSubsequentMonths) {
// Get the location name first to find all locations with the same name
const location = await dbClient.collection<BillingLocation>("lokacije")
.findOne({ _id: locationID, userId }, { projection: { bills: 0 } });
if (location) {
// Delete all locations with the same name in current and subsequent months
await dbClient.collection<BillingLocation>("lokacije").deleteMany({
userId,
name: location.name,
$or: [
{ "yearMonth.year": { $gt: yearMonth.year } },
{
"yearMonth.year": yearMonth.year,
"yearMonth.month": { $gte: yearMonth.month }
}
]
});
}
} else {
// Delete only the specific location (current behavior)
await dbClient.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
}
await gotoHome(yearMonth);
return {
message: null
};
}) })

View File

@@ -1,25 +1,25 @@
import NextAuth, { NextAuthConfig } from 'next-auth'; import NextAuth, { NextAuthConfig } from 'next-auth';
import GoogleProvider from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google';
import LinkedinProvider from 'next-auth/providers/linkedin'; // import LinkedinProvider from 'next-auth/providers/linkedin';
import { Session } from 'next-auth'; import { Session } from 'next-auth';
import { AuthenticatedUser } from './types/next-auth'; import { AuthenticatedUser } from './types/next-auth';
import { defaultLocale } from '../i18n'; import { defaultLocale } from '../i18n';
export const myAuth = () => { export const myAuth = () => {
// Use mock authentication in development when enabled via environment variable
if (process.env.NODE_ENV === 'development' && process.env.USE_MOCK_AUTH === 'true') {
const session: Session = {
user: {
id: process.env.MOCK_USER_ID || "109754742613069927799",
name: process.env.MOCK_USER_NAME || "Nikola Derežić",
},
expires: "123",
};
// Ovo koristim u developmentu return Promise.resolve(session);
}
// const session:Session = { return auth();
// user: {
// id: "109754742613069927799",
// name: "Nikola Derežić",
// },
// expires: "123",
// };
// return(Promise.resolve(session));
return(auth());
} }
export const authConfig: NextAuthConfig = { export const authConfig: NextAuthConfig = {

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { FC, ReactNode } from "react"; import { FC, ReactNode, useState } from "react";
import { Bill, BillingLocation } from "../lib/db-types"; import { Bill, BillingLocation } from "../lib/db-types";
import { useFormState } from "react-dom"; import { useFormState } from "react-dom";
import { Main } from "./Main"; import { Main } from "./Main";
@@ -19,6 +19,7 @@ export const BillDeleteForm:FC<BillDeleteFormProps> = ({ bill, location }) => {
const handleAction = deleteBillById.bind(null, location._id, bill._id, year, month); const handleAction = deleteBillById.bind(null, location._id, bill._id, year, month);
const [ state, dispatch ] = useFormState(handleAction, null); const [ state, dispatch ] = useFormState(handleAction, null);
const t = useTranslations("bill-delete-form"); const t = useTranslations("bill-delete-form");
const [deleteInSubsequentMonths, setDeleteInSubsequentMonths] = useState(false);
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">
@@ -33,9 +34,35 @@ export const BillDeleteForm:FC<BillDeleteFormProps> = ({ bill, location }) => {
}) })
} }
</p> </p>
<div className="form-control">
<label className="label cursor-pointer justify-center gap-4">
<span className="label-text">{t("delete-in-subsequent-months")}</span>
<input
type="checkbox"
name="deleteInSubsequentMonths"
className="toggle toggle-error"
checked={deleteInSubsequentMonths}
onChange={(e) => setDeleteInSubsequentMonths(e.target.checked)}
/>
</label>
</div>
{deleteInSubsequentMonths && (
<div className="border-l-4 border-error bg-error/10 p-4 mt-4 rounded-r max-w-[24rem] mx-auto">
<div className="flex items-start gap-3">
<span className="text-xl flex-shrink-0"></span>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-error break-words">{t("warning-title")}</h3>
<div className="text-sm text-error/80 break-words">{t("warning-message")}</div>
</div>
</div>
</div>
)}
<div className="pt-4 text-center"> <div className="pt-4 text-center">
<button className="btn btn-primary">{t("confirm-button")}</button> <button className="btn btn-primary w-[5.5em]">{t("confirm-button")}</button>
<Link className="btn btn-neutral ml-3" href={`/bill/${location._id}-${bill._id}/edit/`}>{t("cancel-button")}</Link> <Link className="btn btn-neutral w-[5.5em] ml-3" href={`/bill/${location._id}-${bill._id}/edit/`}>{t("cancel-button")}</Link>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -202,6 +202,16 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
))} ))}
</div> </div>
{/* Show toggle only when adding a new bill (not editing) */}
{!bill && (
<div className="form-control mt-4">
<label className="label cursor-pointer">
<span className="label-text">{t("add-to-subsequent-months")}</span>
<input type="checkbox" name="addToSubsequentMonths" className="toggle toggle-primary" />
</label>
</div>
)}
<div className="pt-4"> <div className="pt-4">
<button type="submit" className="btn btn-primary">{t("save-button")}</button> <button type="submit" className="btn btn-primary">{t("save-button")}</button>
<Link className="btn btn-neutral ml-3" href={`/?year=${billYear}&month=${billMonth}`}>{t("cancel-button")}</Link> <Link className="btn btn-neutral ml-3" href={`/?year=${billYear}&month=${billMonth}`}>{t("cancel-button")}</Link>

View File

@@ -32,7 +32,7 @@ export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
return (<MonthLocationList />); return (<MonthLocationList />);
} }
const currentYear = Number(searchParams?.year) || availableYears[0]; const currentYear = Number(searchParams?.year) || new Date().getFullYear();
const locations = await fetchAllLocations(currentYear); const locations = await fetchAllLocations(currentYear);

View File

@@ -1,10 +1,9 @@
"use client"; "use client";
import { FC, ReactNode } from "react"; import { FC, ReactNode, useState } from "react";
import { BillingLocation } from "../lib/db-types"; import { BillingLocation } from "../lib/db-types";
import { deleteLocationById } from "../lib/actions/locationActions"; import { deleteLocationById } from "../lib/actions/locationActions";
import { useFormState } from "react-dom"; import { useFormState } from "react-dom";
import { gotoUrl } from "../lib/actions/navigationActions";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -16,13 +15,9 @@ export interface LocationDeleteFormProps {
export const LocationDeleteForm:FC<LocationDeleteFormProps> = ({ location }) => export const LocationDeleteForm:FC<LocationDeleteFormProps> = ({ location }) =>
{ {
const handleAction = deleteLocationById.bind(null, location._id, location.yearMonth); const handleAction = deleteLocationById.bind(null, location._id, location.yearMonth);
const [ state, dispatch ] = useFormState(handleAction, null); const [ , dispatch ] = useFormState(handleAction, null);
const t = useTranslations("location-delete-form"); const t = useTranslations("location-delete-form");
const [deleteInSubsequentMonths, setDeleteInSubsequentMonths] = useState(false);
const handleCancel = () => {
gotoUrl(`/location/${location._id}/edit/`);
};
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">
@@ -36,6 +31,31 @@ export const LocationDeleteForm:FC<LocationDeleteFormProps> = ({ location }) =>
}) })
} }
</p> </p>
<div className="form-control">
<label className="label cursor-pointer justify-center gap-4">
<span className="label-text">{t("delete-in-subsequent-months")}</span>
<input
type="checkbox"
name="deleteInSubsequentMonths"
className="toggle toggle-error"
checked={deleteInSubsequentMonths}
onChange={(e) => setDeleteInSubsequentMonths(e.target.checked)}
/>
</label>
</div>
{deleteInSubsequentMonths && (
<div className="border-l-4 border-error bg-error/10 p-4 mt-4 rounded-r max-w-[24rem]">
<div className="flex items-start gap-3">
<span className="text-xl flex-shrink-0"></span>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-error break-words">{t("warning-title")}</h3>
<div className="text-sm text-error/80 break-words">{t("warning-message")}</div>
</div>
</div>
</div>
)}
<div className="pt-4 text-center"> <div className="pt-4 text-center">
<button className="btn btn-primary w-[5.5em]">{t("confirm-button")}</button> <button className="btn btn-primary w-[5.5em]">{t("confirm-button")}</button>
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/location/${location._id}/edit/`}>{t("cancel-button")}</Link> <Link className="btn btn-neutral w-[5.5em] ml-3" href={`/location/${location._id}/edit/`}>{t("cancel-button")}</Link>

View File

@@ -60,6 +60,36 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth
))} ))}
</div> </div>
{/* Show different options for add vs edit operations */}
{!location ? (
<div className="form-control">
<label className="label cursor-pointer">
<span className="label-text">{t("add-to-subsequent-months")}</span>
<input type="checkbox" name="addToSubsequentMonths" className="toggle toggle-primary" />
</label>
</div>
) : (
<div className="form-control">
<div className="label">
<span className="label-text font-medium">{t("update-scope")}</span>
</div>
<div className="flex flex-col gap-1 ml-4">
<label className="label cursor-pointer justify-start gap-3 py-1">
<input type="radio" name="updateScope" value="current" className="radio radio-primary" defaultChecked />
<span className="label-text">{t("update-current-month")}</span>
</label>
<label className="label cursor-pointer justify-start gap-3 py-1">
<input type="radio" name="updateScope" value="subsequent" className="radio radio-primary" />
<span className="label-text">{t("update-subsequent-months")}</span>
</label>
<label className="label cursor-pointer justify-start gap-3 py-1">
<input type="radio" name="updateScope" value="all" className="radio radio-primary" />
<span className="label-text">{t("update-all-months")}</span>
</label>
</div>
</div>
)}
<div id="status-error" aria-live="polite" aria-atomic="true"> <div id="status-error" aria-live="polite" aria-atomic="true">
{ {
state.message && state.message &&

View File

@@ -62,9 +62,12 @@
} }
}, },
"bill-delete-form": { "bill-delete-form": {
"text": "Please confirm deletion of bill <strong>{bill_name}</strong> at <strong>{location_name}</strong>.", "text": "Please confirm deletion of bill \"<strong>{bill_name}</strong>\" at \"<strong>{location_name}</strong>\".",
"cancel-button": "Cancel", "cancel-button": "Cancel",
"confirm-button": "Confirm" "confirm-button": "Confirm",
"delete-in-subsequent-months": "Also delete in all subsequent months",
"warning-title": "Warning",
"warning-message": "This operation cannot be undone and will delete the bill in all future months!"
}, },
"bill-edit-form": { "bill-edit-form": {
"bill-name-placeholder": "Bill name", "bill-name-placeholder": "Bill name",
@@ -77,6 +80,7 @@
"save-button": "Save", "save-button": "Save",
"cancel-button": "Cancel", "cancel-button": "Cancel",
"delete-tooltip": "Delete bill", "delete-tooltip": "Delete bill",
"add-to-subsequent-months": "Add to all subsequent months",
"validation": { "validation": {
"bill-name-required": "Bill name is required", "bill-name-required": "Bill name is required",
"payed-amount-required": "Payed amount is required", "payed-amount-required": "Payed amount is required",
@@ -88,9 +92,12 @@
"back-button": "Back" "back-button": "Back"
}, },
"location-delete-form": { "location-delete-form": {
"text": "Please confirm deletion of realestate <strong>{name}</strong>””.", "text": "Please confirm deletion of realestate \"<strong>{name}</strong>\".",
"cancel-button": "Cancel", "cancel-button": "Cancel",
"confirm-button": "Confirm" "confirm-button": "Confirm",
"delete-in-subsequent-months": "Also delete in all subsequent months",
"warning-title": "Warning",
"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-placeholder": "Realestate name",
@@ -98,6 +105,11 @@
"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",
"update-scope": "Update scope:",
"update-current-month": "current month only",
"update-subsequent-months": "current and all future months",
"update-all-months": "all months",
"validation": { "validation": {
"location-name-required": "Relaestate name is required" "location-name-required": "Relaestate name is required"
} }

View File

@@ -62,9 +62,12 @@
} }
}, },
"bill-delete-form": { "bill-delete-form": {
"text": "Molim potvrdi brisanje računa <strong>{bill_name}</strong> koji pripada nekretnini <strong>{location_name}</strong>.", "text": "Molim potvrdi brisanje računa \"<strong>{bill_name}</strong>\" koji pripada nekretnini \"<strong>{location_name}</strong>\".",
"cancel-button": "Odustani", "cancel-button": "Odustani",
"confirm-button": "Potvrdi" "confirm-button": "Potvrdi",
"delete-in-subsequent-months": "Također obriši i u svim mjesecima koji slijede",
"warning-title": "Upozorenje",
"warning-message": "Ova operacija je nepovratna i obrisat će račun u svim mjesecima koji slijede!"
}, },
"bill-edit-form": { "bill-edit-form": {
"bill-name-placeholder": "Ime računa", "bill-name-placeholder": "Ime računa",
@@ -77,6 +80,7 @@
"save-button": "Spremi", "save-button": "Spremi",
"cancel-button": "Odbaci", "cancel-button": "Odbaci",
"delete-tooltip": "Obriši račun", "delete-tooltip": "Obriši račun",
"add-to-subsequent-months": "Dodaj u sve mjesece koji slijede",
"validation": { "validation": {
"bill-name-required": "Ime računa je obavezno", "bill-name-required": "Ime računa je obavezno",
"not-a-number": "Vrijednost mora biti brojka", "not-a-number": "Vrijednost mora biti brojka",
@@ -87,9 +91,12 @@
"back-button": "Nazad" "back-button": "Nazad"
}, },
"location-delete-form": { "location-delete-form": {
"text": "Molim potvrdi brisanje nekretnine <strong>{name}</strong>””.", "text": "Molim potvrdi brisanje nekretnine \"<strong>{name}</strong>\".",
"cancel-button": "Odustani", "cancel-button": "Odustani",
"confirm-button": "Potvrdi" "confirm-button": "Potvrdi",
"delete-in-subsequent-months": "Također obriši i u svim mjesecima koji slijede",
"warning-title": "Upozorenje",
"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-placeholder": "Ime nekretnine",
@@ -97,6 +104,11 @@
"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",
"update-scope": "Opseg ažuriranja:",
"update-current-month": "samo trenutni mjesec",
"update-subsequent-months": "trenutni i svi budući mjeseci",
"update-all-months": "svi mjeseci",
"validation": { "validation": {
"location-name-required": "Ime nekretnine je obavezno" "location-name-required": "Ime nekretnine je obavezno"
} }