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>
229 lines
12 KiB
TypeScript
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)}¤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/${_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>);
|
|
}; |