Files
evidencija-rezija/web-app/app/ui/BillEditForm.tsx
Nikola Derežić e318523887 (bugfix) Fix proof of payment download URL to use shareID with checksum
Fixed bug where proof of payment download links used raw locationID instead
of shareID (locationID + checksum), causing link validation to fail. Added
AsyncLink component to handle async shareID generation gracefully.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 18:16:53 +01:00

298 lines
16 KiB
TypeScript

"use client";
import { DocumentIcon, TicketIcon, TrashIcon } from "@heroicons/react/24/outline";
import { Bill, BilledTo, BillingLocation } from "../lib/db-types";
import React, { FC, useEffect, useState } 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";
import { generateShareId } from "../lib/actions/locationActions";
import { AsyncLink } from "./AsyncLink";
// Next.js does not encode an utf-8 file name correctly when sending a form with a file attachment
// This is a workaround for that
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;
/**
* Share ID for viewing-only links (locationID + checksum)
* Note: This is different from the share button which calls `generateShareLink`
* to activate sharing and set TTL in the database
*/
const [shareID, setShareID] = useState<string | null>(null);
useEffect(() => {
// share ID can be generated server-side since it requires a secret key
// which we don't want to expose to the client
(async () => setShareID(await generateShareId(locationID)))();
}, [locationID]);
const initialState = { message: null, errors: {} };
const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth);
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]">
<AsyncLink
disabled={!shareID}
href={`/share/proof-of-payment/per-bill/${shareID}-${billID}/`}
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<TicketIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 mt-[-.2em] text-teal-500" />
{decodeURIComponent(proofOfPayment.fileName)}
</AsyncLink>
</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>);
}