Compare commits
20 Commits
feature/em
...
3366e85950
| Author | SHA1 | Date | |
|---|---|---|---|
| 3366e85950 | |||
| 5cb0210668 | |||
| 2ddff15497 | |||
| 0492469ed6 | |||
| 42d1f6276a | |||
| d17efdc156 | |||
| de97ce744f | |||
| c9cc32b811 | |||
| 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,10 @@ networks:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
web-app:
|
web-app:
|
||||||
image: utility-bills-tracker:${IMAGE_VERSION}
|
image: registry.budakova.org/knee-cola/utility-bills-tracker:${IMAGE_VERSION}
|
||||||
networks:
|
networks:
|
||||||
- traefik-network
|
- traefik-network
|
||||||
- util-bills-mongo-network
|
- util-bills-mongo-network
|
||||||
volumes:
|
|
||||||
- ./web-app/etc/hosts/:/etc/hosts
|
|
||||||
environment:
|
environment:
|
||||||
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
|
MONGODB_URI: mongodb://rezije.app:w4z4piJBgCdAm4tpawqB@mongo:27017/utility-bills
|
||||||
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com
|
GOOGLE_ID: 355397364527-adjrokm6hromcaaar0qfhk050mfr35ou.apps.googleusercontent.com
|
||||||
@@ -28,7 +26,7 @@ services:
|
|||||||
LINKEDIN_SECRET: ugf61aJ2iyErLK40
|
LINKEDIN_SECRET: ugf61aJ2iyErLK40
|
||||||
HOSTNAME: rezije.app # IP address at which the server will be listening (0.0.0.0 = listen on all addresses)
|
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)
|
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 security
|
||||||
SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5
|
SHARE_LINK_SECRET: ef68362357315d5decb27d24ff9abdb4a02a3351cd2899f79bf238dce0fe08c5
|
||||||
SHARE_TTL_INITIAL_DAYS: 10
|
SHARE_TTL_INITIAL_DAYS: 10
|
||||||
@@ -43,7 +41,7 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik-network # mreže preko koje ide komunikacija sa Traefikom
|
- 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.entrypoints=http
|
||||||
- traefik.http.routers.web-app.rule=Host(`${FQDN:-rezije.app}`)
|
- traefik.http.routers.web-app.rule=Host(`${FQDN:-rezije.app}`)
|
||||||
|
|
||||||
@@ -55,8 +53,8 @@ services:
|
|||||||
MONGO_INITDB_ROOT_USERNAME: root
|
MONGO_INITDB_ROOT_USERNAME: root
|
||||||
MONGO_INITDB_ROOT_PASSWORD: example
|
MONGO_INITDB_ROOT_PASSWORD: example
|
||||||
volumes:
|
volumes:
|
||||||
- ./mongo-volume:/data/db
|
- /home/knee-cola/docker/evidencija-rezija/mongo-volume:/data/db
|
||||||
- ./mongo-backup:/backup
|
- /home/knee-cola/docker/evidencija-rezija/mongo-backup:/backup
|
||||||
networks:
|
networks:
|
||||||
- util-bills-mongo-network
|
- util-bills-mongo-network
|
||||||
mongo-express:
|
mongo-express:
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ COPY --from=builder /app/public/* /app/public/
|
|||||||
# this file is required for the pdfjs-dist package
|
# this file is required for the pdfjs-dist package
|
||||||
COPY --from=builder /app/node_modules/pdfjs-dist/build/pdf.worker.min.mjs /app/public/pdf.worker.min.mjs
|
COPY --from=builder /app/node_modules/pdfjs-dist/build/pdf.worker.min.mjs /app/public/pdf.worker.min.mjs
|
||||||
|
|
||||||
|
# copy custom hosts file to avoid issues with some auth providers
|
||||||
|
COPY ./etc/hosts /etc/hosts
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
# Automatically leverage output traces to reduce image size
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./
|
COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { gotoHomeWithMessage } from './navigationActions';
|
|||||||
import { unstable_noStore, revalidatePath } from 'next/cache';
|
import { unstable_noStore, revalidatePath } from 'next/cache';
|
||||||
import { IntlTemplateFn } from '@/app/i18n';
|
import { IntlTemplateFn } from '@/app/i18n';
|
||||||
import { getTranslations, getLocale } from "next-intl/server";
|
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 { validatePdfFile } from '../validators/pdfValidator';
|
||||||
import { checkUploadRateLimit } from '../uploadRateLimiter';
|
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
|
* Generate/activate share link for location
|
||||||
* Called when owner clicks "Share" button
|
* Called when owner clicks "Share" button
|
||||||
@@ -800,7 +812,7 @@ export const generateShareLink = withUser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Generate combined share ID (locationId + checksum)
|
// Generate combined share ID (locationId + checksum)
|
||||||
const shareId = generateShareId(locationId);
|
const shareId = await generateShareId(locationId);
|
||||||
|
|
||||||
// Build share URL
|
// Build share URL
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
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
|
* Extract location ID and checksum from combined share ID
|
||||||
* @param shareId - Combined ID (locationId + checksum)
|
* @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 { DocumentIcon, TicketIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||||
import { Bill, BilledTo, BillingLocation } from "../lib/db-types";
|
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 { useFormState } from "react-dom";
|
||||||
import { updateOrAddBill } from "../lib/actions/billActions";
|
import { updateOrAddBill } from "../lib/actions/billActions";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -11,6 +11,8 @@ import { DecodeResult, findDecodePdf417 } from "../lib/pdf/barcodeDecoderWasm";
|
|||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { InfoBox } from "./InfoBox";
|
import { InfoBox } from "./InfoBox";
|
||||||
import { Pdf417Barcode } from "./Pdf417Barcode";
|
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
|
// 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
|
// 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;
|
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 initialState = { message: null, errors: {} };
|
||||||
|
|
||||||
const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth);
|
const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth);
|
||||||
@@ -238,14 +254,15 @@ export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
|
|||||||
// -> don't show anything
|
// -> don't show anything
|
||||||
proofOfPayment.fileName ? (
|
proofOfPayment.fileName ? (
|
||||||
<div className="mt-3 ml-[-.5rem]">
|
<div className="mt-3 ml-[-.5rem]">
|
||||||
<Link
|
<AsyncLink
|
||||||
href={`/share/proof-of-payment/per-bill/${locationID}-${billID}/`}
|
disabled={!shareID}
|
||||||
|
href={`/share/proof-of-payment/per-bill/${shareID}-${billID}/`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
|
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" />
|
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 mt-[-.2em] text-teal-500" />
|
||||||
{decodeURIComponent(proofOfPayment.fileName)}
|
{decodeURIComponent(proofOfPayment.fileName)}
|
||||||
</Link>
|
</AsyncLink>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { fetchAllLocations } from '@/app/lib/actions/locationActions';
|
import { fetchAllLocations } from '@/app/lib/actions/locationActions';
|
||||||
import { fetchAvailableYears } from '@/app/lib/actions/monthActions';
|
import { fetchAvailableYears } from '@/app/lib/actions/monthActions';
|
||||||
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
|
import { getUserSettings } from '@/app/lib/actions/userSettingsActions';
|
||||||
import { BillingLocation, YearMonth } from '@/app/lib/db-types';
|
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { MonthLocationList } from '@/app/ui/MonthLocationList';
|
import { MonthArray, MonthLocationList } from '@/app/ui/MonthLocationList';
|
||||||
|
import { ParamsYearInvalidMessage } from './ParamsYearInvalidMessage';
|
||||||
|
|
||||||
export interface HomePageProps {
|
export interface HomePageProps {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
@@ -14,11 +14,9 @@ export interface HomePageProps {
|
|||||||
|
|
||||||
export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
|
export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
|
||||||
|
|
||||||
|
/** years found in the DB sorted descending */
|
||||||
let availableYears: number[];
|
let availableYears: number[];
|
||||||
|
|
||||||
// const asyncTimout = (ms:number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
// await asyncTimout(5000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
availableYears = await fetchAvailableYears();
|
availableYears = await fetchAvailableYears();
|
||||||
} catch (error:any) {
|
} catch (error:any) {
|
||||||
@@ -28,50 +26,78 @@ export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
|
|||||||
</main>);
|
</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;
|
||||||
if(availableYears.length === 0) {
|
let selectedYear:number;
|
||||||
return (<MonthLocationList />);
|
|
||||||
|
// 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(selectedYear);
|
||||||
|
|
||||||
const locations = await fetchAllLocations(currentYear);
|
|
||||||
const userSettings = await getUserSettings();
|
const userSettings = await getUserSettings();
|
||||||
|
|
||||||
// group locations by month
|
// Create months object by grouping locations by yearMonth
|
||||||
const months = locations.reduce((acc, location) => {
|
const { months } = locations.reduce((acc, location) => {
|
||||||
const {year, month} = location.yearMonth;
|
const {year, month} = location.yearMonth;
|
||||||
const key = `${year}-${month}`;
|
const key = `${year}-${month}`;
|
||||||
|
|
||||||
const locationsInMonth = acc[key];
|
const monthIx = acc.index[key];
|
||||||
|
|
||||||
if(locationsInMonth) {
|
if(monthIx) {
|
||||||
return({
|
|
||||||
...acc,
|
const existingMonth = acc.months[monthIx];
|
||||||
[key]: {
|
|
||||||
yearMonth: location.yearMonth,
|
existingMonth.locations.push(location);
|
||||||
locations: [...locationsInMonth.locations, location],
|
existingMonth.unpaidTotal += location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0);
|
||||||
unpaidTotal: locationsInMonth.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);
|
||||||
payedTotal: locationsInMonth.payedTotal + location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0)
|
|
||||||
}
|
return acc;
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return({
|
acc.months.push({
|
||||||
...acc,
|
yearMonth: location.yearMonth,
|
||||||
[key]: {
|
locations: [location],
|
||||||
yearMonth: location.yearMonth,
|
unpaidTotal: location.bills.reduce((acc, bill) => !bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0),
|
||||||
locations: [location],
|
payedTotal: location.bills.reduce((acc, bill) => bill.paid ? acc + (bill.payedAmount ?? 0) : acc, 0)
|
||||||
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,
|
acc.index[key] = acc.months.length - 1;
|
||||||
locations: BillingLocation[],
|
|
||||||
unpaidTotal: number,
|
return acc;
|
||||||
payedTotal: number
|
}, {
|
||||||
} });
|
index: {},
|
||||||
|
months: []
|
||||||
|
} as {
|
||||||
|
index: Record<string, number>,
|
||||||
|
months: MonthArray
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MonthLocationList availableYears={availableYears} months={months} userSettings={userSettings} />
|
<MonthLocationList availableYears={availableYears} months={months} userSettings={userSettings} />
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon, EnvelopeIcon, ExclamationTriangleIcon, ClockIcon } from "@heroicons/react/24/outline";
|
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 { BillBadge } from "./BillBadge";
|
||||||
import { BillingLocation, EmailStatus } from "../lib/db-types";
|
import { BillingLocation, EmailStatus } from "../lib/db-types";
|
||||||
import { formatYearMonth } from "../lib/format";
|
import { formatYearMonth } from "../lib/format";
|
||||||
import { formatCurrency } from "../lib/formatStrings";
|
import { formatCurrency } from "../lib/formatStrings";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "react-toastify";
|
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 {
|
export interface LocationCardProps {
|
||||||
location: BillingLocation;
|
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 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);
|
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 () => {
|
const handleCopyLinkClick = async () => {
|
||||||
// copy URL to clipboard
|
// copy URL to clipboard
|
||||||
const shareLink = await generateShareLink(_id);
|
const shareLink = await generateShareLink(_id);
|
||||||
@@ -124,16 +137,17 @@ export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{utilBillsProofOfPayment?.uploadedAt && (
|
{utilBillsProofOfPayment?.uploadedAt && (
|
||||||
<Link
|
<AsyncLink
|
||||||
href={`/share/proof-of-payment/${_id}/`}
|
href={`/share/proof-of-payment/combined/${shareID}`}
|
||||||
target="_blank"
|
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 className="w-5 min-w-5 mr-2"><TicketIcon className="mt-[.1rem]" /></span>
|
||||||
<span>
|
<span>
|
||||||
<span className="underline">{t("download-proof-of-payment-label")}</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" />
|
<CheckCircleIcon className="h-5 w-5 ml-2 mt-[-.2rem] text-success inline-block" />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</AsyncLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,22 +22,22 @@ const getNextYearMonth = (yearMonth:YearMonth) => {
|
|||||||
} as YearMonth);
|
} as YearMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MonthArray = Array<{
|
||||||
|
yearMonth: YearMonth;
|
||||||
|
locations: BillingLocation[];
|
||||||
|
payedTotal: number;
|
||||||
|
unpaidTotal: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
export interface MonthLocationListProps {
|
export interface MonthLocationListProps {
|
||||||
availableYears?: number[];
|
availableYears: number[];
|
||||||
months?: {
|
months: MonthArray;
|
||||||
[key: string]: {
|
|
||||||
yearMonth: YearMonth;
|
|
||||||
locations: BillingLocation[];
|
|
||||||
payedTotal: number;
|
|
||||||
unpaidTotal: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
userSettings?: UserSettings | null;
|
userSettings?: UserSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MonthLocationList:React.FC<MonthLocationListProps > = ({
|
export const MonthLocationList:React.FC<MonthLocationListProps > = ({
|
||||||
availableYears,
|
availableYears,
|
||||||
months,
|
months: activeYearMonths,
|
||||||
userSettings,
|
userSettings,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ export const MonthLocationList:React.FC<MonthLocationListProps > = ({
|
|||||||
}
|
}
|
||||||
}, [search, router, t]);
|
}, [search, router, t]);
|
||||||
|
|
||||||
if(!availableYears || !months) {
|
if(availableYears.length === 0 || activeYearMonths.length === 0) {
|
||||||
const currentYearMonth:YearMonth = {
|
const currentYearMonth:YearMonth = {
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
month: new Date().getMonth() + 1
|
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
|
// when the month is toggled, update the URL
|
||||||
// and set the the new expandedMonth
|
// and set the the new expandedMonth
|
||||||
const handleMonthToggle = (yearMonth:YearMonth) => {
|
const handleMonthToggle = (yearMonth:YearMonth) => {
|
||||||
@@ -123,10 +121,10 @@ export const MonthLocationList:React.FC<MonthLocationListProps > = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return(<>
|
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) =>
|
activeYearMonths.map(({ yearMonth, locations, unpaidTotal, payedTotal }, monthIx) =>
|
||||||
<MonthCard yearMonth={yearMonth} key={`month-${monthKey}`} unpaidTotal={unpaidTotal} payedTotal={payedTotal} currency={userSettings?.currency} expanded={ yearMonth.month === expandedMonth } onToggle={handleMonthToggle} >
|
<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 ?
|
yearMonth.month === expandedMonth ?
|
||||||
locations.map((location, ix) => <LocationCard key={`location-${location._id}`} location={location} currency={userSettings?.currency} />)
|
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 }) => {
|
export const Pdf417Barcode:FC<{hub3aText:string, className?: string }> = ({ hub3aText: hub3a_text, className }) => {
|
||||||
const [bitmapData, setBitmapData] = useState<string | undefined>(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
const aspectRatio = 3;
|
const aspectRatio = 3;
|
||||||
|
|||||||
4
web-app/package-lock.json
generated
4
web-app/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "evidencija-rezija",
|
"name": "evidencija-rezija",
|
||||||
"version": "2.20.0",
|
"version": "2.21.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"version": "2.20.0",
|
"version": "2.21.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
|
|||||||
@@ -58,5 +58,5 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17.0"
|
"node": ">=18.17.0"
|
||||||
},
|
},
|
||||||
"version": "2.20.0"
|
"version": "2.21.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user