Files
evidencija-rezija/app/ui/ViewLocationCard.tsx
Knee Cola 6df9557921 Add utility bills proof of payment file upload functionality
Changes:
- Updated BillingLocation interface:
  - Added utilBillsProofOfPaymentAttachment field (BillAttachment type)
- Added server action uploadUtilBillsProofOfPayment:
  - Validates PDF file type
  - Serializes file attachment to base64
  - Stores attachment in BillingLocation document
  - Returns success/error status
- Updated ViewLocationCard component:
  - Added file upload input with PDF-only accept
  - Implemented handleFileChange with immediate upload
  - Added upload state management (isUploading, uploadError, attachment)
  - Shows spinner while uploading
  - Input disabled during upload
  - Conditionally renders file input or download link
  - Link displayed after successful upload
- Created route handler for serving proof of payment PDFs:
  - GET /share/proof-of-payment/[id]/route.tsx
  - Fetches attachment from database
  - Converts base64 to binary
  - Returns PDF with proper headers
- Added not-found page for proof of payment route
- Updated middleware to include proof-of-payment in public pages
- Added translations:
  - en: "Upload proof of payment (PDF only)"
  - hr: "Priložite potvrdu o uplati:"

File uploads immediately on selection without page reload.
Only PDF files accepted with client and server-side validation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:47:08 +01:00

162 lines
8.1 KiB
TypeScript

'use client';
import { FC, useState } from "react";
import { BilledTo, BillingLocation, UserSettings } from "../lib/db-types";
import { formatYearMonth } from "../lib/format";
import { formatCurrency } from "../lib/formatStrings";
import { useTranslations } from "next-intl";
import { ViewBillBadge } from "./ViewBillBadge";
import { Pdf417Barcode } from "./Pdf417Barcode";
import { PaymentParams } from "hub-3a-payment-encoder";
import Link from "next/link";
import { DocumentIcon } from "@heroicons/react/24/outline";
import { uploadUtilBillsProofOfPayment } from "../lib/actions/locationActions";
export interface ViewLocationCardProps {
location: BillingLocation;
userSettings: UserSettings | null;
}
export const ViewLocationCard:FC<ViewLocationCardProps> = ({location, userSettings}) => {
const { _id, name: locationName, yearMonth, bills, tenantName, tenantStreet, tenantTown, generateTenantCode, utilBillsProofOfPaymentAttachment } = location;
const t = useTranslations("home-page.location-card");
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [attachment, setAttachment] = useState(utilBillsProofOfPaymentAttachment);
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('utilBillsProofOfPaymentAttachment', file);
const result = await uploadUtilBillsProofOfPayment(_id, formData);
if (result.success) {
// Update local state with the uploaded attachment
setAttachment({
fileName: file.name,
fileSize: file.size,
fileType: file.type,
fileLastModified: file.lastModified,
fileContentsBase64: '', // We don't need the contents in the UI
});
} 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 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(
<div data-key={_id } className="card card-compact card-bordered max-w-[30em] min-w-[350px] 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?.show2dCodeInMonthlyStatement && generateTenantCode ?
<>
<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">{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}</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>
<li><strong>{t("payment-purpose-code-label")}</strong><pre className="inline pl-1">{paymentParams.SifraNamjene}</pre></li>
</ul>
<Pdf417Barcode paymentParams={paymentParams} />
</>
: null
}
{attachment ? (
<div className="mt-4">
<Link
href={`/share/proof-of-payment/${_id}/`}
target="_blank"
className='text-center w-full max-w-[20em] text-nowrap truncate inline-block'
>
<DocumentIcon className="h-[1em] w-[1em] text-2xl inline-block mr-1" />
{decodeURIComponent(attachment.fileName)}
</Link>
</div>
) : (
<div className="form-control w-full mb-4 mt-4">
<label className="label">
<span className="label-text">{t("upload-proof-of-payment-label")}</span>
</label>
<div className="flex items-center gap-2">
<input
id="utilBillsProofOfPaymentAttachment"
name="utilBillsProofOfPaymentAttachment"
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>
)}
</div>
</div>);
};