From 08af8e3bf13de6fad6c02b861bd0a8e93f969fda Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Apr 2026 11:43:07 +0000 Subject: [PATCH] enhancing filter capabilities for property table --- .../documents/DocumentsClient.tsx | 330 ++++++++++++++++++ .../[slug]/components/PropertyFilters.tsx | 162 +++++---- 2 files changed, 418 insertions(+), 74 deletions(-) create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsClient.tsx diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsClient.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsClient.tsx new file mode 100644 index 0000000..471b336 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsClient.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useState } from "react"; +import { + Camera, + FileText, + Zap, + ClipboardCheck, + Download, + Loader2, + LayoutGrid, + List, +} from "lucide-react"; + +export type RawFileType = + | "photo_pack" + | "site_note" + | "rd_sap_site_note" + | "pas_2023_ventilation" + | "pas_2023_condition" + | "pas_significance" + | "par_photo_pack" + | "pas_2023_property" + | "pas_2023_occupancy" + | "ecmk_site_note" + | "ecmk_rd_sap_site_note" + | "unknown"; + +type Document = { + id: string; + s3FileKey: string; + s3FileBucket: string; + docType: RawFileType; + s3UploadTimestamp: string; +}; + +type GroupConfig = { + label: string; + types: RawFileType[]; + icon: React.ComponentType<{ className?: string }>; +}; + +const GROUPS: GroupConfig[] = [ + { + label: "Photos", + types: ["photo_pack", "par_photo_pack"], + icon: Camera, + }, + { + label: "Energy Performance", + types: ["site_note", "ecmk_site_note", "rd_sap_site_note", "ecmk_rd_sap_site_note"], + icon: Zap, + }, + { + label: "PAS Condition & Other", + types: ["pas_2023_condition", "pas_2023_ventilation", "pas_2023_occupancy", "pas_2023_property", "pas_significance"], + icon: ClipboardCheck, + }, +]; + +function getGroupForType(docType: RawFileType): GroupConfig | undefined { + return GROUPS.find((g) => g.types.includes(docType)); +} + +const DOC_TYPE_LABELS: Record = { + photo_pack: "Photo Pack", + par_photo_pack: "Photo Pack", + site_note: "Site Note", + ecmk_site_note: "Site Note", + rd_sap_site_note: "RdSAP Report", + ecmk_rd_sap_site_note: "RdSAP Report", + pas_2023_condition: "Condition Report", + pas_2023_ventilation: "Ventilation Report", + pas_2023_occupancy: "Occupancy Report", + pas_2023_property: "Property Report", + pas_significance: "Significance Report", + unknown: "Document", +}; + +function getDisplayLabel(docType: RawFileType): string { + return DOC_TYPE_LABELS[docType] ?? "Document"; +} + +function extractFilename(s3Key: string): string { + const parts = s3Key.split("/"); + return parts[parts.length - 1] ?? s3Key; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +async function fetchPresignedUrl(key: string, bucket: string): Promise { + const res = await fetch("/api/sign-document-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, bucket }), + }); + if (!res.ok) throw new Error("Failed to get download URL"); + const data = await res.json(); + return data.url; +} + +function DocumentCard({ + doc, + viewMode, +}: { + doc: Document; + viewMode: "grid" | "list"; +}) { + const [loading, setLoading] = useState(false); + + const group = getGroupForType(doc.docType); + const Icon = group?.icon ?? FileText; + const filename = extractFilename(doc.s3FileKey); + const label = getDisplayLabel(doc.docType); + const date = formatDate(doc.s3UploadTimestamp); + + async function handleDownload() { + setLoading(true); + try { + const url = await fetchPresignedUrl(doc.s3FileKey, doc.s3FileBucket); + window.open(url, "_blank"); + } catch { + // silently fail — could add a toast here + } finally { + setLoading(false); + } + } + + if (viewMode === "list") { + return ( +
+
+ +
+
+

+ {filename} +

+

+ {label} · {date} +

+
+ +
+ ); + } + + return ( +
+
+
+ +
+
+

+ {label} +

+

+ {filename} +

+
+ {date} + +
+
+ ); +} + +export function DocumentsClient({ documents }: { documents: Document[] }) { + const [activeFilter, setActiveFilter] = useState("All"); + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + + // Only show groups that have at least one document + const presentGroups = GROUPS.filter((g) => + documents.some((d) => g.types.includes(d.docType)) + ); + + const filteredDocs = + activeFilter === "All" + ? documents + : documents.filter((d) => { + const group = getGroupForType(d.docType); + return group?.label === activeFilter; + }); + + // For grouped display: group the filtered docs + const groupsToShow = + activeFilter === "All" + ? presentGroups + : presentGroups.filter((g) => g.label === activeFilter); + + return ( +
+ {/* Header */} +
+

+ Documents +

+
+ + {/* Controls */} +
+ {/* Filter pills */} +
+ + {presentGroups.map((g) => ( + + ))} +
+ + {/* View toggle */} +
+ + +
+
+ + {/* Empty state */} + {filteredDocs.length === 0 && ( +
+ No documents found. +
+ )} + + {/* Document groups */} + {filteredDocs.length > 0 && ( +
+ {groupsToShow.map((group) => { + const groupDocs = filteredDocs.filter((d) => + group.types.includes(d.docType) + ); + if (groupDocs.length === 0) return null; + return ( +
+ {/* Section header */} +
+

+ {group.label} +

+
+
+ + {/* Cards */} + {viewMode === "grid" ? ( +
+ {groupDocs.map((doc) => ( + + ))} +
+ ) : ( +
+ {groupDocs.map((doc) => ( + + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index d7cb313..b295f4f 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -387,25 +387,21 @@ export default function PropertyFilters({ }) { // Draft state — only applied when user clicks Apply const [draft, setDraft] = useState(filterGroups); - const [showForm, setShowForm] = useState(false); - const [addToGroupId, setAddToGroupId] = useState(null); + // "and" = new group, "or:" = OR into existing group, null = hidden + const [addMode, setAddMode] = useState<"and" | `or:${string}` | null>(null); - // Sync incoming filterGroups changes (e.g., external clear) - // We use a simple pattern: if parent clears, reset draft too - function openNewFilter() { - setAddToGroupId(null); - setShowForm(true); + function openAndFilter() { + setAddMode("and"); } function openOrFilter(groupId: string) { - setAddToGroupId(groupId); - setShowForm(true); + setAddMode(`or:${groupId}`); } function handleConfirm(groupId: string | null, condition: PropertyFilter) { setDraft((prev) => { if (groupId === null) { - // New group + // New AND group const newGroup: FilterGroup = { id: crypto.randomUUID(), conditions: [condition], @@ -420,8 +416,7 @@ export default function PropertyFilters({ ); } }); - setShowForm(false); - setAddToGroupId(null); + setAddMode(null); } function removeCondition(groupId: string, conditionId: string) { @@ -446,81 +441,100 @@ export default function PropertyFilters({ onChange([]); } + const hasGroups = draft.length > 0; + return (
{/* Group list */} - {draft.map((group, groupIdx) => ( -
- {/* AND divider between groups */} - {groupIdx > 0 && ( -
-
- - and - -
-
- )} - -
- {group.conditions.map((condition, condIdx) => ( -
- {/* OR label between conditions in same group */} - {condIdx > 0 && ( -
-
- - or - -
-
- )} - removeCondition(group.id, condition.id)} - /> + {draft.map((group, groupIdx) => { + const isOrTarget = addMode === `or:${group.id}`; + return ( +
+ {/* AND divider between groups */} + {groupIdx > 0 && ( +
+
+ + AND + +
- ))} - - {/* Add OR condition to this group */} - {!(showForm && addToGroupId === group.id) && ( - )} - {showForm && addToGroupId === group.id && ( - { setShowForm(false); setAddToGroupId(null); }} - /> - )} +
+ {group.conditions.map((condition, condIdx) => ( +
+ {/* OR label between conditions in same group */} + {condIdx > 0 && ( +
+
+ + OR + +
+
+ )} + removeCondition(group.id, condition.id)} + /> +
+ ))} + + {/* OR form inline */} + {isOrTarget ? ( + setAddMode(null)} + /> + ) : ( + + )} +
-
- ))} + ); + })} - {/* Add new filter group form */} - {showForm && addToGroupId === null ? ( + {/* AND: add new group form or button */} + {addMode === "and" ? ( { setShowForm(false); setAddToGroupId(null); }} + onCancel={() => setAddMode(null)} /> ) : ( - +
+ {hasGroups && ( + <> +
+ + then + +
+ + )} + +
)} {/* Apply / Clear */}