mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
enhancing filter capabilities for property table
This commit is contained in:
parent
910198e847
commit
08af8e3bf1
2 changed files with 418 additions and 74 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue