Compare commits
26 Commits
45d5507bf9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 419d91e292 | |||
| a428a77eb1 | |||
| 16eaa5bfa1 | |||
| 2cff1ec18b | |||
| 3366e85950 | |||
| 5cb0210668 | |||
| 2ddff15497 | |||
| 0492469ed6 | |||
| 0ecae68c63 | |||
| 42d1f6276a | |||
| d17efdc156 | |||
| de97ce744f | |||
| c9cc32b811 | |||
| bc5f5e051f | |||
| 528c433fce | |||
| 50238b4e90 | |||
| 5773156222 | |||
| a3ec20544c | |||
| e318523887 | |||
| 37f617683e | |||
| 1ca55ae820 | |||
| 0e3e41e064 | |||
| 488c771a09 | |||
| 0b8c8ae6c4 | |||
| 1076797c89 | |||
|
|
a54771e479 |
58
.gitea/workflows/build.yml
Normal file
58
.gitea/workflows/build.yml
Normal 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
|
||||
94
.gitea/workflows/check_image_version.yml
Normal file
94
.gitea/workflows/check_image_version.yml
Normal 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 }}"
|
||||
82
.gitea/workflows/check_package_version.yml
Normal file
82
.gitea/workflows/check_package_version.yml
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
13
web-app/app/ui/AsyncLink.tsx
Normal file
13
web-app/app/ui/AsyncLink.tsx
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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 (<MonthLocationList />);
|
||||
return (<ParamsYearInvalidMessage />);
|
||||
}
|
||||
|
||||
const currentYear = Number(searchParams?.year) || new Date().getFullYear();
|
||||
// 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]} />);
|
||||
}
|
||||
|
||||
const locations = await fetchAllLocations(currentYear);
|
||||
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 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]: {
|
||||
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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,22 +22,22 @@ const getNextYearMonth = (yearMonth:YearMonth) => {
|
||||
} as YearMonth);
|
||||
}
|
||||
|
||||
export interface MonthLocationListProps {
|
||||
availableYears?: number[];
|
||||
months?: {
|
||||
[key: string]: {
|
||||
export type MonthArray = Array<{
|
||||
yearMonth: YearMonth;
|
||||
locations: BillingLocation[];
|
||||
payedTotal: number;
|
||||
unpaidTotal: number;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export interface MonthLocationListProps {
|
||||
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} />)
|
||||
|
||||
21
web-app/app/ui/ParamsYearInvalidMessage.tsx
Normal file
21
web-app/app/ui/ParamsYearInvalidMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
4
web-app/package-lock.json
generated
4
web-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -58,5 +58,5 @@
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"version": "2.20.0"
|
||||
"version": "2.21.3"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user