Compare commits

26 Commits

Author SHA1 Message Date
b79b354fdc Merge branch 'hotfix/2.21.3' into develop 2026-01-09 19:42:14 +01:00
a428a77eb1 (ver) web-app: version bump 2026-01-09 19:42:06 +01:00
16eaa5bfa1 (bugfix) Fix hosts file configuration by using extra_hosts at runtime
Docker overwrites /etc/hosts at container runtime, so copying it during
build (COPY command) or mounting it as volume doesn't work reliably.
Moved to extra_hosts in docker-compose files for both standalone and
swarm deployments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 19:41:33 +01:00
2cff1ec18b Merge branch 'release/2.21.2'
All checks were successful
Build and Push Docker Image / build_web_app__check_image_version (push) Successful in 14s
Build and Push Docker Image / build_web_app (push) Successful in 5m57s
2026-01-09 19:18:29 +01:00
3366e85950 Merge branch 'feature/hotfix-hosts-file' into develop 2026-01-09 19:17:58 +01:00
5cb0210668 (ver) web-app: version bump 2026-01-09 19:17:53 +01:00
2ddff15497 (bugfix) Dockerfile: hosts file was copied in wrong step 2026-01-09 19:17:31 +01:00
0492469ed6 (refactor) dockerfile: modified default port 2026-01-09 19:17:01 +01:00
0ecae68c63 Merge branch 'release/2.21.1'
All checks were successful
Build and Push Docker Image / build_web_app__check_image_version (push) Successful in 15s
Build and Push Docker Image / build_web_app (push) Successful in 6m6s
2026-01-09 18:52:07 +01:00
42d1f6276a Merge branch 'feature/fixing-deploy-path' into develop 2026-01-09 18:51:48 +01:00
d17efdc156 (ver) web-app: version bump 2026-01-09 18:51:40 +01:00
de97ce744f (refactor) Move hosts file copy from volume mount to Dockerfile
Bake the custom hosts file into the Docker image instead of mounting it as a volume. This simplifies deployment configuration and makes the image more self-contained.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:51:05 +01:00
c9cc32b811 (config) Convert docker-compose paths to absolute for Portainer compatibility
Changed relative volume paths to absolute paths and updated image reference to use full registry path. This enables deployment via Portainer which doesn't have working directory context.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:49:00 +01:00
bc5f5e051f Merge branch 'release/2.21.0'
All checks were successful
Build and Push Docker Image / build_web_app__check_image_version (push) Successful in 13s
Build and Push Docker Image / build_web_app (push) Successful in 6m22s
2026-01-09 18:38:11 +01:00
528c433fce (ver) web-app: version bump 2026-01-09 18:34:59 +01:00
50238b4e90 Merge branch 'feature/gitea-workflow' into develop 2026-01-09 18:33:09 +01:00
5773156222 Add Gitea CI/CD workflows for automated Docker builds
Implements automated Docker image building and publishing to registry:

- build.yml: Main workflow that builds and pushes Docker images to registry
  - Triggers on push to master branch
  - Only builds when image with current version doesn't exist
  - Uses Docker BuildKit with layer caching for faster builds
  - Tags images with both version number and 'latest'

- check_image_version.yml: Reusable workflow to verify image existence
  - Reads version from package.json
  - Uses lightweight manifest inspection (no image download)
  - Returns image_exists and version as outputs

- check_package_version.yml: Reusable workflow to detect version changes
  - Compares version between commits
  - Handles edge cases (first commit, missing package.json)
  - Includes validation for version extraction failures

All workflows include proper error handling and clear logging.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:32:48 +01:00
a3ec20544c (refactor) Move generateShareId to locationActions and apply to LocationCard
- Moved generateShareId from shareChecksum.ts to locationActions.ts as a server action
- Updated LocationCard to use shareID with checksum for proof of payment download link
- Replaced Link with AsyncLink to handle async shareID generation
- Commented out debug console.log in Pdf417Barcode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:18:17 +01:00
e318523887 (bugfix) Fix proof of payment download URL to use shareID with checksum
Fixed bug where proof of payment download links used raw locationID instead
of shareID (locationID + checksum), causing link validation to fail. Added
AsyncLink component to handle async shareID generation gracefully.

Changes:
- BillEditForm: Generate shareID using generateShareId server action
- BillEditForm: Use AsyncLink to prevent broken links during async load
- AsyncLink: New reusable component for links that need async data
- Updated download URL from locationID-billID to shareID-billID format

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:16:53 +01:00
37f617683e (refactor + bugfix) Improve data structure and handle empty database edge cases
Refactored months data structure from object to array for better performance
and cleaner iteration. Fixed crash when availableYears array is empty by
adding proper guards and fallback to current year.

Changes:
- MonthLocationList: Changed months prop from object to array type
- HomePage: Refactored reduce logic to build array instead of object
- HomePage: Added empty database handling in year selection logic
- HomePage: Added early returns for invalid year params in empty DB

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:36:10 +01:00
1ca55ae820 Merge branch 'master' into develop 2026-01-05 16:08:17 +01:00
0e3e41e064 2.20.3 2026-01-05 16:03:44 +01:00
488c771a09 (fix) ParamsYearInvalidMessage moved to client-side component so that it can use useEffect 2026-01-05 16:03:38 +01:00
0b8c8ae6c4 Merge branch 'master' into develop 2026-01-05 15:57:31 +01:00
1076797c89 (bugfix) HomePage: if current year was not found in DB containing data the app would crash.
This would happen at in January after user hasn't touched the app since the previous year and he did not create any records in the next (now current) year
2026-01-05 15:57:10 +01:00
Knee Cola
a54771e479 Merge branch 'hotfix/2.20.1' 2025-12-24 23:55:58 +01:00
17 changed files with 421 additions and 100 deletions

View File

@@ -0,0 +1,58 @@
name: Build and Push Docker Image
on:
push:
branches:
- master
jobs:
# Verifies if Docker image with current version already exists in registry
# This prevents rebuilding the same version but allows pulls and version changes
# to always trigger new builds. Uses lightweight manifest inspect (no download)
build_web_app__check_image_version:
uses: ./.gitea/workflows/check_image_version.yml
with:
workspacePath: './web-app'
imageName: 'utility-bills-tracker'
registryUrl: 'registry.budakova.org'
registryUsername: ${{ vars.PROFILE_REGISTRY_USERNAME }}
registryNamespace: 'knee-cola'
secrets:
registryToken: ${{ secrets.PROFILE_REGISTRY_TOKEN }}
# Builds and pushes Docker image to registry if:
# - Image with current version doesn't exist in registry
# This prevents rebuilding the same version unnecessarily
build_web_app:
needs: [build_web_app__check_image_version]
if: needs.build_web_app__check_image_version.outputs.image_exists == 'false'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
# Gitea automatically provides these secrets:
# - `vars.REGISTRY_USERNAME` - defined as action variable in **repo settings**
# - `secrets.REGISTRY_TOKEN` - defined as action secret in **repo settings**
# created in user settings as personal access token with `write:packages` scope
# - `vars.PROFILE_REGISTRY_USERNAME` - defined as action variable in **profile settings**
# - `secrets.PROFILE_REGISTRY_TOKEN` - defined as action secret in **profile settings**
# created in user settings as personal access token with `write:packages` scope
run: |
echo "${{ secrets.PROFILE_REGISTRY_TOKEN }}" | docker login registry.budakova.org -u "${{ vars.PROFILE_REGISTRY_USERNAME }}" --password-stdin
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./web-app
push: true
tags: |
registry.budakova.org/knee-cola/utility-bills-tracker:${{ needs.build_web_app__check_image_version.outputs.version }}
registry.budakova.org/knee-cola/utility-bills-tracker:latest
cache-from: type=registry,ref=registry.budakova.org/knee-cola/utility-bills-tracker:buildcache
cache-to: type=registry,ref=registry.budakova.org/knee-cola/utility-bills-tracker:buildcache,mode=max

View File

@@ -0,0 +1,94 @@
name: Check Image Version
on:
workflow_call:
inputs:
workspacePath:
description: 'Path relative to repo root where package.json is located'
required: false
type: string
default: '.'
imageName:
description: 'Docker image name without registry FQDN or username'
required: true
type: string
registryUrl:
description: 'Docker registry URL (e.g., registry.budakova.org)'
required: false
type: string
default: 'registry.budakova.org'
registryUsername:
description: 'Docker registry username'
required: true
type: string
registryNamespace:
description: 'Docker registry namespace/organization (e.g., knee-cola)'
required: true
type: string
secrets:
registryToken:
description: 'Registry access token'
required: true
outputs:
image_exists:
description: 'Whether the image exists in the registry'
value: ${{ jobs.check_image.outputs.image_exists }}
version:
description: 'Current version from package.json'
value: ${{ jobs.check_image.outputs.version }}
jobs:
check_image:
runs-on: ubuntu-latest
outputs:
image_exists: ${{ steps.manifest-check.outputs.image_exists }}
version: ${{ steps.version-read.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Read version from package.json
id: version-read
run: |
WORKSPACE_PATH="${{ inputs.workspacePath }}"
# Clean up path - remove trailing slash if present
WORKSPACE_PATH="${WORKSPACE_PATH%/}"
# Handle root directory case
if [ "$WORKSPACE_PATH" = "." ]; then
PACKAGE_JSON_PATH="package.json"
else
PACKAGE_JSON_PATH="${WORKSPACE_PATH}/package.json"
fi
VERSION=$(node -p "try { require('./${PACKAGE_JSON_PATH}').version } catch(e) { console.error('Error reading version:', e.message); process.exit(1) }") || {
echo "Error: Failed to read version from ${PACKAGE_JSON_PATH}"
exit 1
}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Checking for image version: $VERSION"
- name: Login to Registry
run: |
echo "${{ secrets.registryToken }}" | docker login ${{ inputs.registryUrl }} -u "${{ inputs.registryUsername }}" --password-stdin
- name: Check if image exists in registry
id: manifest-check
run: |
VERSION=${{ steps.version-read.outputs.version }}
IMAGE="${{ inputs.registryUrl }}/${{ inputs.registryNamespace }}/${{ inputs.imageName }}:${VERSION}"
echo "Checking manifest for image: $IMAGE"
if docker manifest inspect "$IMAGE" &>/dev/null; then
echo "Image exists in registry"
echo "image_exists=true" >> $GITHUB_OUTPUT
else
echo "Image does not exist in registry"
echo "image_exists=false" >> $GITHUB_OUTPUT
fi
- name: Summary
run: |
echo "Version: ${{ steps.version-read.outputs.version }}"
echo "Image exists: ${{ steps.manifest-check.outputs.image_exists }}"

View File

@@ -0,0 +1,82 @@
name: Check Package Version
on:
workflow_call:
inputs:
workspacePath:
description: 'Path relative to repo root where package.json is located'
required: false
type: string
default: '.'
outputs:
version_changed:
description: 'Whether the version changed from the previous commit'
value: ${{ jobs.check_version.outputs.version_changed }}
version:
description: 'Current version from package.json'
value: ${{ jobs.check_version.outputs.version }}
jobs:
check_version:
runs-on: ubuntu-latest
outputs:
version_changed: ${{ steps.version-check.outputs.version_changed }}
version: ${{ steps.version-check.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if version changed
id: version-check
run: |
WORKSPACE_PATH="${{ inputs.workspacePath }}"
# Clean up path - remove trailing slash if present
WORKSPACE_PATH="${WORKSPACE_PATH%/}"
# Handle root directory case
if [ "$WORKSPACE_PATH" = "." ]; then
PACKAGE_JSON_PATH="package.json"
else
PACKAGE_JSON_PATH="${WORKSPACE_PATH}/package.json"
fi
# Get current version
CURRENT_VERSION=$(node -p "require('./${PACKAGE_JSON_PATH}').version")
echo "Current version: $CURRENT_VERSION"
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# Check if HEAD~1 exists (handle first commit)
if ! git rev-parse HEAD~1 &>/dev/null; then
echo "First commit detected, running workflow"
echo "version_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Check if package.json exists in previous commit
if ! git show HEAD~1:${PACKAGE_JSON_PATH} &>/dev/null; then
echo "package.json doesn't exist in previous commit"
echo "version_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Extract previous version using grep/sed (safer than node for old file)
PREVIOUS_VERSION=$(git show HEAD~1:${PACKAGE_JSON_PATH} | grep '"version"' | head -1 | sed -E 's/.*"version"\s*:\s*"([^"]+)".*/\1/')
echo "Previous version: $PREVIOUS_VERSION"
# Validate extraction
if [ -z "$PREVIOUS_VERSION" ]; then
echo "Warning: Could not extract previous version, assuming changed"
echo "version_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Compare versions
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
echo "Version changed: $PREVIOUS_VERSION -> $CURRENT_VERSION"
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "Version unchanged: $CURRENT_VERSION"
echo "version_changed=false" >> $GITHUB_OUTPUT
fi

View File

@@ -13,12 +13,15 @@ networks:
services:
web-app:
image: utility-bills-tracker:${IMAGE_VERSION}
image: registry.budakova.org/knee-cola/utility-bills-tracker:${IMAGE_VERSION}
networks:
- traefik-network
- util-bills-mongo-network
volumes:
- ./web-app/etc/hosts/:/etc/hosts
# NextJS will do name resolution for `rezije.app` and will crash if it
# resolves to an IP adress different from the one assigned to the Docker container.
# This will prevent that from happening.
extra_hosts:
- "rezije.app:0.0.0.0"
environment:
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com
@@ -28,7 +31,7 @@ services:
LINKEDIN_SECRET: ugf61aJ2iyErLK40
HOSTNAME: rezije.app # IP address at which the server will be listening (0.0.0.0 = listen on all addresses)
NEXTAUTH_URL: https://rezije.app # URL next-auth will use while redirecting user during authentication (if not set - will use HOSTNAME)
PORT: ${PORT:-80}
PORT: ${PORT:-3000}
# Share link security
SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5
SHARE_TTL_INITIAL_DAYS: 10
@@ -43,7 +46,7 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network=traefik-network # mreže preko koje ide komunikacija sa Traefikom
- traefik.http.services.web-app.loadbalancer.server.port=80
- traefik.http.services.web-app.loadbalancer.server.port=3000
- traefik.http.routers.web-app.entrypoints=http
- traefik.http.routers.web-app.rule=Host(`${FQDN:-rezije.app}`)
@@ -55,8 +58,8 @@ services:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
volumes:
- ./mongo-volume:/data/db
- ./mongo-backup:/backup
- /home/knee-cola/docker/evidencija-rezija/mongo-volume:/data/db
- /home/knee-cola/docker/evidencija-rezija/mongo-backup:/backup
networks:
- util-bills-mongo-network
mongo-express:

View File

@@ -17,8 +17,11 @@ services:
networks:
- traefik-network
- util-bills-mongo-network
volumes:
- ./web-app/etc/hosts/:/etc/hosts
# NextJS will do name resolution for `rezije.app` and will crash if it
# resolves to an IP adress different from the one assigned to the Docker container.
# This will prevent that from happening.
extra_hosts:
- "rezije.app:0.0.0.0"
environment:
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com

View File

@@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions';
import { unstable_noStore, revalidatePath } from 'next/cache';
import { IntlTemplateFn } from '@/app/i18n';
import { getTranslations, getLocale } from "next-intl/server";
import { generateShareId, extractShareId, validateShareChecksum } from '../shareChecksum';
import { extractShareId, validateShareChecksum, generateShareChecksum } from '../shareChecksum';
import { validatePdfFile } from '../validators/pdfValidator';
import { checkUploadRateLimit } from '../uploadRateLimiter';
@@ -765,6 +765,18 @@ export const uploadUtilBillsProofOfPayment = async (
}
}
/**
* Generate combined location ID with checksum appended
* @param locationId - The MongoDB location ID (24 chars)
* @returns Combined ID: locationId + checksum (40 chars total)
*/
export async function generateShareId(locationId: string): Promise<string> {
const checksum = generateShareChecksum(locationId);
return locationId + checksum;
}
/**
* Generate/activate share link for location
* Called when owner clicks "Share" button
@@ -800,7 +812,7 @@ export const generateShareLink = withUser(
);
// Generate combined share ID (locationId + checksum)
const shareId = generateShareId(locationId);
const shareId = await generateShareId(locationId);
// Build share URL
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';

View File

@@ -56,16 +56,6 @@ export function validateShareChecksum(
}
}
/**
* Generate combined location ID with checksum appended
* @param locationId - The MongoDB location ID (24 chars)
* @returns Combined ID: locationId + checksum (40 chars total)
*/
export function generateShareId(locationId: string): string {
const checksum = generateShareChecksum(locationId);
return locationId + checksum;
}
/**
* Extract location ID and checksum from combined share ID
* @param shareId - Combined ID (locationId + checksum)

View File

@@ -0,0 +1,13 @@
import React from "react";
import Link from "next/link";
/** Link component that can be disabled */
export const AsyncLink: React.FC<{ href: string; children: React.ReactNode, target?: string, className?: string, disabled?: boolean }> = ({ href, children, target, className, disabled }) =>
disabled ? <span className={className}>{children}</span> :
<Link
href={href}
target={target}
className={className}
>
{children}
</Link>

View File

@@ -2,7 +2,7 @@
import { DocumentIcon, TicketIcon, TrashIcon } from "@heroicons/react/24/outline";
import { Bill, BilledTo, BillingLocation } from "../lib/db-types";
import React, { FC, useEffect } from "react";
import React, { FC, useEffect, useState } from "react";
import { useFormState } from "react-dom";
import { updateOrAddBill } from "../lib/actions/billActions";
import Link from "next/link";
@@ -11,6 +11,8 @@ import { DecodeResult, findDecodePdf417 } from "../lib/pdf/barcodeDecoderWasm";
import { useLocale, useTranslations } from "next-intl";
import { InfoBox } from "./InfoBox";
import { Pdf417Barcode } from "./Pdf417Barcode";
import { generateShareId } from "../lib/actions/locationActions";
import { AsyncLink } from "./AsyncLink";
// Next.js does not encode an utf-8 file name correctly when sending a form with a file attachment
// This is a workaround for that
@@ -35,6 +37,20 @@ export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
const { yearMonth: { year: billYear, month: billMonth }, _id: locationID, proofOfPaymentType } = location;
/**
* Share ID for viewing-only links (locationID + checksum)
* Note: This is different from the share button which calls `generateShareLink`
* to activate sharing and set TTL in the database
*/
const [shareID, setShareID] = useState<string | null>(null);
useEffect(() => {
// share ID can be generated server-side since it requires a secret key
// which we don't want to expose to the client
(async () => setShareID(await generateShareId(locationID)))();
}, [locationID]);
const initialState = { message: null, errors: {} };
const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth);
@@ -238,14 +254,15 @@ export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
// -> don't show anything
proofOfPayment.fileName ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/per-bill/${locationID}-${billID}/`}
<AsyncLink
disabled={!shareID}
href={`/share/proof-of-payment/per-bill/${shareID}-${billID}/`}
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 mt-[-.2em] text-teal-500" />
{decodeURIComponent(proofOfPayment.fileName)}
</Link>
</AsyncLink>
</div>
) : null
}

View File

@@ -1,9 +1,9 @@
import { fetchAllLocations } from '@/app/lib/actions/locationActions';
import { fetchAvailableYears } from '@/app/lib/actions/monthActions';
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
import { BillingLocation, YearMonth } from '@/app/lib/db-types';
import { FC } from 'react';
import { MonthLocationList } from '@/app/ui/MonthLocationList';
import { MonthArray, MonthLocationList } from '@/app/ui/MonthLocationList';
import { ParamsYearInvalidMessage } from './ParamsYearInvalidMessage';
export interface HomePageProps {
searchParams?: {
@@ -14,11 +14,9 @@ export interface HomePageProps {
export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
/** years found in the DB sorted descending */
let availableYears: number[];
// const asyncTimout = (ms:number) => new Promise(resolve => setTimeout(resolve, ms));
// await asyncTimout(5000);
try {
availableYears = await fetchAvailableYears();
} catch (error:any) {
@@ -28,50 +26,78 @@ export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
</main>);
}
// if the database is in it's initial state, show the add location button for the current month
if(availableYears.length === 0) {
return (<MonthLocationList />);
const paramsYear = searchParams?.year ? Number(searchParams.year) : null;
let selectedYear:number;
// IF year is set via params, check if it's valid
if(paramsYear) {
// IF the database is in it's initial state
// THEN show message param being invalid AND redirect to root page
if(availableYears.length === 0) {
return (<ParamsYearInvalidMessage />);
}
// IF the year specified in the params is not found in the DB
// THEN show message param being invalid AND redirect to page showing first available year
if(!availableYears.includes(paramsYear)) {
return (<ParamsYearInvalidMessage firstAvailableYear={availableYears[0]} />);
}
selectedYear = paramsYear;
} else {
const currYear = new Date().getFullYear();
if(availableYears.length === 0) {
// Database is in it's initial state
// so just set selected year to current year
selectedYear = currYear;
} else {
// IF current year is available in DB THEN use it
// ELSE use the latest year found in the DB
selectedYear = availableYears.includes(currYear) ? currYear : availableYears[0];
}
}
const currentYear = Number(searchParams?.year) || new Date().getFullYear();
const locations = await fetchAllLocations(currentYear);
const locations = await fetchAllLocations(selectedYear);
const userSettings = await getUserSettings();
// group locations by month
const months = locations.reduce((acc, location) => {
// Create months object by grouping locations by yearMonth
const { months } = locations.reduce((acc, location) => {
const {year, month} = location.yearMonth;
const key = `${year}-${month}`;
const locationsInMonth = acc[key];
const monthIx = acc.index[key];
if(locationsInMonth) {
return({
...acc,
[key]: {
yearMonth: location.yearMonth,
locations: [...locationsInMonth.locations, location],
unpaidTotal: locationsInMonth.unpaidTotal + location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0),
payedTotal: locationsInMonth.payedTotal + location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0)
}
})
if(monthIx) {
const existingMonth = acc.months[monthIx];
existingMonth.locations.push(location);
existingMonth.unpaidTotal += location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0);
existingMonth.payedTotal += location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0);
return acc;
}
return({
...acc,
[key]: {
yearMonth: location.yearMonth,
locations: [location],
unpaidTotal: location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0),
payedTotal: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0)
}
acc.months.push({
yearMonth: location.yearMonth,
locations: [location],
unpaidTotal: location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0),
payedTotal: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0)
});
}, {} as {[key:string]:{
yearMonth: YearMonth,
locations: BillingLocation[],
unpaidTotal: number,
payedTotal: number
} });
acc.index[key] = acc.months.length - 1;
return acc;
}, {
index: {},
months: []
} as {
index: Record<string, number>,
months: MonthArray
});
return (
<MonthLocationList availableYears={availableYears} months={months} userSettings={userSettings} />

View File

@@ -1,15 +1,16 @@
'use client';
import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon, EnvelopeIcon, ExclamationTriangleIcon, ClockIcon } from "@heroicons/react/24/outline";
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import { BillBadge } from "./BillBadge";
import { BillingLocation, EmailStatus } from "../lib/db-types";
import { formatYearMonth } from "../lib/format";
import { formatCurrency } from "../lib/formatStrings";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useTranslations } from "next-intl";
import { toast } from "react-toastify";
import { generateShareLink } from "../lib/actions/locationActions";
import { generateShareId, generateShareLink } from "../lib/actions/locationActions";
import { AsyncLink } from "./AsyncLink";
export interface LocationCardProps {
location: BillingLocation;
@@ -35,6 +36,18 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
const totalUnpaid = bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0);
const totalPayed = bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0);
/**
* Share ID which can be used in shareable links
* Note: not to be used in share button directly since `generateShareLink` sets sharing TTL in the DB
* */
const [shareID, setShareID] = useState<string>("not-yet-generated");
useEffect(() => {
// share ID can be generated server-side since it requires a secret key
// which we don't want to expose to the client
(async () => setShareID(await generateShareId(_id)))();
}, [_id]);
const handleCopyLinkClick = async () => {
// copy URL to clipboard
const shareLink = await generateShareLink(_id);
@@ -124,16 +137,17 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
</div>
)}
{utilBillsProofOfPayment?.uploadedAt && (
<Link
href={`/share/proof-of-payment/${_id}/`}
<AsyncLink
href={`/share/proof-of-payment/combined/${shareID}`}
target="_blank"
className="flex mt-1 ml-1">
className="flex mt-1 ml-1"
disabled={!shareID} >
<span className="w-5 min-w-5 mr-2"><TicketIcon className="mt-[.1rem]" /></span>
<span>
<span className="underline">{t("download-proof-of-payment-label")}</span>
<CheckCircleIcon className="h-5 w-5 ml-2 mt-[-.2rem] text-success inline-block" />
</span>
</Link>
</AsyncLink>
)}
</div>
</div>

View File

@@ -22,22 +22,22 @@ const getNextYearMonth = (yearMonth:YearMonth) => {
} as YearMonth);
}
export type MonthArray = Array<{
yearMonth: YearMonth;
locations: BillingLocation[];
payedTotal: number;
unpaidTotal: number;
}>;
export interface MonthLocationListProps {
availableYears?: number[];
months?: {
[key: string]: {
yearMonth: YearMonth;
locations: BillingLocation[];
payedTotal: number;
unpaidTotal: number;
};
};
availableYears: number[];
months: MonthArray;
userSettings?: UserSettings | null;
}
export const MonthLocationList:React.FC<MonthLocationListProps > = ({
availableYears,
months,
months: activeYearMonths,
userSettings,
}) => {
@@ -93,7 +93,7 @@ export const MonthLocationList:React.FC<MonthLocationListProps > = ({
}
}, [search, router, t]);
if(!availableYears || !months) {
if(availableYears.length === 0 || activeYearMonths.length === 0) {
const currentYearMonth:YearMonth = {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1
@@ -107,8 +107,6 @@ export const MonthLocationList:React.FC<MonthLocationListProps > = ({
</>)
};
const monthsArray = Object.entries(months);
// when the month is toggled, update the URL
// and set the the new expandedMonth
const handleMonthToggle = (yearMonth:YearMonth) => {
@@ -123,10 +121,10 @@ export const MonthLocationList:React.FC<MonthLocationListProps > = ({
}
return(<>
<AddMonthButton yearMonth={getNextYearMonth(monthsArray[0][1].locations[0].yearMonth)} />
<AddMonthButton yearMonth={getNextYearMonth(activeYearMonths[0].locations[0].yearMonth)} />
{
monthsArray.map(([monthKey, { yearMonth, locations, unpaidTotal, payedTotal }], monthIx) =>
<MonthCard yearMonth={yearMonth} key={`month-${monthKey}`} unpaidTotal={unpaidTotal} payedTotal={payedTotal} currency={userSettings?.currency} expanded={ yearMonth.month === expandedMonth } onToggle={handleMonthToggle} >
activeYearMonths.map(({ yearMonth, locations, unpaidTotal, payedTotal }, monthIx) =>
<MonthCard yearMonth={yearMonth} key={`month-${yearMonth.year}-${yearMonth.month}`} unpaidTotal={unpaidTotal} payedTotal={payedTotal} currency={userSettings?.currency} expanded={ yearMonth.month === expandedMonth } onToggle={handleMonthToggle} >
{
yearMonth.month === expandedMonth ?
locations.map((location, ix) => <LocationCard key={`location-${location._id}`} location={location} currency={userSettings?.currency} />)

View File

@@ -0,0 +1,21 @@
"use client";
import { FC, useEffect } from 'react';
import { redirect } from 'next/navigation';
export const ParamsYearInvalidMessage:FC<{ firstAvailableYear?: number }> = ({ firstAvailableYear }) => {
// Redirect to the first available year after showing the message
useEffect(() => {
if(firstAvailableYear) {
redirect(`/?year=${firstAvailableYear}`);
} else {
redirect(`/`);
}
});
return(
<main className="flex min-h-screen flex-col p-6 bg-base-300">
<p className="text-center text-2xl text-red-500">The year specified in the URL is invalid ... redirecting</p>
</main>
);
};

View File

@@ -7,7 +7,7 @@ import { renderBarcode } from '../lib/pdf/renderBarcode';
export const Pdf417Barcode:FC<{hub3aText:string, className?: string }> = ({ hub3aText: hub3a_text, className }) => {
const [bitmapData, setBitmapData] = useState<string | undefined>(undefined);
console.log("Rendering Pdf417Barcode with hub3a_text:", hub3a_text);
// console.log("Rendering Pdf417Barcode with hub3a_text:", hub3a_text);
useEffect(() => {
const aspectRatio = 3;

View File

@@ -1,10 +0,0 @@
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
# NextJS will do name resolution for `rezije.app` and will crash if it
# resolves to an IP adress different from the one assigned to the Docker container.
# This will prevent that from happening.
0.0.0.0 rezije.app

View File

@@ -1,11 +1,11 @@
{
"name": "evidencija-rezija",
"version": "2.20.0",
"version": "2.21.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"version": "2.20.0",
"version": "2.21.3",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -58,5 +58,5 @@
"engines": {
"node": ">=18.17.0"
},
"version": "2.20.0"
"version": "2.21.3"
}