- Add ItemDetailModal showing PO document (view/download/share/email) + buyer notes - Add FileText (PO) and MessageSquare (notes) buttons to compact item list with popovers - Fix photo upload: append to order_items.order_item_attachements (PB file field) - Add colored left sidebar stripe per supplier group for visual distinction - Fix popover z-index so they appear above following cards (overflow-visible + z-50) - Extend PurchaseOrder type with documents, email_payload, supplier - Add order_item_attachements and expand.purchase_order to OrderItem - onShowDetails prop wired through SupplierGroup and AreaGroup Co-authored-by: Orca <help@stably.ai>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { OrderItem, PurchaseOrder } from '@/lib/types';
|
|
import { pb } from '@/lib/pocketbase';
|
|
import {
|
|
X,
|
|
FileText,
|
|
Share2,
|
|
Mail,
|
|
Check,
|
|
AlertCircle,
|
|
Copy,
|
|
ExternalLink,
|
|
} from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
interface ItemDetailModalProps {
|
|
item: OrderItem;
|
|
purchaseOrder: PurchaseOrder | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
// PocketBase's pb.files.getUrl expects a record object with id and
|
|
// collectionId. We satisfy the contract with a typed inline object rather
|
|
// than a broader `any` cast.
|
|
function buildPoFileUrl(po: PurchaseOrder): string | null {
|
|
if (!po.documents?.length) return null;
|
|
const record = {
|
|
id: po.id,
|
|
collectionId: (po as unknown as { collectionId: string }).collectionId ?? '',
|
|
};
|
|
return pb.files.getUrl(record, po.documents[0]);
|
|
}
|
|
|
|
function statusColor(status: string): string {
|
|
switch (status.toLowerCase()) {
|
|
case 'sent':
|
|
case 'approved':
|
|
return 'bg-[#e6f1ec] text-[#125a3e]';
|
|
case 'pending':
|
|
return 'bg-[#fcf3d8] text-[#8a5a02]';
|
|
case 'cancelled':
|
|
return 'bg-[#fbeaef] text-[#8a1b3a]';
|
|
default:
|
|
return 'bg-[#ebeae5] text-[#26251e]';
|
|
}
|
|
}
|
|
|
|
export default function ItemDetailModal({
|
|
item,
|
|
purchaseOrder,
|
|
isOpen,
|
|
onClose,
|
|
}: ItemDetailModalProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
const [sharing, setSharing] = useState(false);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const poUrl = purchaseOrder ? buildPoFileUrl(purchaseOrder) : null;
|
|
const supplier = item.expand?.supplier;
|
|
const hasNotes = Boolean(item.notes?.trim());
|
|
|
|
const handleShare = async () => {
|
|
if (!poUrl || !purchaseOrder) return;
|
|
setSharing(true);
|
|
try {
|
|
if (navigator.share) {
|
|
await navigator.share({
|
|
title: `Purchase Order ${purchaseOrder.po_number}`,
|
|
text: `Purchase Order ${purchaseOrder.po_number} for ${supplier?.suppliername ?? 'your order'}`,
|
|
url: poUrl,
|
|
});
|
|
} else {
|
|
await navigator.clipboard.writeText(poUrl);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2500);
|
|
}
|
|
} catch (err) {
|
|
if ((err as Error).name !== 'AbortError') {
|
|
console.error('Share failed:', err);
|
|
}
|
|
} finally {
|
|
setSharing(false);
|
|
}
|
|
};
|
|
|
|
const handleEmail = () => {
|
|
if (!purchaseOrder?.email_payload) return;
|
|
const { subject, bodyText } = purchaseOrder.email_payload;
|
|
const bodyWithLink = poUrl ? `${bodyText}\n\nPDF Download: ${poUrl}` : bodyText;
|
|
const mailto = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyWithLink)}`;
|
|
window.location.href = mailto;
|
|
};
|
|
|
|
const handleCopyLink = async () => {
|
|
if (!poUrl) return;
|
|
try {
|
|
await navigator.clipboard.writeText(poUrl);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2500);
|
|
} catch {
|
|
// clipboard may be blocked
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[80] flex items-end sm:items-center justify-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Item details"
|
|
>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Sheet */}
|
|
<div className="relative w-full max-w-lg bg-[#ebeae5] rounded-t-2xl sm:rounded-2xl shadow-xl flex flex-col max-h-[90dvh] overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[rgba(38,37,30,0.1)]">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-[#1f8a65] flex items-center justify-center">
|
|
<FileText size={18} className="text-white" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-[15px] font-semibold text-[#26251e] leading-tight">
|
|
{purchaseOrder?.po_number ?? 'No PO'}
|
|
</h2>
|
|
{supplier && (
|
|
<p className="text-[11px] text-[rgba(38,37,30,0.55)] leading-tight mt-0.5">
|
|
{supplier.suppliername}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close details"
|
|
className="w-9 h-9 rounded-xl flex items-center justify-center active:bg-[rgba(38,37,30,0.08)] transition-colors"
|
|
>
|
|
<X size={20} className="text-[#26251e]" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Scrollable body */}
|
|
<div className="overflow-y-auto flex-1 p-5 space-y-5">
|
|
{/* PO summary grid */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{purchaseOrder?.status && (
|
|
<div className="bg-[#f2f1ed] rounded-xl p-3">
|
|
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
|
|
Status
|
|
</p>
|
|
<span
|
|
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-lg ${statusColor(
|
|
purchaseOrder.status
|
|
)}`}
|
|
>
|
|
{purchaseOrder.status}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{purchaseOrder?.total_amount != null && (
|
|
<div className="bg-[#f2f1ed] rounded-xl p-3">
|
|
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
|
|
PO Total
|
|
</p>
|
|
<p className="text-[14px] font-semibold text-[#26251e]">
|
|
R{(purchaseOrder.total_amount / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{item.expand?.order?.order_number && (
|
|
<div className="bg-[#f2f1ed] rounded-xl p-3">
|
|
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
|
|
Order Ref
|
|
</p>
|
|
<p className="text-[13px] font-medium text-[#26251e] leading-tight">
|
|
{item.expand.order.order_number}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{supplier?.supplieraddress && (
|
|
<div className="bg-[#f2f1ed] rounded-xl p-3 col-span-2">
|
|
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide mb-1">
|
|
Supplier Address
|
|
</p>
|
|
<p className="text-[13px] font-medium text-[#26251e] leading-snug selectable">
|
|
{supplier.supplieraddress}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* PO Document actions */}
|
|
{poUrl ? (
|
|
<div className="space-y-3">
|
|
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide">
|
|
Attached Document
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<a
|
|
href={poUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-[#26251e] text-white active:bg-[#3a3832] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold"
|
|
>
|
|
<ExternalLink size={15} aria-hidden="true" />
|
|
View / Download PDF
|
|
</a>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={handleShare}
|
|
disabled={sharing}
|
|
className="flex items-center justify-center gap-2 py-3 rounded-xl bg-[#1f8a65] text-white active:bg-[#125a3e] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold disabled:opacity-60"
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<Check size={15} aria-hidden="true" />
|
|
Copied!
|
|
</>
|
|
) : (
|
|
<>
|
|
<Share2 size={15} aria-hidden="true" />
|
|
Share PDF
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{purchaseOrder?.email_payload ? (
|
|
<button
|
|
onClick={handleEmail}
|
|
className="flex items-center justify-center gap-2 py-3 rounded-xl bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold"
|
|
>
|
|
<Mail size={15} aria-hidden="true" />
|
|
Email PO
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleCopyLink}
|
|
className="flex items-center justify-center gap-2 py-3 rounded-xl bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors touch-manipulation press-feedback-sm text-[13px] font-semibold"
|
|
>
|
|
<Copy size={15} aria-hidden="true" />
|
|
Copy Link
|
|
</button>
|
|
)}
|
|
</div>
|
|
{copied && (
|
|
<p className="text-[11px] text-center text-[#125a3e] font-medium">
|
|
Link copied to clipboard
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 py-3 px-4 rounded-xl bg-[#fcf3d8] border border-[#f6e3a4]">
|
|
<AlertCircle size={15} className="text-[#8a5a02] flex-shrink-0" />
|
|
<p className="text-[12px] text-[#8a5a02]">No document attached to this PO</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Buyer Notes */}
|
|
{hasNotes && (
|
|
<div className="space-y-2">
|
|
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide">
|
|
Buyer Notes
|
|
</p>
|
|
<div className="bg-[#fcf3d8] border border-[#f6e3a4] rounded-xl p-4">
|
|
<p className="text-[13px] text-[#8a5a02] leading-relaxed selectable">
|
|
{item.notes}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Collection / problem notes */}
|
|
{item.problem_notes && (
|
|
<div className="space-y-2">
|
|
<p className="text-[10px] text-[rgba(38,37,30,0.55)] uppercase tracking-wide">
|
|
Collection Notes
|
|
</p>
|
|
<div className="bg-[#fbeaef] border border-[#f3c4d2] rounded-xl p-4">
|
|
<p className="text-[13px] text-[#8a1b3a] leading-relaxed selectable">
|
|
{item.problem_notes}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-5 pt-3">
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full py-3.5 rounded-xl bg-[#26251e] text-white active:bg-[#3a3832] transition-colors touch-manipulation press-feedback-sm text-[14px] font-semibold"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|