Files
evidencija-rezija/app/ui/ViewLocationCard.tsx
Knee Cola 0f8b5678f4 Fix client-side cache staleness after proof of payment upload
Added cache revalidation to ensure ViewLocationCard reflects uploaded
proof of payment when navigating back from ViewBillCard:

- Server-side: Added revalidatePath() to upload actions in billActions
  and locationActions to invalidate Next.js server cache
- Client-side: Added router.refresh() calls in ViewBillCard and
  ViewLocationCard to refresh client router cache after successful upload

This maintains the current UX (no redirect on upload) while ensuring
fresh data is displayed on navigation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 16:57:00 +01:00

229 lines
12 KiB
TypeScript

'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 { Pdf417Barcode } from "./Pdf417Barcode";
import { EncodePayment, PaymentParams } from "hub-3a-payment-encoder";
import Link from "next/link";
import { DocumentIcon, LinkIcon } from "@heroicons/react/24/outline";
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
import QRCode from "react-qr-code";
export interface ViewLocationCardProps {
location: BillingLocation;
userSettings: UserSettings | null;
}
export const ViewLocationCard: FC<ViewLocationCardProps> = ({ location, userSettings }) => {
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
if (file.type !== 'application/pdf') {
setUploadError('Only PDF files are accepted');
e.target.value = ''; // Reset input
return;
}
setIsUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append('utilBillsProofOfPayment', file);
const result = await uploadUtilBillsProofOfPayment(_id, 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 monthlyExpense = bills.reduce((acc, bill) => (bill.paid && (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: (monthlyExpense / 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} bill={bill} />)
}
</div>
{
monthlyExpense > 0 ?
<p className="text-[1.2rem]">
{t("payed-total-label")} <strong>{formatCurrency(monthlyExpense, 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} />
</label>
</>
: null
}
{
userSettings?.enableRevolutPayment && tenantPaymentMethod === "revolut" ? (() => {
const revolutPaymentUrl = `https://revolut.me/${userSettings.ownerRevolutProfileName?.replace('@', '')}?amount=${(monthlyExpense).toFixed(0)}&currency=${userSettings.currency}`;
return (
<>
<p className="max-w-[25em] ml-1 mt-1 mb-1">{t("payment-info-header")}</p>
<div className="flex justify-center">
<QRCode value={revolutPaymentUrl} size={200} className="p-4 bg-white border border-gray-300 rounded-box" />
</div>
<p className="text-center mt-1 mb-3">
<LinkIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1 ml-[-.5em]" />
<Link
href={revolutPaymentUrl}
target="_blank"
className="underline"
>
{t("revolut-link-text")}
</Link>
</p>
</>
);
})()
: null
}
{
// IF proof of payment type is "combined", show upload fieldset
proofOfPaymentType === "combined" &&
<fieldset className="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4 pb-2 pt-0 mt-2">
<legend className="fieldset-legend font-semibold uppercase">{t("upload-proof-of-payment-legend")}</legend>
{
// IF proof of payment was uploaded
attachmentUploadedAt ? (
// IF file name is available, show link to download
// ELSE it's not available that means that the uploaded file was purged by housekeeping
// -> don't show anything
attachmentFilename ? (
<div className="mt-3 ml-[-.5rem]">
<Link
href={`/share/proof-of-payment/combined/${_id}/`}
target="_blank"
className='text-center w-full max-w-[20rem] text-nowrap truncate inline-block'
>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{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>);
};