Merge branch 'release/1.36.0'
This commit is contained in:
5
.vscode/launch.json
vendored
5
.vscode/launch.json
vendored
@@ -9,6 +9,11 @@
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"USE_MOCK_AUTH": "true",
|
||||
"MOCK_USER_ID": "109754742613069927799",
|
||||
"MOCK_USER_NAME": "Nikola Derežić"
|
||||
},
|
||||
"runtimeArgs": [
|
||||
"run", // this is `run` from `npm run`
|
||||
"dev" // this is `dev` from `npm run dev`
|
||||
|
||||
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal 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
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
@@ -27,11 +27,11 @@ RUN npm run build
|
||||
#-----------------------------------------
|
||||
# STAGE 3: Run the Next.js server
|
||||
#-----------------------------------------
|
||||
FROM base as production
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
@@ -54,7 +54,7 @@ USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV PORT=3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
|
||||
@@ -28,6 +28,7 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
|
||||
_id: z.string(),
|
||||
billName: z.coerce.string().min(1, t("bill-name-required")),
|
||||
billNotes: z.string(),
|
||||
addToSubsequentMonths: z.boolean().optional(),
|
||||
payedAmount: z.string().nullable().transform((val, ctx) => {
|
||||
|
||||
if(!val || val === '') {
|
||||
@@ -123,6 +124,7 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI
|
||||
.safeParse({
|
||||
billName: formData.get('billName'),
|
||||
billNotes: formData.get('billNotes'),
|
||||
addToSubsequentMonths: formData.get('addToSubsequentMonths') === 'on',
|
||||
payedAmount: formData.get('payedAmount'),
|
||||
});
|
||||
|
||||
@@ -138,6 +140,7 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI
|
||||
const {
|
||||
billName,
|
||||
billNotes,
|
||||
addToSubsequentMonths,
|
||||
payedAmount,
|
||||
} = validatedFields.data;
|
||||
|
||||
@@ -183,25 +186,95 @@ export const updateOrAddBill = withUser(async (user:AuthenticatedUser, locationI
|
||||
]
|
||||
});
|
||||
} else {
|
||||
// find a location with the given locationID
|
||||
const post = await dbClient.collection<BillingLocation>("lokacije").updateOne(
|
||||
// Create new bill - add to current location first
|
||||
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
|
||||
userId // make sure that the location belongs to the user
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
bills: {
|
||||
_id: (new ObjectId()).toHexString(),
|
||||
name: billName,
|
||||
paid: billPaid,
|
||||
attachment: billAttachment,
|
||||
notes: billNotes,
|
||||
payedAmount,
|
||||
barcodeImage,
|
||||
}
|
||||
bills: newBill
|
||||
}
|
||||
});
|
||||
|
||||
// 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 ) {
|
||||
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]);
|
||||
};
|
||||
|
||||
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 dbClient = await getDbClient();
|
||||
|
||||
const deleteInSubsequentMonths = formData?.get('deleteInSubsequentMonths') === 'on';
|
||||
|
||||
// find a location with the given locationID
|
||||
const post = 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
|
||||
if (deleteInSubsequentMonths) {
|
||||
// Get the current location and bill to find the bill name and location name
|
||||
const location = await dbClient.collection<BillingLocation>("lokacije")
|
||||
.findOne({ _id: locationID, userId }, {
|
||||
projection: {
|
||||
"bills.attachment.fileContentsBase64": 0,
|
||||
"bills.barcodeImage": 0
|
||||
}
|
||||
});
|
||||
|
||||
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});
|
||||
return(post.modifiedCount);
|
||||
return { message: null };
|
||||
});
|
||||
@@ -27,6 +27,8 @@ const FormSchema = (t:IntlTemplateFn) => z.object({
|
||||
_id: z.string(),
|
||||
locationName: z.coerce.string().min(1, t("location-name-required")),
|
||||
locationNotes: z.string(),
|
||||
addToSubsequentMonths: z.boolean().optional(),
|
||||
updateScope: z.enum(["current", "subsequent", "all"]).optional(),
|
||||
})
|
||||
// dont include the _id field in the response
|
||||
.omit({ _id: true });
|
||||
@@ -47,6 +49,8 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
||||
const validatedFields = FormSchema(t).safeParse({
|
||||
locationName: formData.get('locationName'),
|
||||
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...
|
||||
@@ -60,6 +64,8 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
||||
const {
|
||||
locationName,
|
||||
locationNotes,
|
||||
addToSubsequentMonths,
|
||||
updateScope,
|
||||
} = validatedFields.data;
|
||||
|
||||
// update the bill in the mongodb
|
||||
@@ -68,18 +74,70 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
||||
const { id: userId, email: userEmail } = user;
|
||||
|
||||
if(locationId) {
|
||||
await dbClient.collection<BillingLocation>("lokacije").updateOne(
|
||||
{
|
||||
_id: locationId, // find a location with the given locationID
|
||||
userId // make sure the location belongs to the user
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
name: locationName,
|
||||
notes: locationNotes,
|
||||
// Get the current location first to find its name
|
||||
const currentLocation = await dbClient.collection<BillingLocation>("lokacije")
|
||||
.findOne({ _id: locationId, userId }, { projection: { bills: 0 } });
|
||||
|
||||
if (!currentLocation) {
|
||||
return {
|
||||
message: "Location not found",
|
||||
errors: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Always add location to the specified month
|
||||
await dbClient.collection<BillingLocation>("lokacije").insertOne({
|
||||
_id: (new ObjectId()).toHexString(),
|
||||
userId,
|
||||
@@ -89,6 +147,78 @@ export const updateOrAddLocation = withUser(async (user:AuthenticatedUser, locat
|
||||
yearMonth: yearMonth,
|
||||
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);
|
||||
@@ -226,7 +356,7 @@ export const fetchLocationById = async (locationID:string) => {
|
||||
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();
|
||||
|
||||
@@ -234,8 +364,35 @@ export const deleteLocationById = withUser(async (user:AuthenticatedUser, locati
|
||||
|
||||
const { id: userId } = user;
|
||||
|
||||
// find a location with the given locationID
|
||||
const post = await dbClient.collection<BillingLocation>("lokacije").deleteOne({ _id: locationID, userId });
|
||||
const deleteInSubsequentMonths = formData.get('deleteInSubsequentMonths') === 'on';
|
||||
|
||||
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)
|
||||
await gotoHome(yearMonth);
|
||||
|
||||
return {
|
||||
message: null
|
||||
};
|
||||
})
|
||||
@@ -1,25 +1,25 @@
|
||||
import NextAuth, { NextAuthConfig } from 'next-auth';
|
||||
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 { AuthenticatedUser } from './types/next-auth';
|
||||
import { defaultLocale } from '../i18n';
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
return Promise.resolve(session);
|
||||
}
|
||||
|
||||
// Ovo koristim u developmentu
|
||||
|
||||
// const session:Session = {
|
||||
// user: {
|
||||
// id: "109754742613069927799",
|
||||
// name: "Nikola Derežić",
|
||||
// },
|
||||
// expires: "123",
|
||||
// };
|
||||
|
||||
// return(Promise.resolve(session));
|
||||
|
||||
return(auth());
|
||||
return auth();
|
||||
}
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import { FC, ReactNode, useState } from "react";
|
||||
import { Bill, BillingLocation } from "../lib/db-types";
|
||||
import { useFormState } from "react-dom";
|
||||
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 [ state, dispatch ] = useFormState(handleAction, null);
|
||||
const t = useTranslations("bill-delete-form");
|
||||
const [deleteInSubsequentMonths, setDeleteInSubsequentMonths] = useState(false);
|
||||
|
||||
return(
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<button className="btn btn-primary">{t("confirm-button")}</button>
|
||||
<Link className="btn btn-neutral ml-3" href={`/bill/${location._id}-${bill._id}/edit/`}>{t("cancel-button")}</Link>
|
||||
<button className="btn btn-primary w-[5.5em]">{t("confirm-button")}</button>
|
||||
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/bill/${location._id}-${bill._id}/edit/`}>{t("cancel-button")}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -202,6 +202,16 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
|
||||
))}
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
|
||||
return (<MonthLocationList />);
|
||||
}
|
||||
|
||||
const currentYear = Number(searchParams?.year) || availableYears[0];
|
||||
const currentYear = Number(searchParams?.year) || new Date().getFullYear();
|
||||
|
||||
const locations = await fetchAllLocations(currentYear);
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import { FC, ReactNode, useState } from "react";
|
||||
import { BillingLocation } from "../lib/db-types";
|
||||
import { deleteLocationById } from "../lib/actions/locationActions";
|
||||
import { useFormState } from "react-dom";
|
||||
import { gotoUrl } from "../lib/actions/navigationActions";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -16,13 +15,9 @@ export interface LocationDeleteFormProps {
|
||||
export const LocationDeleteForm:FC<LocationDeleteFormProps> = ({ location }) =>
|
||||
{
|
||||
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 handleCancel = () => {
|
||||
gotoUrl(`/location/${location._id}/edit/`);
|
||||
};
|
||||
const [deleteInSubsequentMonths, setDeleteInSubsequentMonths] = useState(false);
|
||||
|
||||
return(
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -60,6 +60,36 @@ export const LocationEditForm:FC<LocationEditFormProps> = ({ location, yearMonth
|
||||
))}
|
||||
</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">
|
||||
{
|
||||
state.message &&
|
||||
|
||||
@@ -62,9 +62,12 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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-name-placeholder": "Bill name",
|
||||
@@ -77,6 +80,7 @@
|
||||
"save-button": "Save",
|
||||
"cancel-button": "Cancel",
|
||||
"delete-tooltip": "Delete bill",
|
||||
"add-to-subsequent-months": "Add to all subsequent months",
|
||||
"validation": {
|
||||
"bill-name-required": "Bill name is required",
|
||||
"payed-amount-required": "Payed amount is required",
|
||||
@@ -88,9 +92,12 @@
|
||||
"back-button": "Back"
|
||||
},
|
||||
"location-delete-form": {
|
||||
"text": "Please confirm deletion of realestate “<strong>{name}</strong>””.",
|
||||
"text": "Please confirm deletion of realestate \"<strong>{name}</strong>\".",
|
||||
"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-name-placeholder": "Realestate name",
|
||||
@@ -98,6 +105,11 @@
|
||||
"save-button": "Save",
|
||||
"cancel-button": "Cancel",
|
||||
"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": {
|
||||
"location-name-required": "Relaestate name is required"
|
||||
}
|
||||
|
||||
@@ -62,9 +62,12 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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-name-placeholder": "Ime računa",
|
||||
@@ -77,6 +80,7 @@
|
||||
"save-button": "Spremi",
|
||||
"cancel-button": "Odbaci",
|
||||
"delete-tooltip": "Obriši račun",
|
||||
"add-to-subsequent-months": "Dodaj u sve mjesece koji slijede",
|
||||
"validation": {
|
||||
"bill-name-required": "Ime računa je obavezno",
|
||||
"not-a-number": "Vrijednost mora biti brojka",
|
||||
@@ -87,9 +91,12 @@
|
||||
"back-button": "Nazad"
|
||||
},
|
||||
"location-delete-form": {
|
||||
"text": "Molim potvrdi brisanje nekretnine “<strong>{name}</strong>””.",
|
||||
"text": "Molim potvrdi brisanje nekretnine \"<strong>{name}</strong>\".",
|
||||
"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-name-placeholder": "Ime nekretnine",
|
||||
@@ -97,6 +104,11 @@
|
||||
"save-button": "Spremi",
|
||||
"cancel-button": "Odbaci",
|
||||
"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": {
|
||||
"location-name-required": "Ime nekretnine je obavezno"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user