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:
30
web-app/app/ui/AddLocationButton.tsx
Normal file
30
web-app/app/ui/AddLocationButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
web-app/app/ui/AddMonthButton.tsx
Normal file
30
web-app/app/ui/AddMonthButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
web-app/app/ui/BillBadge.tsx
Normal file
24
web-app/app/ui/BillBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
web-app/app/ui/BillDeleteForm.tsx
Normal file
71
web-app/app/ui/BillDeleteForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
web-app/app/ui/BillEditForm.tsx
Normal file
281
web-app/app/ui/BillEditForm.tsx
Normal 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>);
|
||||
}
|
||||
38
web-app/app/ui/EnterOrSignInButton.tsx
Normal file
38
web-app/app/ui/EnterOrSignInButton.tsx
Normal 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)}
|
||||
</>);
|
||||
};
|
||||
81
web-app/app/ui/HomePage.tsx
Normal file
81
web-app/app/ui/HomePage.tsx
Normal 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;
|
||||
24
web-app/app/ui/InfoBox.tsx
Normal file
24
web-app/app/ui/InfoBox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
127
web-app/app/ui/LocationCard.tsx
Normal file
127
web-app/app/ui/LocationCard.tsx
Normal 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")} <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")} <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>);
|
||||
};
|
||||
79
web-app/app/ui/LocationDeleteForm.tsx
Normal file
79
web-app/app/ui/LocationDeleteForm.tsx
Normal 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>
|
||||
436
web-app/app/ui/LocationEditForm.tsx
Normal file
436
web-app/app/ui/LocationEditForm.tsx
Normal 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
25
web-app/app/ui/Main.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
web-app/app/ui/MonthCard.tsx
Normal file
58
web-app/app/ui/MonthCard.tsx
Normal 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>
|
||||
)
|
||||
};
|
||||
23
web-app/app/ui/MonthCardSkeleton.tsx
Normal file
23
web-app/app/ui/MonthCardSkeleton.tsx
Normal 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>;
|
||||
148
web-app/app/ui/MonthLocationList.tsx
Normal file
148
web-app/app/ui/MonthLocationList.tsx
Normal 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 />
|
||||
</>)
|
||||
}
|
||||
6
web-app/app/ui/MultiParagrpahText.tsx
Normal file
6
web-app/app/ui/MultiParagrpahText.tsx
Normal 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>));
|
||||
18
web-app/app/ui/NotFoundPage.tsx
Normal file
18
web-app/app/ui/NotFoundPage.tsx
Normal 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>
|
||||
8
web-app/app/ui/NoteBox.tsx
Normal file
8
web-app/app/ui/NoteBox.tsx
Normal 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>
|
||||
30
web-app/app/ui/PageFooter.tsx
Normal file
30
web-app/app/ui/PageFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
web-app/app/ui/PageHeader.tsx
Normal file
27
web-app/app/ui/PageHeader.tsx
Normal 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"> </span>
|
||||
<SelectLanguage />
|
||||
{isRestrictedPage && (
|
||||
<Link href={`/${locale}/home/account/`} className="btn btn-ghost btn-circle">
|
||||
<AccountCircle fontSize="large" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
web-app/app/ui/Pagination.tsx
Normal file
137
web-app/app/ui/Pagination.tsx
Normal 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>);
|
||||
}
|
||||
34
web-app/app/ui/Pdf417Barcode.tsx
Normal file
34
web-app/app/ui/Pdf417Barcode.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
76
web-app/app/ui/Pdf417BarcodeWasm.tsx
Normal file
76
web-app/app/ui/Pdf417BarcodeWasm.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
33
web-app/app/ui/PrintButton.tsx
Normal file
33
web-app/app/ui/PrintButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
158
web-app/app/ui/PrintPreview.tsx
Normal file
158
web-app/app/ui/PrintPreview.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
16
web-app/app/ui/SelectLanguage.tsx
Normal file
16
web-app/app/ui/SelectLanguage.tsx
Normal 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>);
|
||||
}
|
||||
30
web-app/app/ui/SignInButton.tsx
Normal file
30
web-app/app/ui/SignInButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
403
web-app/app/ui/UserSettingsForm.tsx
Normal file
403
web-app/app/ui/UserSettingsForm.tsx
Normal 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¤cy=${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>
|
||||
);
|
||||
};
|
||||
31
web-app/app/ui/ViewBillBadge.tsx
Normal file
31
web-app/app/ui/ViewBillBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
web-app/app/ui/ViewBillCard.tsx
Normal file
175
web-app/app/ui/ViewBillCard.tsx
Normal 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>);
|
||||
}
|
||||
236
web-app/app/ui/ViewLocationCard.tsx
Normal file
236
web-app/app/ui/ViewLocationCard.tsx
Normal 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)}¤cy=${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
19
web-app/app/ui/button.tsx
Normal 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
4
web-app/app/ui/fonts.ts
Normal 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
18
web-app/app/ui/global.css
Normal 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;
|
||||
}
|
||||
7
web-app/app/ui/home.module.css
Normal file
7
web-app/app/ui/home.module.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user