refactor: convert repository to monorepo with npm workspaces

Restructured the repository into a monorepo to better organize application code
and maintenance scripts.

## Workspace Structure
- web-app: Next.js application (all app code moved from root)
- housekeeping: Database backup and maintenance scripts

## Key Changes
- Moved all application code to web-app/ using git mv
- Moved database scripts to housekeeping/ workspace
- Updated Dockerfile for monorepo build process
- Updated docker-compose files (volume paths: ./web-app/etc/hosts/)
- Updated .gitignore for workspace-level node_modules
- Updated documentation (README.md, CLAUDE.md, CHANGELOG.md)

## Migration Impact
- Root package.json now manages workspaces
- Build commands delegate to web-app workspace
- All file history preserved via git mv
- Docker build process updated for workspace structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Knee Cola
2025-12-25 12:13:04 +01:00
parent 321267a848
commit 57dcebd640
170 changed files with 9027 additions and 137 deletions

View File

@@ -0,0 +1,30 @@
"use client";
import { PlusCircleIcon, HomeIcon } from "@heroicons/react/24/outline";
import { YearMonth } from "../lib/db-types";
import { formatYearMonth } from "../lib/format";
import Link from "next/link";
import { useTranslations } from 'next-intl';
export interface AddLocationButtonProps {
/** year and month at which the new billing location should be addes */
yearMonth: YearMonth;
className?: string;
}
export const AddLocationButton:React.FC<AddLocationButtonProps> = ({yearMonth, className}) => {
const t = useTranslations("home-page.add-location-button");
return(
<div className={`card card-compact card-bordered bg-base-100 shadow-s my-1 ${className}`}>
<Link href={`/home/location/${ formatYearMonth(yearMonth) }/add`} className="card-body tooltip self-center" data-tip={t("tooltip")} data-umami-event="add-new-location">
<span className='flex self-center'>
<HomeIcon className="h-[1em] w-[1em] cursor-pointer text-4xl mt-[.1rem]" />
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-xl text-green-500 ml-[-.6em] mt-[-.3em]" />
<span className="ml-1 self-center text-sm sm:text-xs text-left w-[5.5em] leading-[1.3em]">{t("tooltip")}</span>
</span>
</Link>
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { PlusCircleIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
import React from "react";
import { formatYearMonth } from "../lib/format";
import { YearMonth } from "../lib/db-types";
import Link from "next/link";
import { useLocale, useTranslations } from 'next-intl';
export interface AddMonthButtonProps {
yearMonth: YearMonth;
}
export const AddMonthButton:React.FC<AddMonthButtonProps> = ({ yearMonth }) => {
const t = useTranslations("home-page.add-month-button");
const locale = useLocale();
return(
<div className="card card-compact shadow-s mb-4">
<Link href={`/${locale}/home/year-month/${formatYearMonth(yearMonth)}/add`} className='grid self-center tooltip' data-tip={t("tooltip")}>
<span className='flex self-center'>
<CalendarDaysIcon className="h-[1em] w-[1em] cursor-pointer text-4xl" />
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-xl text-green-500 ml-[-.4em] mt-[-.4em]" />
<span className="ml-1 self-center text-xs text-left leading-[1.2em] w-[6em]">{t("tooltip")}</span>
</span>
</Link>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { FC } from "react"
import { Bill } from "@/app/lib/db-types"
import Link from "next/link"
import { TicketIcon } from "@heroicons/react/24/outline"
export interface BillBadgeProps {
locationId: string,
bill: Bill
};
export const BillBadge:FC<BillBadgeProps> = ({ locationId, bill: { _id: billId, name, paid, hasAttachment, proofOfPayment }}) => {
const className = `badge badge-lg ${paid?"badge-success":" badge-outline"} ${ !paid && hasAttachment ? "btn-outline btn-success" : "" } cursor-pointer`;
return (
<Link href={`/home/bill/${locationId}-${billId}/edit`} className={className}>
{name}
{
proofOfPayment?.uploadedAt ?
<TicketIcon className="h-[1em] w-[1em] inline-block ml-1" /> : null
}
</Link>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { FC, ReactNode, useState } from "react";
import { Bill, BillingLocation } from "../lib/db-types";
import { useFormState } from "react-dom";
import { Main } from "./Main";
import { deleteBillById } from "../lib/actions/billActions";
import Link from "next/link";
import { useTranslations } from "next-intl";
export interface BillDeleteFormProps {
bill: Bill,
location: BillingLocation
}
export const BillDeleteForm:FC<BillDeleteFormProps> = ({ bill, location }) => {
const { year, month } = location.yearMonth;
const handleAction = deleteBillById.bind(null, location._id, bill._id, year, month);
const [ state, dispatch ] = useFormState(handleAction, null);
const t = useTranslations("bill-delete-form");
const [deleteInSubsequentMonths, setDeleteInSubsequentMonths] = useState(false);
return(
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<form action={dispatch}>
<p className="py-6 px-6">
{
t.rich("text", {
bill_name:bill.name,
location_name:location.name,
strong: (chunks:ReactNode) => <strong>{chunks}</strong>,
})
}
</p>
<div className="form-control">
<label className="label cursor-pointer justify-center gap-4">
<span className="label-text">{t("delete-in-subsequent-months")}</span>
<input
type="checkbox"
name="deleteInSubsequentMonths"
className="toggle toggle-error"
checked={deleteInSubsequentMonths}
onChange={(e) => setDeleteInSubsequentMonths(e.target.checked)}
/>
</label>
</div>
{deleteInSubsequentMonths && (
<div className="border-l-4 border-error bg-error/10 p-4 mt-4 rounded-r max-w-[24rem] mx-auto">
<div className="flex items-start gap-3">
<span className="text-xl flex-shrink-0"></span>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-error break-words">{t("warning-title")}</h3>
<div className="text-sm text-error/80 break-words">{t("warning-message")}</div>
</div>
</div>
</div>
)}
<div className="pt-4 text-center">
<button className="btn btn-primary w-[5.5em]">{t("confirm-button")}</button>
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/home/bill/${location._id}-${bill._id}/edit/`}>{t("cancel-button")}</Link>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,281 @@
"use client";
import { DocumentIcon, TicketIcon, TrashIcon } from "@heroicons/react/24/outline";
import { Bill, BilledTo, BillingLocation } from "../lib/db-types";
import React, { FC, useEffect } from "react";
import { useFormState } from "react-dom";
import { updateOrAddBill } from "../lib/actions/billActions";
import Link from "next/link";
import { formatYearMonth } from "../lib/format";
import { DecodeResult, findDecodePdf417 } from "../lib/pdf/barcodeDecoderWasm";
import { useLocale, useTranslations } from "next-intl";
import { InfoBox } from "./InfoBox";
import { Pdf417Barcode } from "./Pdf417Barcode";
// 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
const updateOrAddBillMiddleware = (locationId: string, billId: string | undefined, billYear: number | undefined, billMonth: number | undefined, prevState: any, formData: FormData) => {
// URL encode the file name of the attachment so it is correctly sent to the server
const billAttachment = formData.get('billAttachment') as File;
formData.set('billAttachment', billAttachment, encodeURIComponent(billAttachment.name));
return updateOrAddBill(locationId, billId, billYear, billMonth, prevState, formData);
}
export interface BillEditFormProps {
location: BillingLocation,
bill?: Bill,
}
export const BillEditForm: FC<BillEditFormProps> = ({ location, bill }) => {
const t = useTranslations("bill-edit-form");
const locale = useLocale();
const { _id: billID, name, paid, billedTo = BilledTo.Tenant, attachment, notes, payedAmount: initialPayedAmount, proofOfPayment } = bill ?? { _id: undefined, name: "", paid: false, notes: "" };
const { yearMonth: { year: billYear, month: billMonth }, _id: locationID, proofOfPaymentType } = location;
const initialState = { message: null, errors: {} };
const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth);
const [isScanningPDF, setIsScanningPDF] = React.useState<boolean>(false);
const [state, dispatch] = useFormState(handleAction, initialState);
const [isPaid, setIsPaid] = React.useState<boolean>(paid);
const [billedToValue, setBilledToValue] = React.useState<BilledTo>(billedTo);
const [payedAmount, setPayedAmount] = React.useState<string>(initialPayedAmount ? `${initialPayedAmount / 100}` : "");
// legacy support - to be removed
const [hub3aText, setHub3aText] = React.useState<string | undefined>(bill?.hub3aText);
const [barcodeResults, setBarcodeResults] = React.useState<Array<DecodeResult> | null>(null);
useEffect(() => {
console.log("[BillEditForm] hub3a text from DB:", bill?.hub3aText);
}, [bill?.hub3aText]);
const billedTo_handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setBilledToValue(event.target.value as BilledTo);
}
const billPaid_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsPaid(event.target.checked);
}
const payedAmount_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPayedAmount(event.target.value);
}
const billAttachment_handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
setIsScanningPDF(true);
setPayedAmount("");
setBarcodeResults(null);
const results = await findDecodePdf417(event);
if (results && results.length > 0) {
if (results.length === 1) {
const {
hub3aText,
billInfo
} = results[0];
setPayedAmount(`${billInfo.amount / 100}`);
setHub3aText(hub3aText);
console.log("[BillEditForm] Single barcode result found:", hub3aText);
} else {
console.log("[BillEditForm] Multiple barcode results found:", results);
setPayedAmount("");
setBarcodeResults(results);
setHub3aText(undefined);
}
} else {
console.log("[BillEditForm] No barcode results found.");
}
setIsScanningPDF(false);
}
const handleBarcodeSelectClick = (result: DecodeResult) => {
setPayedAmount(`${result.billInfo.amount / 100}`);
setHub3aText(result.hub3aText);
setBarcodeResults(null);
}
return (
<div className="card card-compact card-bordered bg-base-100 shadow-s">
<div className="card-body">
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
<form action={dispatch}>
{
// don't show the delete button if we are adding a new bill
bill ?
<Link href={`/${locale}/home/bill/${locationID}-${billID}/delete/`} data-tip={t("delete-tooltip")}>
<TrashIcon className="h-[1em] w-[1em] absolute cursor-pointer text-error bottom-5 right-4 text-2xl" />
</Link> : null
}
<input id="billName" name="billName" type="text" placeholder={t("bill-name-placeholder")} className="input input-bordered w-full" defaultValue={name} required />
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.billName &&
state.errors.billName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
{
// <textarea className="textarea textarea-bordered my-1 w-full max-w-sm block" placeholder="Opis" value="Pričuva, Voda, Smeće"></textarea>
attachment ?
<Link href={`/attachment/${locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-4'>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{decodeURIComponent(attachment.fileName)}
</Link>
: null
}
<div className="flex">
<input id="billAttachment" name="billAttachment" type="file" className="file-input file-input-bordered grow file-input-s my-2 block w-full break-words" onChange={billAttachment_handleChange} />
</div>
{
isScanningPDF &&
<div className="flex flex-row items-center w-full mt-2">
<div className="loading loading-spinner loading-m mr-2"></div>
<span>{t("scanning-pdf")}</span>
</div>
}
{
// if multiple results are found, show them as a list
// and notify the user to select the correct one
barcodeResults && barcodeResults.length > 0 &&
<>
<div className="flex mt-2 ml-2">
<label className="label-text max-w-xs break-words">{t("multiple-barcode-results-notification")}</label>
</div>
<div className="flex">
<label className="label grow pt-0 ml-3">
<ul className="list-none">
{barcodeResults.map((result, index) => (
<li key={index} className="cursor-pointer mt-3" onClick={() => handleBarcodeSelectClick(result)}>
👉 {result.billInfo.description}
</li>
))}
</ul>
</label>
</div>
</>
}
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.billAttachment &&
state.errors.billAttachment.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<div className="flex">
<div className="form-control flex-row">
<label className="cursor-pointer label align-middle">
<span className="label-text mr-[1em]">{t("paid-checkbox")}</span>
<input id="billPaid" name="billPaid" type="checkbox" className="toggle toggle-success" defaultChecked={paid} onChange={billPaid_handleChange} />
</label>
</div>
<div className="form-control grow">
<label className="cursor-pointer label grow">
<span className="label-text mx-[1em]">{t("payed-amount")}</span>
<input type="text" id="payedAmount" name="payedAmount" className="input input-bordered text-right w-[5em] grow" placeholder="0.00" value={payedAmount} onFocus={e => e.target.select()} onChange={payedAmount_handleChange} />
</label>
</div>
</div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.payedAmount &&
state.errors.payedAmount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<input type="hidden" name="hub3aText" value={hub3aText ? encodeURIComponent(hub3aText) : ''} />
{
hub3aText ?
<div className="form-control p-1">
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
<Pdf417Barcode hub3aText={hub3aText} className="w-full max-w-[35rem] sm:max-w-[25rem]" />
</label>
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
</div> : null
}
<textarea id="billNotes" name="billNotes" className="textarea textarea-bordered my-2 max-w-lg w-full block" placeholder={t("notes-placeholder")} defaultValue={notes ?? ''}></textarea>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.billNotes &&
state.errors.billNotes.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 mt-4">
<InfoBox>{t("billed-to-info")}</InfoBox>
<legend className="fieldset-legend font-semibold uppercase">{t("billed-to-legend")}</legend>
<select className="select select-bordered w-full" name="billedTo" defaultValue={billedToValue} onChange={billedTo_handleChange}>
<option value={BilledTo.Tenant}>{t("billed-to-tenant-option")}</option>
<option value={BilledTo.Landlord}>{t("billed-to-landlord-option")}</option>
</select>
</fieldset>
{
// IF proof of payment type is "per-bill" and proof of payment was uploaded
proofOfPaymentType === "per-bill" && proofOfPayment?.uploadedAt ?
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-2 pt-0 mt-3 mb-3">
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend>
{
// IF file name is available, show link to download
// ELSE it's not available that means that the uploaded file was purged by housekeeping
// -> don't show anything
proofOfPayment.fileName ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/per-bill/${locationID}-${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>
</div>
) : null
}
</fieldset> : null
}
{/* Show toggle only when adding a new bill (not editing) */}
{!bill && (
<div className="form-control">
<label className="label cursor-pointer">
<span className="label-text">{t("add-to-subsequent-months")}</span>
<input type="checkbox" name="addToSubsequentMonths" className="toggle toggle-primary" />
</label>
</div>
)}
<div className="pt-4">
<button type="submit" className="btn btn-primary">{t("save-button")}</button>
<Link className="btn btn-neutral ml-3" href={`/home?year=${billYear}&month=${billMonth}`}>{t("cancel-button")}</Link>
</div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.message &&
<p className="mt-2 text-sm text-red-500">
{state.message}
</p>
}
</div>
</form>
</div>
</div>);
}

View File

@@ -0,0 +1,38 @@
import { FC } from 'react';
import { SignInButton } from '@/app/ui/SignInButton';
import Image from 'next/image';
import { getTranslations } from "next-intl/server";
import Link from 'next/link';
import { paragraphFormatFactory } from '../lib/paragraphFormatFactory';
import { AuthProvider } from '../lib/getProviders';
export const EnterOrSignInButton: FC<{ session: any, locale: string, providers: AuthProvider[] }> = async ({ session, locale, providers }) => {
const paragraphFormat = paragraphFormatFactory(locale);
const t = await getTranslations("login-page");
return (
<>
<span className="flex justify-center mt-4">
{
session ? (
<Link
href={`/${locale}/home`}
className="btn btn-neutral btn-lg"
>
<Image src="/icon2.png" alt="logo" width={32} height={32} />
{t("main-card.go-to-app")}
</Link>
) : (
Object.values(providers).map((provider) => (
<div key={provider.name}>
<SignInButton provider={provider} />
</div>
))
)
}
</span>
{t.rich("disclaimer", paragraphFormat)}
</>);
};

View File

@@ -0,0 +1,81 @@
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';
export interface HomePageProps {
searchParams?: {
year?: string;
month?: string;
};
}
export const HomePage:FC<HomePageProps> = async ({ searchParams }) => {
let availableYears: number[];
// const asyncTimout = (ms:number) => new Promise(resolve => setTimeout(resolve, ms));
// await asyncTimout(5000);
try {
availableYears = await fetchAvailableYears();
} catch (error:any) {
return (
<main className="flex min-h-screen flex-col p-6 bg-base-300">
<p className="text-center text-2xl text-red-500">{error.message}</p>
</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 currentYear = Number(searchParams?.year) || new Date().getFullYear();
const locations = await fetchAllLocations(currentYear);
const userSettings = await getUserSettings();
// group locations by month
const months = locations.reduce((acc, location) => {
const {year, month} = location.yearMonth;
const key = `${year}-${month}`;
const locationsInMonth = acc[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)
}
})
}
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)
}
});
}, {} as {[key:string]:{
yearMonth: YearMonth,
locations: BillingLocation[],
unpaidTotal: number,
payedTotal: number
} });
return (
<MonthLocationList availableYears={availableYears} months={months} userSettings={userSettings} />
);
}
export default HomePage;

View File

@@ -0,0 +1,24 @@
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline";
import { QuestionMarkCircleIcon } from "@heroicons/react/16/solid";
import { useTranslations } from "next-intl";
import { FC, ReactNode } from "react";
export const InfoBox: FC<{
children: ReactNode;
title?: string;
}> = ({ children, title }) => {
const t = useTranslations("info-box");
return (
<details className="group border border-gray-800 rounded-lg p-2 mb-1 w-full">
<summary className="flex cursor-pointer items-center justify-between">
<span className="font-bold"><QuestionMarkCircleIcon className="w-5 h-5 inline mr-1 mt-[-.3em]" /> {title ?? t("default-title")}</span>
<span className="ml-2 text-sm text-gray-500 group-open:hidden"><ChevronDownIcon className="w-5 h-5 inline" /></span>
<span className="ml-2 text-sm text-gray-500 hidden group-open:inline"><ChevronUpIcon className="w-5 h-5 inline" /></span>
</summary>
<div className="mt-2 italic text-sm text-gray-400 group-open:animate-[animateDown_0.2s_linear_forwards]">{children}</div>
</details>
)
}

View File

@@ -0,0 +1,127 @@
'use client';
import { CheckCircleIcon, Cog8ToothIcon, PlusCircleIcon, ShareIcon, BanknotesIcon, EyeIcon, TicketIcon, ShoppingCartIcon } from "@heroicons/react/24/outline";
import { FC } from "react";
import { BillBadge } from "./BillBadge";
import { BillingLocation } 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 { toast } from "react-toastify";
import { generateShareLink } from "../lib/actions/locationActions";
export interface LocationCardProps {
location: BillingLocation;
currency?: string | null;
}
export const LocationCard: FC<LocationCardProps> = ({ location, currency }) => {
const {
_id,
name,
yearMonth,
bills,
seenByTenantAt,
// NOTE: only the fileName is projected from the DB to reduce data transfer
utilBillsProofOfPayment,
} = location;
const t = useTranslations("home-page.location-card");
const currentLocale = useLocale();
// sum all the unpaid and paid bill amounts (regardless of who pays)
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 handleCopyLinkClick = async () => {
// copy URL to clipboard
const shareLink = await generateShareLink(_id);
if(shareLink.error) {
toast.error(shareLink.error, { theme: "dark" });
} else {
navigator.clipboard.writeText(shareLink.shareUrl as string);
toast.success(t("link-copy-message"), { theme: "dark" });
}
}
return (
<div data-key={_id} className="card card-compact card-bordered sm:max-w-[35em] bg-base-100 border-1 border-neutral my-1">
<div className="card-body">
<Link href={`/home/location/${_id}/edit`} className="card-subtitle tooltip" data-tip={t("edit-card-tooltip")}>
<Cog8ToothIcon className="h-[1em] w-[1em] absolute cursor-pointer top-[-.2rem] right-0 text-2xl" />
</Link>
<h2 className="card-title mr-[2em] mt-[-1em] text-[1rem]">{formatYearMonth(yearMonth)} {name}</h2>
{
bills.length > 0 ? (
<div className="card-actions mb-1">
{
bills.map(bill => <BillBadge key={`${_id}-${bill._id}`} locationId={_id} bill={bill} />)
}
</div>
) : null
}
<div className="flex justify-between items-center mb-0">
<Link href={`/home/bill/${_id}/add`} className="tooltip" data-tip={t("add-bill-button-tooltip")}>
<PlusCircleIcon className="h-[1em] w-[1em] cursor-pointer text-2xl inline" /><span className="text-xs ml-[0.2rem]">{t("add-bill-button-tooltip")}</span>
</Link>
<ShareIcon className="h-[1em] w-[1em] cursor-pointer text-2xl inline hover:text-red-500" title="create sharable link" onClick={handleCopyLinkClick} />
</div>
{ totalUnpaid > 0 || totalPayed > 0 || seenByTenantAt || utilBillsProofOfPayment?.uploadedAt ?
<>
<div className="flex ml-1">
<div className="divider divider-horizontal p-0 m-0"></div>
<div className="card rounded-box grid grow place-items-left place-items-top p-0">
{
totalUnpaid > 0 ?
<div className="flex ml-1">
<span className="w-5 min-w-5 mr-2"><ShoppingCartIcon className="mt-[.1rem]" /></span>
<span>
{t("total-due-label")}&nbsp;<strong>{formatCurrency(totalUnpaid, currency ?? "EUR")}</strong>
</span>
</div>
: null
}
{
totalPayed > 0 ?
<div className="flex ml-1">
<span className="w-5 min-w-5 mr-2"><BanknotesIcon className="mt-[.1rem]" /></span>
<span>
{t("total-payed-label")}&nbsp;<strong>{formatCurrency(totalPayed, currency ?? "EUR")}</strong>
<CheckCircleIcon className="h-5 w-5 ml-1 mt-[-.2rem] text-success inline-block" />
</span>
</div>
: null
}
{seenByTenantAt && (
<div className="flex mt-1 ml-1">
<span className="w-5 mr-2 min-w-5"><EyeIcon className="mt-[.1rem]" /></span>
<span>
<span>{t("seen-by-tenant-label")} at {seenByTenantAt.toLocaleString()}</span>
<CheckCircleIcon className="h-5 w-5 ml-1 mt-[-.2rem] text-success inline-block" />
</span>
</div>
)}
{utilBillsProofOfPayment?.uploadedAt && (
<Link
href={`/share/proof-of-payment/${_id}/`}
target="_blank"
className="flex mt-1 ml-1">
<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>
)}
</div>
</div>
</> : null
}
</div>
</div>);
};

View File

@@ -0,0 +1,79 @@
"use client";
import { FC, ReactNode, useState } from "react";
import { BillingLocation } from "../lib/db-types";
import { deleteLocationById } from "../lib/actions/locationActions";
import { useFormState } from "react-dom";
import Link from "next/link";
import { useTranslations } from "next-intl";
export interface LocationDeleteFormProps {
/** location which should be deleted */
location: BillingLocation
}
export const LocationDeleteForm:FC<LocationDeleteFormProps> = ({ location }) =>
{
const handleAction = deleteLocationById.bind(null, location._id, location.yearMonth);
const [ , dispatch ] = useFormState(handleAction, null);
const t = useTranslations("location-delete-form");
const [deleteInSubsequentMonths, setDeleteInSubsequentMonths] = useState(false);
return(
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<form action={dispatch}>
<p className="py-6 px-6">
{
t.rich("text", {
name:location.name,
strong: (chunks:ReactNode) => <strong>{chunks}</strong>,
})
}
</p>
<div className="form-control">
<label className="label cursor-pointer justify-center gap-4">
<span className="label-text">{t("delete-in-subsequent-months")}</span>
<input
type="checkbox"
name="deleteInSubsequentMonths"
className="toggle toggle-error"
checked={deleteInSubsequentMonths}
onChange={(e) => setDeleteInSubsequentMonths(e.target.checked)}
/>
</label>
</div>
{deleteInSubsequentMonths && (
<div className="border-l-4 border-error bg-error/10 p-4 mt-4 rounded-r max-w-[24rem]">
<div className="flex items-start gap-3">
<span className="text-xl flex-shrink-0"></span>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-error break-words">{t("warning-title")}</h3>
<div className="text-sm text-error/80 break-words">{t("warning-message")}</div>
</div>
</div>
</div>
)}
<div className="pt-4 text-center">
<button className="btn btn-primary w-[5.5em]">{t("confirm-button")}</button>
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/home/location/${location._id}/edit/`}>{t("cancel-button")}</Link>
</div>
</form>
</div>
</div>
);
}
export const LocationDeleteFormSkeleton:FC = () =>
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<p className="py-6 px-6"></p>
<div className="pt-4 text-center">
<div className="btn skeleton w-[5.5em]"></div>
<div className="btn ml-3 skeleton w-[5.5em]"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,436 @@
"use client";
import { TrashIcon } from "@heroicons/react/24/outline";
import { FC, useState } from "react";
import { BillingLocation, UserSettings, YearMonth } from "../lib/db-types";
import { updateOrAddLocation } from "../lib/actions/locationActions";
import { useFormState } from "react-dom";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { InfoBox } from "./InfoBox";
export type LocationEditFormProps = {
/** location which should be edited */
location: BillingLocation,
/** year adn month at a new billing location should be assigned */
yearMonth?: undefined,
/** user settings for payment configuration */
userSettings: UserSettings | null
} | {
/** location which should be edited */
location?: undefined,
/** year adn month at a new billing location should be assigned */
yearMonth: YearMonth,
/** user settings for payment configuration */
userSettings: UserSettings | null
}
export const LocationEditForm: FC<LocationEditFormProps> = ({ location, yearMonth, userSettings }) => {
const initialState = { message: null, errors: {} };
const handleAction = updateOrAddLocation.bind(null, location?._id, location?.yearMonth ?? yearMonth);
const [state, dispatch] = useFormState(handleAction, initialState);
const t = useTranslations("location-edit-form");
const locale = useLocale();
// Track tenant field values for real-time validation
const [formValues, setFormValues] = useState({
locationName: location?.name ?? "",
tenantName: location?.tenantName ?? "",
tenantStreet: location?.tenantStreet ?? "",
tenantTown: location?.tenantTown ?? "",
tenantEmail: location?.tenantEmail ?? "",
tenantPaymentMethod: location?.tenantPaymentMethod ?? "none",
proofOfPaymentType: location?.proofOfPaymentType ?? "none",
autoBillFwd: location?.autoBillFwd ?? false,
billFwdStrategy: location?.billFwdStrategy ?? "when-payed",
rentDueNotification: location?.rentDueNotification ?? false,
rentAmount: location?.rentAmount ?? "",
rentDueDay: location?.rentDueDay ?? 1,
});
const handleInputChange = (field: keyof typeof formValues, value: string | boolean | number) => {
setFormValues(prev => ({ ...prev, [field]: value }));
};
let { year, month } = location ? location.yearMonth : yearMonth;
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<form action={dispatch}>
{
location &&
<Link href={`/${locale}/home/location/${location._id}/delete`} className="absolute bottom-5 right-4 tooltip" data-tip={t("delete-tooltip")}>
<TrashIcon className="h-[1em] w-[1em] text-error text-2xl" />
</Link>
}
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-3 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("location-name-legend")}</legend>
<fieldset className="fieldset p-2 pt-0">
<input id="locationName"
name="locationName"
type="text"
placeholder={t("location-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
value={formValues.locationName}
onChange={(e) => handleInputChange("locationName", e.target.value)}
/>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.locationName &&
state.errors.locationName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("tenant-payment-instructions-legend")}</legend>
<InfoBox>{t("tenant-payment-instructions-code-info")}</InfoBox>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("tenant-payment-instructions-method--legend")}</legend>
<select
value={(!userSettings?.enableIbanPayment && !userSettings?.enableRevolutPayment) ? "none" : formValues.tenantPaymentMethod}
className="select input-bordered w-full"
name="tenantPaymentMethod"
onChange={(e) => handleInputChange("tenantPaymentMethod", e.target.value)}
>
<option value="none">{t("tenant-payment-instructions-method--none")}</option>
<option value="iban" disabled={!userSettings?.enableIbanPayment}>
{
userSettings?.enableIbanPayment ?
t("tenant-payment-instructions-method--iban") :
t("tenant-payment-instructions-method--iban-disabled")
}
</option>
<option value="revolut" disabled={!userSettings?.enableRevolutPayment}>
{
userSettings?.enableRevolutPayment ?
t("tenant-payment-instructions-method--revolut") :
t("tenant-payment-instructions-method--revolut-disabled")
}
</option>
</select>
</fieldset>
{formValues.tenantPaymentMethod === "iban" && userSettings?.enableIbanPayment ? (
<div className="animate-expand-fade-in origin-top">
<div className="divider mt-4 mb-2 font-bold uppercase">{t("iban-payment--form-title")}</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-payment--tenant-name-label")}</span>
</label>
<input
id="tenantName"
name="tenantName"
type="text"
maxLength={30}
placeholder={t("iban-payment--tenant-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.tenantName}
onChange={(e) => handleInputChange("tenantName", e.target.value)}
/>
<div id="tenantName-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantName &&
state.errors.tenantName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-payment--tenant-street-label")}</span>
</label>
<input
id="tenantStreet"
name="tenantStreet"
type="text"
maxLength={27}
placeholder={t("iban-payment--tenant-street-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.tenantStreet}
onChange={(e) => handleInputChange("tenantStreet", e.target.value)}
/>
<div id="tenantStreet-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantStreet &&
state.errors.tenantStreet.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full mb-4">
<label className="label">
<span className="label-text">{t("iban-payment--tenant-town-label")}</span>
</label>
<input
id="tenantTown"
name="tenantTown"
type="text"
maxLength={27}
placeholder={t("iban-payment--tenant-town-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.tenantTown}
onChange={(e) => handleInputChange("tenantTown", e.target.value)}
/>
<div id="tenantTown-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantTown &&
state.errors.tenantTown.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
</div>
) : // ELSE include hidden inputs to preserve existing values
<>
<input
id="tenantName"
name="tenantName"
type="hidden"
maxLength={30}
defaultValue={formValues.tenantName}
/>
<input
id="tenantStreet"
name="tenantStreet"
type="hidden"
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.tenantStreet}
/>
<input
id="tenantTown"
name="tenantTown"
type="hidden"
defaultValue={formValues.tenantTown}
/>
</>
}
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("proof-of-payment-attachment-type--legend")}</legend>
<InfoBox>{t("proof-of-payment-attachment-type--info")}</InfoBox>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("proof-of-payment-attachment-type--option--label")}</legend>
<select
value={formValues.proofOfPaymentType}
className="select input-bordered w-full"
name="proofOfPaymentType"
onChange={(e) => handleInputChange("proofOfPaymentType", e.target.value)}
>
<option value="none">{t("proof-of-payment-attachment-type--option--none")}</option>
<option value="combined">{t("proof-of-payment-attachment-type--option--combined")}</option>
<option value="per-bill">{t("proof-of-payment-attachment-type--option--per-bill")}</option>
</select>
{
formValues.tenantPaymentMethod === "none" && formValues.proofOfPaymentType === "combined" ?
<p className="mt-4 ml-4 text-sm w-full sm:w-[30rem] text-yellow-600">
{
t.rich("proof-of-payment-attachment-type--option--combined--hint",
{
strong: (children: React.ReactNode) => <strong>{children}</strong>
}
)
}
</p> :
<p className="mt-4 ml-4 text-sm w-full sm:w-[30rem] italic text-gray-500">
{
formValues.proofOfPaymentType === "combined" ?
t("proof-of-payment-attachment-type--option--combined--tooltip") :
t("proof-of-payment-attachment-type--option--per-bill--tooltip")
}
</p>
}
</fieldset>
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("auto-utility-bill-forwarding-legend")}</legend>
<InfoBox>{t("auto-utility-bill-forwarding-info")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
disabled={true}
type="checkbox"
name="autoBillFwd"
className="toggle toggle-primary"
checked={formValues.autoBillFwd}
onChange={(e) => handleInputChange("autoBillFwd", e.target.checked)}
/>
<legend className="fieldset-legend">{t("auto-utility-bill-forwarding-toggle-label")}</legend>
</label>
</fieldset>
{formValues.autoBillFwd && (
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("utility-bill-forwarding-strategy-label")}</legend>
<select defaultValue={formValues.billFwdStrategy} className="select input-bordered w-full" name="billFwdStrategy">
<option value="when-payed">{t("utility-bill-forwarding-when-payed")}</option>
<option value="when-attached">{t("utility-bill-forwarding-when-attached")}</option>
</select>
</fieldset>
)}
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("auto-rent-notification-legend")}</legend>
<InfoBox>{t("auto-rent-notification-info")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
disabled={true}
type="checkbox"
name="rentDueNotification"
className="toggle toggle-primary"
checked={formValues.rentDueNotification}
onChange={(e) => handleInputChange("rentDueNotification", e.target.checked)}
/>
<legend className="fieldset-legend">{t("auto-rent-notification-toggle-label")}</legend>
</label>
</fieldset>
{formValues.rentDueNotification && (
<div className="animate-expand-fade-in origin-top">
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("rent-due-day-label")}</legend>
<select defaultValue={formValues.rentDueDay}
className="select input-bordered w-full"
name="rentDueDay"
onChange={(e) => handleInputChange("rentDueDay", parseInt(e.target.value, 10))
}>
{Array.from({ length: 28 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}>{day}</option>
))}
</select>
</fieldset>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("rent-amount-label")}</legend>
<input
id="rentAmount"
name="rentAmount"
type="number"
min="1"
step="0.01"
placeholder={t("rent-amount-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600 text-right"
defaultValue={formValues.rentAmount}
onChange={(e) => handleInputChange("rentAmount", parseFloat(e.target.value))}
/>
<div id="rentAmount-error" aria-live="polite" aria-atomic="true">
{state.errors?.rentAmount &&
state.errors.rentAmount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>
</div>
)}
</fieldset>
{(formValues.autoBillFwd || formValues.rentDueNotification) && (
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase">{t("tenant-email-legend")}</legend>
<input
id="tenantEmail"
name="tenantEmail"
type="email"
placeholder={t("tenant-email-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.tenantEmail}
onChange={(e) => handleInputChange("tenantEmail", e.target.value)}
/>
<div id="tenantEmail-error" aria-live="polite" aria-atomic="true">
{state.errors?.tenantEmail &&
state.errors.tenantEmail.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>
)}
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("scope-legend")}</legend>
{!location ? (
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="addToSubsequentMonths"
className="toggle toggle-primary"
/>
<legend className="fieldset-legend">{t("add-to-subsequent-months")}</legend>
</label>
</fieldset>
) : (
<>
<InfoBox>{t("update-scope-info")}</InfoBox>
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend">{t("update-scope-legend")}</legend>
<select defaultValue="current" className="select input-bordered w-full" name="updateScope">
<option value="current">{t("update-current-month")}</option>
<option value="subsequent">{t("update-subsequent-months")}</option>
<option value="all">{t("update-all-months")}</option>
</select>
</fieldset>
</>
)}
</fieldset>
<div id="status-error" aria-live="polite" aria-atomic="true">
{
state.message &&
<p className="mt-2 text-sm text-red-500">
{state.message}
</p>
}
</div>
<div className="pt-4">
<button className="btn btn-primary w-[5.5em]">{t("save-button")}</button>
<Link className="btn btn-neutral w-[5.5em] ml-3" href={`/home?year=${year}&month=${month}`}>{t("cancel-button")}</Link>
</div>
</form>
</div>
</div>
)
}
export const LocationEditFormSkeleton: FC = () => {
const t = useTranslations("location-edit-form");
return (
<div className="card card-compact card-bordered min-w-[20em] max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<fieldset className="fieldset mt-2 p-2">
<legend className="fieldset-legend font-semibold uppercase">{t("location-name-legend")}</legend>
<div id="locationName" className="input w-full skeleton"></div>
</fieldset>
<div className="pt-4">
<div className="btn w-[5.5em] skeleton"></div>
<div className="btn w-[5.5em] ml-3 skeleton"></div>
</div>
</div>
</div>
)
}

25
web-app/app/ui/Main.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { FC } from "react";
import { PageHeader } from "./PageHeader";
import { PageFooter } from "./PageFooter";
import { NextIntlClientProvider, useMessages } from "next-intl";
export interface MainProps {
children: React.ReactNode;
}
export const Main:FC<MainProps> = ({ children }) => {
const message = useMessages();
return(
<NextIntlClientProvider messages={message}>
<main className="flex min-h-screen flex-col bg-base-300">
<PageHeader />
<div className="sm:mx-auto px-4">
{children}
</div>
<PageFooter />
</main>
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { FC, useEffect, useRef } from "react";
import { formatYearMonth } from "../lib/format";
import { YearMonth } from "../lib/db-types";
import { formatCurrency } from "../lib/formatStrings";
import { useTranslations } from "next-intl";
export interface MonthCardProps {
yearMonth: YearMonth,
children?: React.ReactNode,
unpaidTotal: number,
payedTotal: number,
currency?: string | null,
expanded?:boolean,
onToggle: (yearMonth:YearMonth) => void
}
export const MonthCard:FC<MonthCardProps> = ({ yearMonth, children, unpaidTotal, payedTotal, currency, expanded, onToggle }) => {
const elRef = useRef<HTMLDivElement>(null);
const t = useTranslations("home-page.month-card");
// Setting the `month` will activate the accordion belonging to that month
// If the accordion is already active, it will collapse it
const handleChange = (event:any) => onToggle(yearMonth);
useEffect(() => {
if(expanded && elRef.current) {
// if the element i selected > scroll it into view
elRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [expanded]);
return(
<div className={`collapse collapse-plus bg-base-200 my-1 sm:min-w-[25em] ${expanded ? "border-2 border-neutral" : ""}`} ref={elRef}>
<input type="checkbox" name="my-accordion-3" checked={expanded} onChange={handleChange} />
<div className={`collapse-title text-xl font-medium ${expanded ? "text-white" : ""}`}>
{`${formatYearMonth(yearMonth)}`}
{
unpaidTotal>0 ?
<p className="text-xs font-medium">
{t("total-due-label")} <strong>{ formatCurrency(unpaidTotal, currency ?? "EUR") }</strong>
</p> : null
}
{
payedTotal>0 ?
<p className="text-xs font-medium">
{t("total-payed-label")} <strong>{ formatCurrency(payedTotal, currency ?? "EUR") }</strong>
</p> : null
}
</div>
<div className="collapse-content">
{children}
</div>
</div>
)
};

View File

@@ -0,0 +1,23 @@
import { Cog8ToothIcon, PlusCircleIcon } from "@heroicons/react/24/outline";
import React from "react"
const LocationCardSkeleton: React.FC = () =>
<div className={`skeleton card card-compact card-bordered max-w-[30em] bg-base-100 shadow-s my-1`}>
<div className="card-body">
<h2 className="card-title mr-[2em]"></h2>
</div>
</div>;
export interface MonthCardSkeletonProps {
checked?: boolean;
}
export const MonthCardSkeleton: React.FC<MonthCardSkeletonProps> = ({checked=false}) =>
<div className={`skeleton collapse collapse-plus bg-base-200 my-1`}>
<input type="checkbox" name="my-accordion-3" checked={checked} disabled />
<div className="text-xl w-[15em]"></div>
<div className="collapse-content">
<LocationCardSkeleton />
<LocationCardSkeleton />
</div>
</div>;

View File

@@ -0,0 +1,148 @@
"use client";
import React from "react";
import { AddLocationButton } from "./AddLocationButton";
import { AddMonthButton } from "./AddMonthButton";
import { MonthCard } from "./MonthCard";
import Pagination from "./Pagination";
import { LocationCard } from "./LocationCard";
import { PrintButton } from "./PrintButton";
import { BillingLocation, UserSettings, YearMonth } from "../lib/db-types";
import { useRouter, useSearchParams } from "next/navigation";
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { useTranslations } from "next-intl";
import { MultiBillEditButton } from "../[locale]/home/multi-bill-edit/[year]/[month]/MultiBillEditButton";
const getNextYearMonth = (yearMonth:YearMonth) => {
const {year, month} = yearMonth;
return({
year: month===12 ? year+1 : year,
month: month===12 ? 1 : month+1
} as YearMonth);
}
export interface MonthLocationListProps {
availableYears?: number[];
months?: {
[key: string]: {
yearMonth: YearMonth;
locations: BillingLocation[];
payedTotal: number;
unpaidTotal: number;
};
};
userSettings?: UserSettings | null;
}
export const MonthLocationList:React.FC<MonthLocationListProps > = ({
availableYears,
months,
userSettings,
}) => {
const router = useRouter();
const search = useSearchParams();
const t = useTranslations("home-page");
const initialMonth = search.get('month')
const [expandedMonth, setExpandedMonth] = React.useState<number>(
initialMonth ? parseInt(initialMonth as string) : -1 // no month is expanded
);
// Check for success messages
React.useEffect(() => {
const params = new URLSearchParams(search.toString());
let messageShown = false;
if (search.get('userSettingsSaved') === 'true') {
toast.success(t("user-settings-saved-message"), { theme: "dark" });
params.delete('userSettingsSaved');
messageShown = true;
}
if (search.get('billSaved') === 'true') {
toast.success(t("bill-saved-message"), { theme: "dark" });
params.delete('billSaved');
messageShown = true;
}
if (search.get('billDeleted') === 'true') {
toast.success(t("bill-deleted-message"), { theme: "dark" });
params.delete('billDeleted');
messageShown = true;
}
if (search.get('locationSaved') === 'true') {
toast.success(t("location-saved-message"), { theme: "dark" });
params.delete('locationSaved');
messageShown = true;
}
if (search.get('locationDeleted') === 'true') {
toast.success(t("location-deleted-message"), { theme: "dark" });
params.delete('locationDeleted');
messageShown = true;
}
if (search.get('bill-multi-edit-saved') === 'true') {
toast.success(t("bill-multi-edit-save-success-message"), { theme: "dark" });
params.delete('bill-multi-edit-saved');
messageShown = true;
}
}, [search, router, t]);
if(!availableYears || !months) {
const currentYearMonth:YearMonth = {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1
};
return(
<>
<MonthCard yearMonth={currentYearMonth} key={`month-${currentYearMonth}`} unpaidTotal={0} payedTotal={0} currency={userSettings?.currency} onToggle={() => {}} expanded={true} >
<AddLocationButton yearMonth={currentYearMonth} />
</MonthCard>
</>)
};
const monthsArray = Object.entries(months);
// when the month is toggled, update the URL
// and set the the new expandedMonth
const handleMonthToggle = (yearMonth:YearMonth) => {
// if the month is already expanded, collapse it
if(expandedMonth === yearMonth.month) {
// router.push(`/home?year=${yearMonth.year}`);
setExpandedMonth(-1); // no month is expanded
} else {
// router.push(`/home?year=${yearMonth.year}&month=${yearMonth.month}`);
setExpandedMonth(yearMonth.month);
}
}
return(<>
<AddMonthButton yearMonth={getNextYearMonth(monthsArray[0][1].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} >
{
yearMonth.month === expandedMonth ?
locations.map((location, ix) => <LocationCard key={`location-${location._id}`} location={location} currency={userSettings?.currency} />)
: null
}
<div className="flex flex-col sm:flex-row sm:gap-2 justify-center">
<AddLocationButton yearMonth={yearMonth} />
<PrintButton yearMonth={yearMonth} />
<MultiBillEditButton yearMonth={yearMonth} />
</div>
</MonthCard>
)
}
<div className="mt-5 flex w-full justify-center">
<Pagination availableYears={availableYears} />
</div>
<ToastContainer />
</>)
}

View File

@@ -0,0 +1,6 @@
import React from "react";
export const MultiParagraphText: React.FC<{ text: string }> = ({ text }) =>
text.split("\n").map((line, index) => (
<p key={index} className="p mt-[1em] max-w-[38em] mx-auto text-justify">{line}</p>));

View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
import { FaceFrownIcon } from "@heroicons/react/24/outline";
import { FC } from 'react';
export interface NotFoundPageProps {
title?:string,
description?:string,
}
export const NotFoundPage:FC<NotFoundPageProps> = ({ title="404 Not Found", description="Could not find the requested content." }) =>
<main className="flex h-full flex-col items-center justify-center gap-2">
<FaceFrownIcon className="w-10 text-gray-400" />
<h2 className="text-xl font-semibold">{title}</h2>
<p>{description}</p>
<Link href="/home" className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400">
Go Back
</Link>
</main>

View File

@@ -0,0 +1,8 @@
import { CogIcon } from "@heroicons/react/24/outline";
import { FC, ReactNode } from "react";
export const NoteBox: FC<{ children: ReactNode }> = ({ children }) =>
<div className="group border border-gray-800 rounded-lg p-2 mb-1 max-w-md">
<div className="mt-2 italic text-sm"><CogIcon className="w-6 h-6 inline mt-[-.3em] text-blue-400" /> {children}</div>
</div>

View File

@@ -0,0 +1,30 @@
import Image from "next/image";
import Link from "next/link";
import React from "react";
import { useTranslations } from "next-intl";
import { getLocale } from "next-intl/server";
export const PageFooter: React.FC = async () => {
const t = useTranslations("PageFooter");
const locale = await getLocale();
return(
<div className="bg-base-100 text-base-content mt-10">
<footer className="footer mx-auto max-w-2xl px-4 py-10">
<div>
<div className="flex items-center gap-2">
<Image src="/icon4.png" alt="logo" width={64} height={64}></Image>
<div className="font-title inline-flex text-3xl font-black ml-2">Režije</div>
</div>
<p>{t('app-description')}</p>
<div className="flex gap-4">
<Link href="/" className="link link-hover">{t('links.home')}</Link>
<Link href={`/${locale}/privacy-policy/`} className="link link-hover">{t('links.privacy-policy')}</Link>
<Link href={`/${locale}/terms-of-service/`} className="link link-hover">{t('links.terms-of-service')}</Link>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { SelectLanguage } from "./SelectLanguage";
import AccountCircle from "@mui/icons-material/AccountCircle";
import { useLocale } from "next-intl";
import { usePathname } from "next/navigation";
export const PageHeader = () => {
const locale = useLocale();
const pathname = usePathname();
const isRestrictedPage = pathname.includes('/home');
return (
<div className="navbar bg-base-100 mb-6">
<Link className="btn btn-ghost text-xl" href="/"><Image src="/icon3.png" alt="logo" width={48} height={48} /> Režije</Link>
<span className="grow">&nbsp;</span>
<SelectLanguage />
{isRestrictedPage && (
<Link href={`/${locale}/home/account/`} className="btn btn-ghost btn-circle">
<AccountCircle fontSize="large" />
</Link>
)}
</div>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
export const generatePagination = (availableYears: number[], currentYear:number) => {
const [firstYear, secondYear, thirdYear] = availableYears;
const [lastYear, yearBeforeLast1, yearBeforeLast2] = [...availableYears].reverse();
const currentYearIndex = availableYears.indexOf(currentYear);
// If the current year is among the first 3 years,
// show the first 3 and ellipsis
if (currentYearIndex < 2) {
return [firstYear, secondYear, thirdYear, '...'];
}
// If the current year is among the last 2 years,
// ellipsis and last 3 years.
if (currentYearIndex > availableYears.length - 3) {
return ['...', yearBeforeLast2, yearBeforeLast1, lastYear];
}
// If the current year is somewhere in the middle,
// show the first year, an ellipsis, the current year and its neighbors,
// another ellipsis, and the last year.
return [
'...',
availableYears[currentYearIndex - 1],
currentYear,
availableYears[currentYearIndex + 1],
'...',
];
};
export default function Pagination({ availableYears } : { availableYears: number[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
// don't show pagination if there's less than 2 years available
if(availableYears.length < 2) {
return null;
}
const showAllPages = availableYears.length < 5;
const createYearURL = (yearNumber: number | string | undefined) => {
if(!yearNumber) return "/";
const params = new URLSearchParams(searchParams);
params.set('year', yearNumber.toString());
return `${pathname}?${params.toString()}`;
}
const currentYear = Number(searchParams.get('year')) || 1;
const selectedYears = showAllPages ? availableYears : generatePagination(availableYears, currentYear);
const latestYear = availableYears[0];
const earliestYear = availableYears[availableYears.length-1];
return (
<>
<div className="join">
{
// If there are more than 3 years, show the left arrow.
!showAllPages &&
<YearArrow
direction="left"
href={createYearURL(latestYear)}
isDisabled={currentYear === latestYear}
/>
}
<div className="flex -space-x-px">
{selectedYears.map((year, index) => {
let position: 'first' | 'last' | 'single' | 'middle' | undefined;
if (index === 0) position = 'first';
if (index === selectedYears.length - 1) position = 'last';
if (selectedYears.length === 1) position = 'single';
if (year === '...') position = 'middle';
return (
<YearNumber
key={`year-number-${year}-${index}`}
href={createYearURL(year)}
year={year}
position={position}
isActive={currentYear === year}
/>
);
})}
</div>
{
// If there are more than 3 years, show the left arrow.
!showAllPages &&
<YearArrow
direction="right"
href={createYearURL(earliestYear)}
isDisabled={currentYear === earliestYear}
/>
}
</div>
</>
);
}
function YearNumber({
year,
href,
isActive,
position,
}: {
year: number | string;
href: string;
position?: 'first' | 'last' | 'middle' | 'single';
isActive: boolean;
}) {
return(<Link href={href} className={`join-item btn ${ isActive ? 'btn-active' : '' } ${ position === 'middle' ? 'btn-disabled' : '' }`}>{year}</Link>);
}
function YearArrow({
href,
direction,
isDisabled,
}: {
href: string;
direction: 'left' | 'right';
isDisabled?: boolean;
}) {
const icon = direction === 'left' ? "⏴" : "⏵";
return (<Link className={`join-item btn ${isDisabled ? "btn-disabled" : ""}`} href={href}>{icon}</Link>);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useState, useEffect, FC } from 'react';
import { generateBarcode } from '../lib/pdf/pdf417';
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);
useEffect(() => {
const aspectRatio = 3;
const errorCorrectionLevel = 4; // error correction 4 is common for HUB3A PDF417 barcodes
const barcodeMatrix = generateBarcode(hub3a_text, errorCorrectionLevel ?? 4 , aspectRatio);
const bitmap = renderBarcode(barcodeMatrix, 4, 3); // 4:3 block size is common for HUB3A PDF417 barcodes
setBitmapData(bitmap);
}, [hub3a_text]);
// Don't render until bitmap is generated (prevents hydration mismatch)
if (!bitmapData) {
return (
<div style={{ width: "350px", height: "92px" }} className="flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={bitmapData} alt="PDF417 Barcode" className={className} />
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState, useEffect, FC } from 'react';
import { writeBarcode, prepareZXingModule, type WriterOptions } from 'zxing-wasm/writer';
// Configure WASM file location for writer
prepareZXingModule({
overrides: {
locateFile: (path, prefix) => {
if (path.endsWith('.wasm')) {
return window.location.origin + '/zxing_writer.wasm';
}
return prefix + path;
}
}
});
export const Pdf417BarcodeWasm: FC<{ hub3aText: string, className?: string }> = ({ hub3aText, className }) => {
const [barcodeDataUrl, setBarcodeDataUrl] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
const generateBarcode = async () => {
try {
setError(undefined);
setBarcodeDataUrl(undefined);
const writerOptions: WriterOptions = {
format: 'PDF417',
ecLevel: "5",
scale: 2,
};
const result = await writeBarcode(hub3aText, writerOptions);
// Convert PNG blob to data URL
const reader = new FileReader();
reader.onloadend = () => {
setBarcodeDataUrl(reader.result as string);
};
reader.readAsDataURL(result.image as Blob);
} catch (err) {
console.error('Failed to generate PDF417 barcode:', err);
setError('Failed to generate barcode');
}
};
if (hub3aText) {
generateBarcode();
}
}, [hub3aText]);
// Show error state
if (error) {
return (
<div style={{ width: "350px", height: "92px" }} className="flex items-center justify-center">
<span className="text-error text-sm">{error}</span>
</div>
);
}
// Don't render until barcode is generated (prevents hydration mismatch)
if (!barcodeDataUrl) {
return (
<div style={{ width: "350px", height: "92px" }} className="flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={barcodeDataUrl} alt="PDF417 Barcode" className={className} />
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { PrinterIcon } from '@heroicons/react/24/outline';
import { useTranslations } from 'next-intl';
import { YearMonth } from '../lib/db-types';
export interface PrintButtonProps {
yearMonth: YearMonth;
className?: string;
}
export const PrintButton: React.FC<PrintButtonProps> = ({ yearMonth, className }) => {
const t = useTranslations("home-page.month-card");
const handlePrintClick = () => {
window.open(`/home/print/${yearMonth.year}/${yearMonth.month}`, '_blank');
};
return (
<div className={`card card-compact card-bordered bg-base-100 shadow-s my-1 ${className}`}>
<button
className="card-body tooltip self-center cursor-pointer"
onClick={handlePrintClick}
data-tip={t("print-codes-tooltip")}
>
<span className='flex self-center'>
<PrinterIcon className="h-[1em] w-[1em] cursor-pointer text-4xl" />
<span className="ml-1 self-center text-sm sm:text-xs text-left sm:w-[5.5em] leading-[1.3em]">{t("print-codes-label")}</span>
</span>
</button>
</div>
);
};

View File

@@ -0,0 +1,158 @@
'use client';
import { PrintBarcodeData } from '../lib/actions/printActions';
import { Pdf417Barcode } from './Pdf417Barcode';
export interface PrintPreviewProps {
data: PrintBarcodeData[];
year: number;
month: number;
translations: {
title: string;
barcodesFound: string;
barcodeSingular: string;
printButton: string;
printFooter: string;
tableHeaderIndex: string;
tableHeaderBillInfo: string;
tableHeaderBarcode: string;
};
}
export const PrintPreview: React.FC<PrintPreviewProps> = ({ data, year, month, translations }) => {
return (
<>
{/* Print-specific CSS styles */}
<style jsx global>{`
html {
background-color: white !important;
color-scheme: light !important;
}
`}</style>
<style jsx>{`
@media print {
@page {
size: A4;
margin: 0;
}
body {
-webkit-print-color-adjust: exact !important;
color-adjust: exact !important;
print-color-adjust: exact !important;
}
.print-table {
page-break-inside: avoid;
}
.print-table tr {
page-break-inside: avoid;
page-break-after: auto;
}
.print-table thead {
display: table-header-group;
}
/* Optimize for B&W printing */
* {
color: black !important;
background: white !important;
box-shadow: none !important;
text-shadow: none !important;
}
.print-table th,
.print-table td {
border: 2px solid black !important;
background: white !important;
vertical-align: top;
}
.print-table th {
padding: 8px 12px !important;
}
.print-table thead tr {
background: #f5f5f5 !important;
}
}
`}</style>
<div className="min-h-screen bg-white">
{/* Header section - hidden in print */}
<div className="p-6 border-b border-gray-200 print:hidden">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{translations.title}
</h1>
<p className="text-lg text-gray-600 mb-4">
{year}-{month.toString().padStart(2, '0')} {data.length} {data.length === 1 ? translations.barcodeSingular : translations.barcodesFound}
</p>
<button
onClick={() => window.print()}
className="btn btn-primary"
>
🖨 {translations.printButton}
</button>
</div>
{/* Print content */}
<div className="p-8">
<table className="w-full border-collapse border-2 border-gray-800 print-table">
<thead>
<tr className="bg-gray-100">
<th className="border-2 border-gray-800 px-3 py-2 text-center font-bold text-sm w-16">
{translations.tableHeaderIndex}
</th>
<th className="border-2 border-gray-800 px-3 py-2 text-left font-bold text-sm">
{translations.tableHeaderBillInfo}
</th>
<th className="border-2 border-gray-800 px-3 py-2 text-center font-bold text-sm w-64">
{translations.tableHeaderBarcode}
</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr key={`${item.locationName}-${item.billName}`} className="hover:bg-gray-50">
<td className="border-2 border-gray-800 px-3 py-4 text-center font-mono text-sm font-medium">
{(index + 1).toString().padStart(2, '0')}
</td>
<td className="border-2 border-gray-800 px-3 py-4">
<div className="space-y-1">
<div className="font-medium text-sm text-gray-800">
🏠 {item.locationName}
</div>
<div className="text-sm text-gray-700">
📋 {item.billName}
</div>
{item.payedAmount && (
<div className="text-sm text-gray-700">
💰 {(item.payedAmount / 100).toFixed(2)}
</div>
)}
</div>
</td>
<td className="border-2 border-gray-800 px-3 py-1.5 text-center">
<div className="flex justify-center items-center">
{
item.hub3aText ? <Pdf417Barcode hub3aText={item.hub3aText} className="print:m-[5em_auto]" /> : null
}
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Print footer - only visible when printing */}
<div className="mt-6 text-center text-xs text-gray-500 hidden print:block">
<p>{translations.printFooter}</p>
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,16 @@
"use client";
import { useLocale } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { defaultLocale, localeNames, locales } from "../i18n";
export const SelectLanguage: React.FC = () => {
const currentPathname = usePathname();
const currentLocale = useLocale();
const secondLocale = locales.find((l) => l !== currentLocale) as string;
const secondLocalePathname = defaultLocale === currentLocale ? `/${secondLocale}${currentPathname}` : currentPathname.replace(`/${currentLocale}`, `/${secondLocale}`);
return (<a className="btn btn-ghost text-xl self-end" href={secondLocalePathname}>{localeNames[secondLocale]}</a>);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { signIn } from "next-auth/react"
import { useTranslations } from "next-intl";
import Image from "next/image";
const providerLogo = (provider: {id:string, name:string}) => {
switch(provider.id) {
case "google": return "https://authjs.dev/img/providers/google.svg";
case "facebook": return "https://authjs.dev/img/providers/facebook.svg";
case "github": return "https://authjs.dev/img/providers/github.svg";
case "twitter": return "https://authjs.dev/img/providers/twitter.svg";
case "email": return "https://authjs.dev/img/providers/email.svg";
case "linkedin": return "https://authjs.dev/img/providers/linkedin.svg";
default: return "https://authjs.dev/img/providers/google.svg";
}
}
export const SignInButton:React.FC<{ provider: {id:string, name:string} }> = ({ provider }) => {
const t = useTranslations("login-page");
return(
<button className="btn btn-neutral m-1" onClick={() => signIn(provider.id, { callbackUrl:"/home" }) } data-umami-event="user-login">
<Image alt="Provider Logo" loading="lazy" height="24" width="24" id="provider-logo-dark" src={providerLogo(provider)} />
<span>
{t("sign-in-button")} {provider.name}</span>
</button>
);
}

View File

@@ -0,0 +1,403 @@
"use client";
import { FC, useState } from "react";
import { UserSettings } from "../lib/db-types";
import { updateUserSettings } from "../lib/actions/userSettingsActions";
import { useFormState, useFormStatus } from "react-dom";
import { useLocale, useTranslations } from "next-intl";
import Link from "next/link";
import SettingsIcon from "@mui/icons-material/Settings";
import { formatIban } from "../lib/formatStrings";
import { InfoBox } from "./InfoBox";
import { NoteBox } from "./NoteBox";
import { LinkIcon } from "@heroicons/react/24/outline";
export type UserSettingsFormProps = {
userSettings: UserSettings | null;
}
type FormFieldsProps = {
userSettings: UserSettings | null;
errors: any;
message: string | null;
}
const FormFields: FC<FormFieldsProps> = ({ userSettings, errors, message }) => {
const { pending } = useFormStatus();
const t = useTranslations("user-settings-form");
const locale = useLocale();
// Track current form values for real-time validation
const [formValues, setFormValues] = useState({
enableIbanPayment: userSettings?.enableIbanPayment ?? false,
ownerName: userSettings?.ownerName ?? "",
ownerStreet: userSettings?.ownerStreet ?? "",
ownerTown: userSettings?.ownerTown ?? "",
ownerIBAN: formatIban(userSettings?.ownerIBAN) ?? "",
currency: userSettings?.currency ?? "EUR",
enableRevolutPayment: userSettings?.enableRevolutPayment ?? false,
ownerRevolutProfileName: userSettings?.ownerRevolutProfileName ?? "",
});
// https://revolut.me/aderezic?currency=EUR&amount=70000
const handleInputChange = (field: keyof typeof formValues, value: string | boolean) => {
setFormValues(prev => ({ ...prev, [field]: value }));
};
return (
<>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pt-1 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("general-settings-legend")}</legend>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("currency-label")}</span>
</label>
<select
id="currency"
name="currency"
className="select select-bordered w-full"
defaultValue={formValues.currency}
onChange={(e) => handleInputChange("currency", e.target.value)}
disabled={pending}
>
<option value="EUR">EUR - Euro</option>
<option value="USD">USD - US Dollar</option>
<option value="GBP">GBP - British Pound</option>
<option value="CHF">CHF - Swiss Franc</option>
<option value="JPY">JPY - Japanese Yen</option>
<option value="CAD">CAD - Canadian Dollar</option>
<option value="AUD">AUD - Australian Dollar</option>
<option value="NZD">NZD - New Zealand Dollar</option>
<option value="CNY">CNY - Chinese Yuan</option>
<option value="HKD">HKD - Hong Kong Dollar</option>
<option value="SGD">SGD - Singapore Dollar</option>
<option value="SEK">SEK - Swedish Krona</option>
<option value="NOK">NOK - Norwegian Krone</option>
<option value="DKK">DKK - Danish Krone</option>
<option value="PLN">PLN - Polish Zloty</option>
<option value="CZK">CZK - Czech Koruna</option>
<option value="HUF">HUF - Hungarian Forint</option>
<option value="RON">RON - Romanian Leu</option>
<option value="BGN">BGN - Bulgarian Lev</option>
<option value="RSD">RSD - Serbian Dinar</option>
<option value="BAM">BAM - Bosnia-Herzegovina Mark</option>
<option value="MKD">MKD - Macedonian Denar</option>
<option value="ALL">ALL - Albanian Lek</option>
<option value="TRY">TRY - Turkish Lira</option>
<option value="RUB">RUB - Russian Ruble</option>
<option value="UAH">UAH - Ukrainian Hryvnia</option>
<option value="INR">INR - Indian Rupee</option>
<option value="BRL">BRL - Brazilian Real</option>
<option value="MXN">MXN - Mexican Peso</option>
<option value="ZAR">ZAR - South African Rand</option>
<option value="KRW">KRW - South Korean Won</option>
<option value="THB">THB - Thai Baht</option>
<option value="MYR">MYR - Malaysian Ringgit</option>
<option value="IDR">IDR - Indonesian Rupiah</option>
<option value="PHP">PHP - Philippine Peso</option>
</select>
<div id="currency-error" aria-live="polite" aria-atomic="true">
{errors?.currency &&
errors.currency.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("iban-payment-instructions--legend")}</legend>
<InfoBox>{t("iban-payment-instructions--intro-message")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enableIbanPayment"
className="toggle toggle-primary"
checked={formValues.enableIbanPayment}
onChange={(e) => handleInputChange("enableIbanPayment", e.target.checked)}
/>
<legend className="fieldset-legend">{t("iban-payment-instructions--toggle-label")}</legend>
</label>
</fieldset>
{ formValues.enableIbanPayment ? (
<div className="animate-expand-fade-in origin-top">
<div className="divider mt-2 mb-2 font-bold uppercase">{t("iban-form-title")}</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-owner-name-label")}</span>
</label>
<input
id="ownerName"
name="ownerName"
type="text"
maxLength={25}
placeholder={t("iban-owner-name-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.ownerName}
onChange={(e) => handleInputChange("ownerName", e.target.value)}
disabled={pending}
/>
<div id="ownerName-error" aria-live="polite" aria-atomic="true">
{errors?.ownerName &&
errors.ownerName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-owner-street-label")} </span>
</label>
<input
id="ownerStreet"
name="ownerStreet"
type="text"
maxLength={25}
placeholder={t("iban-owner-street-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.ownerStreet}
onChange={(e) => handleInputChange("ownerStreet", e.target.value)}
disabled={pending}
/>
<div id="ownerStreet-error" aria-live="polite" aria-atomic="true">
{errors?.ownerStreet &&
errors.ownerStreet.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-owner-town-label")}</span>
</label>
<input
id="ownerTown"
name="ownerTown"
type="text"
maxLength={27}
placeholder={t("iban-owner-town-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.ownerTown}
onChange={(e) => handleInputChange("ownerTown", e.target.value)}
disabled={pending}
/>
<div id="ownerTown-error" aria-live="polite" aria-atomic="true">
{errors?.ownerTown &&
errors.ownerTown.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("iban-owner-iban-label")}</span>
</label>
<input
id="ownerIBAN"
name="ownerIBAN"
type="text"
placeholder={t("iban-owner-iban-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.ownerIBAN}
onChange={(e) => handleInputChange("ownerIBAN", e.target.value)}
disabled={pending}
/>
<div id="ownerIBAN-error" aria-live="polite" aria-atomic="true">
{errors?.ownerIBAN &&
errors.ownerIBAN.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<NoteBox>{t("payment-additional-notes")}</NoteBox>
</div>
) : // ELSE include hidden inputs to preserve existing values
<>
<input
id="ownerName"
name="ownerName"
type="hidden"
value={formValues.ownerName}
/>
<input
id="ownerStreet"
name="ownerStreet"
type="hidden"
value={formValues.ownerStreet}
/>
<input
id="ownerTown"
name="ownerTown"
type="hidden"
defaultValue={formValues.ownerTown}
/>
<input
id="ownerIBAN"
name="ownerIBAN"
type="hidden"
defaultValue={formValues.ownerIBAN}
/>
</>
}
</fieldset>
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 mt-4">
<legend className="fieldset-legend font-semibold uppercase text-base">{t("revolut-payment-instructions--legend")}</legend>
<InfoBox>{t("revolut-payment-instructions--intro-message")}</InfoBox>
<fieldset className="fieldset">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="enableRevolutPayment"
className="toggle toggle-primary"
checked={formValues.enableRevolutPayment}
onChange={(e) => handleInputChange("enableRevolutPayment", e.target.checked)}
/>
<legend className="fieldset-legend">{t("revolut-payment-instructions--toggle-label")}</legend>
</label>
</fieldset>
{ formValues.enableRevolutPayment ? (
<div className="animate-expand-fade-in origin-top">
<div className="divider mt-2 mb-2 font-bold uppercase max-w-[14em]">{t("revolut-form-title")}</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("revolut-profile-label")}</span>
</label>
<input
id="ownerRevolutProfileName"
name="ownerRevolutProfileName"
type="text"
maxLength={25}
placeholder={t("revolut-profile-placeholder")}
className="input input-bordered w-full placeholder:text-gray-600"
defaultValue={formValues.ownerRevolutProfileName}
onChange={(e) => handleInputChange("ownerRevolutProfileName", e.target.value)}
disabled={pending}
/>
<label className="label">
<span className="label-text-alt text-gray-500 max-w-[25rem]">{t("revolut-profile-tooltip")}</span>
</label>
<div id="ownerRevolutProfileName-error" aria-live="polite" aria-atomic="true">
{errors?.ownerRevolutProfileName &&
errors.ownerRevolutProfileName.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
{
!errors?.ownerRevolutProfileName && formValues.ownerRevolutProfileName.length > 5 ? (
<p className="p-2 text-center">
{t("revolut-profile--test-link-label")} {' '}
<LinkIcon className="h-[1.2em] w-[1.2em] inline-block ml-1 mr-1"/>
<Link
href={`https://revolut.me/${formValues.ownerRevolutProfileName?.replace('@', '')}?amount=100&currency=${formValues.currency}`}
target="_blank"
className="underline"
>
{t("revolut-profile--test-link-text")}
</Link>
</p>
) : null
}
</div>
<NoteBox>{t("payment-additional-notes")}</NoteBox>
</div>
)
: // ELSE include hidden input to preserve existing value
<>
<input
id="ownerRevolutProfileName"
name="ownerRevolutProfileName"
type="hidden"
value={formValues.ownerRevolutProfileName}
/>
</>
}
</fieldset>
<div id="general-error" aria-live="polite" aria-atomic="true">
{message && (
<p className="mt-2 text-sm text-red-500">
{message}
</p>
)}
</div>
<div className="pt-4">
<button className="btn btn-primary w-[5.5em]" disabled={pending}>
{pending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
t("save-button")
)}
</button>
<Link className={`btn btn-neutral w-[5.5em] ml-3 ${pending ? "btn-disabled" : ""}`} href={`/${locale}/home/account`}>
{t("cancel-button")}
</Link>
</div>
</>
);
};
export const UserSettingsForm: FC<UserSettingsFormProps> = ({ userSettings }) => {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(updateUserSettings, initialState);
const t = useTranslations("user-settings-form");
return (
<div className="card card-compact card-bordered max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<h2 className="card-title"><SettingsIcon className="w-6 h-6" /> {t("title")}</h2>
<form action={dispatch}>
<FormFields
userSettings={userSettings}
errors={state.errors}
message={state.message ?? null}
/>
</form>
</div>
</div>
);
};
export const UserSettingsFormSkeleton: FC = () => {
return (
<div className="card card-compact card-bordered max-w-[90em] bg-base-100 shadow-s my-1">
<div className="card-body">
<div className="h-8 w-32 skeleton mb-4"></div>
<div className="input w-full skeleton"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="textarea w-full h-24 skeleton mt-4"></div>
<div className="input w-full skeleton mt-4"></div>
<div className="pt-4">
<div className="btn skeleton w-24"></div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import { FC } from "react";
import { Bill } from "@/app/lib/db-types";
import Link from "next/link";
import { TicketIcon } from "@heroicons/react/24/outline";
import { useLocale } from "next-intl";
export interface ViewBillBadgeProps {
locationId: string;
shareId?: string;
bill: Bill;
};
export const ViewBillBadge: FC<ViewBillBadgeProps> = ({ locationId, shareId, bill: { _id: billId, name, paid, attachment, proofOfPayment } }) => {
const currentLocale = useLocale();
const className = `badge badge-lg p-[1em] ${paid ? "badge-success" : " badge-outline"} ${!paid && !!attachment ? "btn-outline btn-success" : ""} cursor-pointer`;
// Use shareId if available (for shared views), otherwise use locationId (for owner views)
const billPageId = shareId || locationId;
return (
<Link href={`/${currentLocale}//share/bill/${billPageId}-${billId}`} className={className}>
{name}
{
proofOfPayment?.uploadedAt ?
<TicketIcon className="h-[1em] w-[1em] inline-block ml-1" /> : null
}
</Link>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { TicketIcon, CheckCircleIcon, XCircleIcon, DocumentIcon } from "@heroicons/react/24/outline";
import { Bill, BillingLocation } from "../lib/db-types";
import { FC, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { formatYearMonth } from "../lib/format";
import { useTranslations } from "next-intl";
import { uploadProofOfPayment } from "../lib/actions/billActions";
import { Pdf417Barcode } from "./Pdf417Barcode";
export interface ViewBillCardProps {
location: BillingLocation;
bill: Bill;
shareId?: string;
}
export const ViewBillCard: FC<ViewBillCardProps> = ({ location, bill, shareId }) => {
const router = useRouter();
const t = useTranslations("bill-edit-form");
const { _id: billID, name, paid, attachment, notes, payedAmount, hub3aText, proofOfPayment } = bill ?? { _id: undefined, name: "", paid: false, notes: "" };
const { _id: locationID, proofOfPaymentType } = location;
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [proofOfPaymentUploadedAt, setProofOfPaymentUploadedAt] = useState<Date | null>(proofOfPayment?.uploadedAt ?? null);
const [proofOfPaymentFilename, setProofOfPaymentFilename] = useState(proofOfPayment?.fileName);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type client-side (quick feedback)
if (file.type !== 'application/pdf') {
setUploadError('Only PDF files are accepted');
e.target.value = ''; // Reset input
return;
}
if (!shareId) {
setUploadError('Invalid upload link');
return;
}
setIsUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('proofOfPayment', file);
const result = await uploadProofOfPayment(shareId, billID as string, formData);
if (result.success) {
setProofOfPaymentFilename(file.name);
setProofOfPaymentUploadedAt(new Date());
router.refresh();
} else {
setUploadError(result.error || 'Upload failed');
}
} catch (error: any) {
setUploadError(error.message || 'Upload failed');
} finally {
setIsUploading(false);
e.target.value = ''; // Reset input
}
};
return (
<div className="card card-compact card-bordered bg-base-100 shadow-s">
<div className="card-body">
<h2 className="card-title">{`${formatYearMonth(location.yearMonth)} ${location.name}`}</h2>
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
<h3 className="text-xl dark:text-neutral-300">{name}</h3>
</span>
<p className={`flex textarea textarea-bordered max-w-[400px] w-full block ${paid ? "bg-green-950" : "bg-red-950"}`}>
<span className="font-bold uppercase">{t("paid-checkbox")}</span>
<span className="text-right inline-block grow">{paid ? <CheckCircleIcon className="h-[1em] w-[1em] ml-[.5em] text-2xl inline-block text-green-500" /> : <XCircleIcon className="h-[1em] w-[1em] text-2xl inline-block text-red-500" />}</span>
</p>
<p className="flex textarea textarea-bordered max-w-[400px] w-full block">
<span className="font-bold uppercase">{t("payed-amount")}</span>
<span className="text-right inline-block grow">{payedAmount ? payedAmount / 100 : ""}</span>
</p>
{
notes ?
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
<p className="font-bold uppercase">{t("notes-placeholder")}</p>
<p className="leading-[1.4em]">
{notes}
</p>
</span>
: null
}
{
attachment ?
<span className="textarea textarea-bordered max-w-[400px] w-full grow">
<p className="font-bold uppercase">{t("attachment")}</p>
<Link href={`/share/attachment/${shareId || locationID}-${billID}/`} target="_blank" className='text-center w-full max-w-[20em] text-nowrap truncate inline-block mt-2'>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{decodeURIComponent(attachment.fileName)}
</Link>
</span>
: null
}
{
hub3aText ?
<div className="form-control p-1">
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
<Pdf417Barcode hub3aText={hub3aText} className="w-full max-w-[35rem] sm:max-w-[25rem]" />
</label>
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
</div> : null
}
{
// IF proof of payment type is "per-bill", show upload fieldset
proofOfPaymentType === "per-bill" &&
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend>
{
// IF proof of payment was uploaded
proofOfPaymentUploadedAt ? (
// IF file name is available, show link to download
// ELSE it's not available that means that the uploaded file was purged by housekeeping
// -> don't show anything
proofOfPaymentFilename ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/per-bill/${shareId || locationID}-${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-[-.1em] text-teal-500" />
{ decodeURIComponent(proofOfPaymentFilename) }
</Link>
</div>
) : null
) : /* ELSE show upload input */ (
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("upload-proof-of-payment-label")}</span>
</label>
<div className="flex items-center gap-2">
<input
id="proofOfPayment"
name="proofOfPayment"
type="file"
accept="application/pdf"
className="file-input file-input-bordered grow file-input-sm my-2 block max-w-[17em] md:max-w-[80em] break-words"
onChange={handleFileChange}
disabled={isUploading}
/>
{isUploading && (
<span className="loading loading-spinner loading-sm"></span>
)}
</div>
{uploadError && (
<p className="text-sm text-red-500 mt-1">{uploadError}</p>
)}
</div>
)}
</fieldset>
}
<div className="text-right">
<Link className="btn btn-neutral ml-3" href={`/share/location/${shareId || locationID}`}>{t("back-button")}</Link>
</div>
</div>
</div>);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { FC, useMemo, useState } from "react";
import { BilledTo, BillingLocation, UserSettings } from "../lib/db-types";
import { formatYearMonth } from "../lib/format";
import { formatCurrency, formatIban } from "../lib/formatStrings";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { ViewBillBadge } from "./ViewBillBadge";
import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder";
import Link from "next/link";
import { LinkIcon } from "@heroicons/react/24/outline";
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
import QRCode from "react-qr-code";
import { TicketIcon } from "@heroicons/react/24/solid";
import { Pdf417Barcode } from "./Pdf417Barcode";
export interface ViewLocationCardProps {
location: BillingLocation;
userSettings: UserSettings | null;
shareId?: string;
}
export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSettings, shareId }) => {
const {
_id,
name: locationName,
yearMonth,
bills,
tenantName,
tenantStreet,
tenantTown,
tenantPaymentMethod,
// NOTE: only the fileName is projected from the DB to reduce data transfer
utilBillsProofOfPayment,
proofOfPaymentType,
} = location;
const router = useRouter();
const t = useTranslations("home-page.location-card");
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [attachmentUploadedAt, setAttachmentUploadedAt] = useState<Date | null>(utilBillsProofOfPayment?.uploadedAt ?? null);
const [attachmentFilename, setAttachmentFilename] = useState(utilBillsProofOfPayment?.fileName);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type client-side (quick feedback)
if (file.type !== 'application/pdf') {
setUploadError('Only PDF files are accepted');
e.target.value = ''; // Reset input
return;
}
if (!shareId) {
setUploadError('Invalid upload link');
return;
}
setIsUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('utilBillsProofOfPayment', file);
const result = await uploadUtilBillsProofOfPayment(shareId, formData);
if (result.success) {
setAttachmentFilename(file.name);
setAttachmentUploadedAt(new Date());
router.refresh();
} else {
setUploadError(result.error || 'Upload failed');
}
} catch (error: any) {
setUploadError(error.message || 'Upload failed');
} finally {
setIsUploading(false);
e.target.value = ''; // Reset input
}
};
// sum all the billAmounts (only for bills billed to tenant)
const totalAmount = bills.reduce((acc, bill) => (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant ? acc + (bill.payedAmount ?? 0) : acc, 0);
const { hub3aText, paymentParams } = useMemo(() => {
if (!userSettings?.enableIbanPayment || tenantPaymentMethod !== "iban") {
return {
hub3aText: "",
paymentParams: {} as PaymentParams
};
}
const locationNameTrimmed_max20 = locationName.trimEnd().trimEnd().substring(0, 19);
const paymentParams: PaymentParams = {
Iznos: (totalAmount / 100).toFixed(2).replace(".", ","),
ImePlatitelja: tenantName ?? "",
AdresaPlatitelja: tenantStreet ?? "",
SjedistePlatitelja: tenantTown ?? "",
Primatelj: userSettings?.ownerName ?? "",
AdresaPrimatelja: userSettings?.ownerStreet ?? "",
SjedistePrimatelja: userSettings?.ownerTown ?? "",
IBAN: userSettings?.ownerIBAN ?? "",
ModelPlacanja: "HR00",
PozivNaBroj: formatYearMonth(yearMonth),
SifraNamjene: "",
OpisPlacanja: `Režije-${locationNameTrimmed_max20}-${formatYearMonth(yearMonth)}`, // max length 35 = "Režije-" (7) + locationName (20) + "-" (1) + "YYYY-MM" (7)
};
return ({
hub3aText: EncodePayment(paymentParams),
paymentParams
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]);
return (
<div data-key={_id} className="card card-compact card-bordered max-w-[30em] min-w-[330px] bg-base-100 border-1 border-neutral my-1">
<div className="card-body">
<h2 className="card-title mr-[2em] text-[1.3rem]">{formatYearMonth(yearMonth)} {locationName}</h2>
<div className="card-actions mt-[1em] mb-[1em]">
{
bills.filter(bill => (bill.billedTo ?? BilledTo.Tenant) === BilledTo.Tenant).map(bill => <ViewBillBadge key={`${_id}-${bill._id}`} locationId={_id} shareId={shareId} bill={bill} />)
}
</div>
{
totalAmount > 0 ?
<p className="text-[1.2rem]">
{t("total-due-label")} <strong>{formatCurrency(totalAmount, userSettings?.currency)}</strong>
</p>
: null
}
{
userSettings?.enableIbanPayment && tenantPaymentMethod === "iban" ?
<>
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
<ul className="ml-4 mb-3">
<li><strong>{t("payment-iban-label")}</strong><pre className="inline pl-1">{formatIban(paymentParams.IBAN)}</pre></li>
<li><strong>{t("payment-recipient-label")}</strong> <pre className="inline pl-1">{paymentParams.Primatelj}</pre></li>
<li><strong>{t("payment-recipient-address-label")}</strong><pre className="inline pl-1">{paymentParams.AdresaPrimatelja}</pre></li>
<li><strong>{t("payment-recipient-city-label")}</strong><pre className="inline pl-1">{paymentParams.SjedistePrimatelja}</pre></li>
<li><strong>{t("payment-amount-label")}</strong> <pre className="inline pl-1">{paymentParams.Iznos} {userSettings?.currency}</pre></li>
<li><strong>{t("payment-description-label")}</strong><pre className="inline pl-1">{paymentParams.OpisPlacanja}</pre></li>
<li><strong>{t("payment-model-label")}</strong><pre className="inline pl-1">{paymentParams.ModelPlacanja}</pre></li>
<li><strong>{t("payment-reference-label")}</strong><pre className="inline pl-1">{paymentParams.PozivNaBroj}</pre></li>
</ul>
<label className="label p-2 grow bg-white border border-gray-300 rounded-box justify-center">
<Pdf417Barcode hub3aText={hub3aText} className="w-full max-w-[35rem] sm:max-w-[25rem]" />
</label>
</>
: null
}
{
userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => {
const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(totalAmount).toFixed(0)}&currency=${userSettings.currency}`;
return (
<>
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
<div className="flex justify-center">
<QRCode value={revolutPaymentUrl} size={200} className="p-4 bg-white border border-gray-300 rounded-box" />
</div>
<p className="text-center mt-1 mb-3">
<LinkIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 ml-[-.5em]" />
<Link
href={revolutPaymentUrl}
target="_blank"
className="underline"
>
{t("revolut-link-text")}
</Link>
</p>
</>
);
})()
: null
}
{
// IF proof of payment type is "combined", show upload fieldset
proofOfPaymentType === "combined" &&
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend>
{
// IF proof of payment was uploaded
attachmentUploadedAt ? (
// IF file name is available, show link to download
// ELSE it's not available that means that the uploaded file was purged by housekeeping
// -> don't show anything
attachmentFilename ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/combined/${shareId || _id}/`}
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-[-.1em] text-teal-500" />
{decodeURIComponent(attachmentFilename)}
</Link>
</div>
) : null
) : /* ELSE show upload input */ (
<div className="form-control w-full">
<label className="label">
<span className="label-text">{t("upload-proof-of-payment-label")}</span>
</label>
<div className="flex items-center gap-2">
<input
id="utilBillsProofOfPayment"
name="utilBillsProofOfPayment"
type="file"
accept="application/pdf"
className="file-input file-input-bordered grow file-input-sm my-2 block max-w-[17em] md:max-w-[80em] break-words"
onChange={handleFileChange}
disabled={isUploading}
/>
{isUploading && (
<span className="loading loading-spinner loading-sm"></span>
)}
</div>
{uploadError && (
<p className="text-sm text-red-500 mt-1">{uploadError}</p>
)}
</div>
)}
</fieldset>
}
</div>
</div>);
};

19
web-app/app/ui/button.tsx Normal file
View File

@@ -0,0 +1,19 @@
import clsx from 'clsx';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
}
export function Button({ children, className, ...rest }: ButtonProps) {
return (
<button
{...rest}
className={clsx(
'flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
className,
)}
>
{children}
</button>
);
}

4
web-app/app/ui/fonts.ts Normal file
View File

@@ -0,0 +1,4 @@
import { Inter, Lusitana } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });
export const lusitana = Lusitana({ weight: ["400", "700"], subsets: ['latin'] });

18
web-app/app/ui/global.css Normal file
View File

@@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}

View File

@@ -0,0 +1,7 @@
.shape {
height: 0;
width: 0;
border-bottom: 30px solid indigo;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
}