enhancing filter capabilities for property table

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-13 11:43:07 +00:00
parent 910198e847
commit 08af8e3bf1
2 changed files with 418 additions and 74 deletions

View file

@ -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<RawFileType, string> = {
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<string> {
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 (
<div className="group flex items-center gap-4 bg-white rounded-xl px-5 py-4 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-[#eff6fc] flex items-center justify-center text-[#3943b7] flex-shrink-0">
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-[#15173e] truncate">
{filename}
</p>
<p className="text-xs text-slate-400 mt-0.5">
{label} · {date}
</p>
</div>
<button
onClick={handleDownload}
disabled={loading}
className="w-9 h-9 rounded-full bg-[#15173e] text-white flex items-center justify-center hover:opacity-80 active:scale-95 transition-all disabled:opacity-50"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</button>
</div>
);
}
return (
<div className="group bg-white p-6 rounded-2xl shadow-sm hover:-translate-y-1 hover:shadow-md transition-all duration-300">
<div className="flex justify-between items-start mb-5">
<div className="w-11 h-11 rounded-xl bg-[#eff6fc] flex items-center justify-center text-[#3943b7]">
<Icon className="w-5 h-5" />
</div>
</div>
<p className="text-xs font-bold uppercase tracking-widest text-slate-400 mb-1">
{label}
</p>
<h3 className="text-sm font-semibold text-[#15173e] mb-1 leading-snug break-all line-clamp-2">
{filename}
</h3>
<div className="flex items-center justify-between pt-4 mt-4 border-t border-slate-100">
<span className="text-xs text-slate-400">{date}</span>
<button
onClick={handleDownload}
disabled={loading}
className="w-9 h-9 rounded-full bg-[#15173e] text-white flex items-center justify-center group-hover:scale-110 active:scale-95 transition-transform disabled:opacity-50"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</button>
</div>
</div>
);
}
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 (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-extrabold text-[#15173e] tracking-tight">
Documents
</h1>
</div>
{/* Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
{/* Filter pills */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setActiveFilter("All")}
className={`px-4 py-2 rounded-full text-sm font-semibold transition-colors ${
activeFilter === "All"
? "bg-[#15173e] text-white shadow"
: "bg-white text-slate-600 hover:bg-slate-100"
}`}
>
All Documents
</button>
{presentGroups.map((g) => (
<button
key={g.label}
onClick={() => setActiveFilter(g.label)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
activeFilter === g.label
? "bg-[#15173e] text-white shadow"
: "bg-white text-slate-600 hover:bg-slate-100"
}`}
>
{g.label}
</button>
))}
</div>
{/* View toggle */}
<div className="bg-slate-100 rounded-xl p-1 flex self-start sm:self-auto">
<button
onClick={() => setViewMode("grid")}
className={`px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all ${
viewMode === "grid"
? "bg-white text-[#15173e] shadow-sm"
: "text-slate-400"
}`}
>
<LayoutGrid className="w-3.5 h-3.5" />
Grid
</button>
<button
onClick={() => setViewMode("list")}
className={`px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all ${
viewMode === "list"
? "bg-white text-[#15173e] shadow-sm"
: "text-slate-400"
}`}
>
<List className="w-3.5 h-3.5" />
List
</button>
</div>
</div>
{/* Empty state */}
{filteredDocs.length === 0 && (
<div className="py-16 text-center text-slate-400 text-sm">
No documents found.
</div>
)}
{/* Document groups */}
{filteredDocs.length > 0 && (
<div className="space-y-10">
{groupsToShow.map((group) => {
const groupDocs = filteredDocs.filter((d) =>
group.types.includes(d.docType)
);
if (groupDocs.length === 0) return null;
return (
<div key={group.label}>
{/* Section header */}
<div className="flex items-center gap-4 mb-4">
<h2 className="text-xs font-black uppercase tracking-widest text-slate-400 whitespace-nowrap">
{group.label}
</h2>
<div className="h-px flex-1 bg-slate-200" />
</div>
{/* Cards */}
{viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{groupDocs.map((doc) => (
<DocumentCard key={doc.id} doc={doc} viewMode="grid" />
))}
</div>
) : (
<div className="flex flex-col gap-2">
{groupDocs.map((doc) => (
<DocumentCard key={doc.id} doc={doc} viewMode="list" />
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -387,25 +387,21 @@ export default function PropertyFilters({
}) {
// Draft state — only applied when user clicks Apply
const [draft, setDraft] = useState<FilterGroups>(filterGroups);
const [showForm, setShowForm] = useState(false);
const [addToGroupId, setAddToGroupId] = useState<string | null>(null);
// "and" = new group, "or:<groupId>" = 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 (
<div className="flex flex-col gap-3 p-3">
{/* Group list */}
{draft.map((group, groupIdx) => (
<div key={group.id}>
{/* AND divider between groups */}
{groupIdx > 0 && (
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 border-t border-gray-200" />
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1">
and
</span>
<div className="flex-1 border-t border-gray-200" />
</div>
)}
<div className="flex flex-col gap-1.5">
{group.conditions.map((condition, condIdx) => (
<div key={condition.id}>
{/* OR label between conditions in same group */}
{condIdx > 0 && (
<div className="flex items-center gap-2 my-1">
<div className="flex-1 border-t border-dashed border-gray-200" />
<span className="text-[10px] font-semibold text-blue-400 uppercase tracking-wide px-1">
or
</span>
<div className="flex-1 border-t border-dashed border-gray-200" />
</div>
)}
<ConditionRow
condition={condition}
onRemove={() => removeCondition(group.id, condition.id)}
/>
{draft.map((group, groupIdx) => {
const isOrTarget = addMode === `or:${group.id}`;
return (
<div key={group.id}>
{/* AND divider between groups */}
{groupIdx > 0 && (
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 border-t border-gray-300" />
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wide bg-white px-1.5 py-0.5 rounded border border-gray-300">
AND
</span>
<div className="flex-1 border-t border-gray-300" />
</div>
))}
{/* Add OR condition to this group */}
{!(showForm && addToGroupId === group.id) && (
<button
type="button"
onClick={() => openOrFilter(group.id)}
className="self-start text-[11px] text-blue-500 hover:text-blue-700 flex items-center gap-0.5 mt-0.5"
>
<Plus className="h-3 w-3" />
or
</button>
)}
{showForm && addToGroupId === group.id && (
<AddFilterForm
targetGroupId={group.id}
onConfirm={handleConfirm}
onCancel={() => { setShowForm(false); setAddToGroupId(null); }}
/>
)}
<div className="flex flex-col gap-1.5 bg-white border border-gray-200 rounded-md p-2">
{group.conditions.map((condition, condIdx) => (
<div key={condition.id}>
{/* OR label between conditions in same group */}
{condIdx > 0 && (
<div className="flex items-center gap-2 my-1">
<div className="flex-1 border-t border-dashed border-blue-200" />
<span className="text-[10px] font-bold text-blue-500 uppercase tracking-wide bg-blue-50 px-1.5 py-0.5 rounded border border-blue-200">
OR
</span>
<div className="flex-1 border-t border-dashed border-blue-200" />
</div>
)}
<ConditionRow
condition={condition}
onRemove={() => removeCondition(group.id, condition.id)}
/>
</div>
))}
{/* OR form inline */}
{isOrTarget ? (
<AddFilterForm
targetGroupId={group.id}
onConfirm={handleConfirm}
onCancel={() => setAddMode(null)}
/>
) : (
<button
type="button"
onClick={() => openOrFilter(group.id)}
className="self-start mt-1 flex items-center gap-1 text-[11px] font-semibold text-blue-500 hover:text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded px-2 py-0.5 transition"
>
<Plus className="h-3 w-3" />
OR
</button>
)}
</div>
</div>
</div>
))}
);
})}
{/* Add new filter group form */}
{showForm && addToGroupId === null ? (
{/* AND: add new group form or button */}
{addMode === "and" ? (
<AddFilterForm
targetGroupId={null}
onConfirm={handleConfirm}
onCancel={() => { setShowForm(false); setAddToGroupId(null); }}
onCancel={() => setAddMode(null)}
/>
) : (
<button
type="button"
onClick={openNewFilter}
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 border border-dashed border-gray-300 rounded-md px-3 py-2 hover:border-gray-400 transition"
>
<Plus className="h-3.5 w-3.5" />
Add filter
</button>
<div className={hasGroups ? "flex items-center gap-2" : undefined}>
{hasGroups && (
<>
<div className="flex-1 border-t border-gray-300" />
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide px-1">
then
</span>
<div className="flex-1 border-t border-gray-300" />
</>
)}
<button
type="button"
onClick={openAndFilter}
className={[
"flex items-center gap-1 text-sm font-semibold transition",
hasGroups
? "shrink-0 text-gray-600 hover:text-gray-900 bg-white border border-gray-300 rounded px-2 py-0.5 text-[11px] hover:border-gray-400"
: "w-full justify-center text-gray-600 hover:text-gray-900 border border-dashed border-gray-300 rounded-md px-3 py-2 hover:border-gray-400",
].join(" ")}
>
<Plus className="h-3.5 w-3.5" />
{hasGroups ? "AND" : "Add filter"}
</button>
</div>
)}
{/* Apply / Clear */}