tweaking layout

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-13 11:33:43 +00:00
parent 8c9cceef77
commit 910198e847
4 changed files with 252 additions and 133 deletions

View file

@ -63,7 +63,7 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
return (
<>
<NavigationMenu className="relative">
<NavigationMenu className="relative px-4">
<NavigationMenuList className="flex-wrap">
{navItems.map(({ label, icon: Icon, href, match }) => {
const isActive = match(pathname);
@ -73,14 +73,14 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
<button
onClick={() => router.push(href)}
className={cn(
"relative flex items-center rounded-md text-sm font-medium p-[3px]",
"relative flex items-center rounded-md text-xs font-medium p-[3px]",
isActive &&
"bg-gradient-to-r from-brandblue via-brandbrown to-brandblue"
)}
>
<div
className={cn(
"flex items-center rounded-md px-4 py-2",
"flex items-center rounded-md px-3 py-1.5",
isActive
? "bg-white text-brandblue shadow-sm"
: "bg-gray-50 text-gray-800 hover:bg-midblue hover:text-gray-100"
@ -107,14 +107,14 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
<button
onClick={() => router.push(href)}
className={cn(
"relative flex items-center rounded-md text-sm font-medium p-[3px]",
"relative flex items-center rounded-md text-xs font-medium p-[3px]",
isActive &&
"bg-gradient-to-r from-brandblue via-brandbrown to-brandblue"
)}
>
<div
className={cn(
"flex items-center rounded-md px-4 py-2",
"flex items-center rounded-md px-3 py-1.5",
isActive
? "bg-white text-brandblue shadow-sm"
: "bg-gray-50 text-gray-800 hover:bg-midblue hover:text-gray-100"

View file

@ -31,9 +31,11 @@ export default async function PortfolioLayout(props: {
<div className="col-span-12 justify-center bg-gray-50 py-1 px-4 relative">
<Toolbar portfolioId={portfolioId} scenarios={scenarios} />
</div>
<div className="col-span-12">
{children}
</div>
</div>
</div>
{children}
</section>
);
}

View file

@ -5,8 +5,29 @@ import { useProperties } from "./useProperties";
import DataTable from "./dataTable";
import PropertyFilters from "./PropertyFilters";
import { FilterGroups } from "@/app/utils/propertyFilters";
import { HomeIcon, FunnelIcon, ChevronDownIcon, XMarkIcon } from "@heroicons/react/24/outline";
import {
HomeIcon,
FunnelIcon,
ChevronDownIcon,
XMarkIcon,
ArrowDownTrayIcon,
ViewColumnsIcon,
} from "@heroicons/react/24/outline";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import {
OPTIONAL_COLUMN_IDS,
OPTIONAL_COLUMN_LABELS,
} from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { VisibilityState, Updater } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import {
Dialog,
@ -40,7 +61,7 @@ function EmptyPropertyState() {
----------------------------------------- */
function LoadingOverlay() {
return (
<div className="absolute inset-0 z-10 rounded-md bg-white/60 flex items-center justify-center pointer-events-none">
<div className="absolute inset-0 z-10 rounded-xl bg-white/60 flex items-center justify-center pointer-events-none">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
<span className="text-xs text-gray-500">Updating</span>
@ -79,7 +100,6 @@ function QuickFilterDropdown({
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState(committedValue);
// Sync draft when dropdown opens
useEffect(() => {
if (isOpen) {
setDraft(committedValue);
@ -91,11 +111,13 @@ function QuickFilterDropdown({
onCommit(draft.trim());
}, [draft, onCommit]);
// Close + commit on outside click
useEffect(() => {
if (!isOpen) return;
function handleMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
commit();
}
}
@ -110,21 +132,31 @@ function QuickFilterDropdown({
<button
onClick={onOpen}
className={[
"flex items-center gap-1.5 h-9 px-3 rounded-md border text-sm transition shrink-0",
"flex items-center gap-1.5 h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
isActive
? "border-gray-800 bg-gray-800 text-white"
: "border-gray-300 text-gray-600 hover:bg-gray-50",
? "border-brandblue bg-brandblue text-white"
: "border-slate-200 bg-white text-primary hover:bg-slate-50",
].join(" ")}
>
<span className="font-medium">{label}</span>
<span>{label}</span>
{isActive ? (
<>
<span className="opacity-75 max-w-[120px] truncate">: {committedValue}</span>
<span className="opacity-75 max-w-[120px] truncate">
: {committedValue}
</span>
<span
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onClear(); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onClear(); } }}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation();
onClear();
}
}}
className="ml-0.5 rounded-full hover:opacity-75"
>
<XMarkIcon className="h-3.5 w-3.5" />
@ -136,21 +168,24 @@ function QuickFilterDropdown({
</button>
{isOpen && (
<div className="absolute left-0 top-full mt-1 z-30 bg-white border border-gray-200 rounded-md shadow-md p-2 flex gap-1.5 items-center">
<div className="absolute left-0 top-full mt-1 z-30 bg-white border border-slate-200 rounded-lg shadow-md p-2 flex gap-1.5 items-center">
<input
ref={inputRef}
className={`h-8 rounded border border-gray-300 px-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-black/10 ${inputWidth}`}
className={`h-8 rounded-lg border border-slate-200 px-2.5 text-xs focus:outline-none focus:ring-2 focus:ring-brandblue/20 focus:border-brandblue ${inputWidth}`}
placeholder={placeholder}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") { setDraft(committedValue); onCommit(committedValue); }
if (e.key === "Escape") {
setDraft(committedValue);
onCommit(committedValue);
}
}}
/>
<button
onClick={commit}
className="h-8 px-2.5 rounded bg-gray-800 text-white text-sm hover:bg-gray-700 transition whitespace-nowrap"
className="h-8 px-2.5 rounded-lg bg-brandblue text-white text-xs font-semibold hover:opacity-90 transition whitespace-nowrap"
>
Apply
</button>
@ -168,19 +203,25 @@ export default function PropertyTable({
}: {
portfolioId: string;
}) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
// Committed quick filter values (drives the query)
const [committedAddress, setCommittedAddress] = useState("");
const [committedPostcode, setCommittedPostcode] = useState("");
const [committedPropertyRef, setCommittedPropertyRef] = useState("");
// Which quick filter dropdown is open
const [openFilter, setOpenFilter] = useState<QuickFilterKey | null>(null);
// Advanced filter groups from the sidebar
const [filterGroups, setFilterGroups] = useState<FilterGroups>([]);
// Column visibility — lifted up from DataTable
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const init: VisibilityState = {};
OPTIONAL_COLUMN_IDS.forEach((id) => {
init[id] = false;
});
return init;
},
);
function commitFilter(field: QuickFilterKey, value: string) {
if (field === "address") setCommittedAddress(value);
if (field === "postcode") setCommittedPostcode(value);
@ -201,17 +242,38 @@ export default function PropertyTable({
if (committedAddress)
quick.push({
id: "qa",
conditions: [{ id: "qa-c", field: "address", operator: "contains", value: committedAddress }],
conditions: [
{
id: "qa-c",
field: "address",
operator: "contains",
value: committedAddress,
},
],
});
if (committedPostcode)
quick.push({
id: "qp",
conditions: [{ id: "qp-c", field: "postcode", operator: "starts_with", value: committedPostcode }],
conditions: [
{
id: "qp-c",
field: "postcode",
operator: "starts_with",
value: committedPostcode,
},
],
});
if (committedPropertyRef)
quick.push({
id: "qr",
conditions: [{ id: "qr-c", field: "propertyRef", operator: "contains", value: committedPropertyRef }],
conditions: [
{
id: "qr-c",
field: "propertyRef",
operator: "contains",
value: committedPropertyRef,
},
],
});
return [...quick, ...filterGroups];
}, [committedAddress, committedPostcode, committedPropertyRef, filterGroups]);
@ -228,6 +290,14 @@ export default function PropertyTable({
filterGroups: allFilterGroups,
});
// Second query for total (no filters) — React Query dedupes when filters are empty
const { data: allData = [] } = useProperties({
portfolioId,
filterGroups: [],
});
const totalCount = allData.length;
const filteredCount = data.length;
/* ----------------------------------------
Delete preview state
----------------------------------------- */
@ -239,26 +309,121 @@ export default function PropertyTable({
const [previewError] = useState<string | null>(null);
return (
<div className="px-4 py-3">
<div className="py-4">
{/* Action bar */}
<div className="flex items-center justify-between mb-3">
{/* Left: results count */}
<div className="flex items-center gap-3">
<span className="text-xs font-black uppercase tracking-wider text-primary">
Results:
</span>
{isLoading ? (
<span className="text-xs text-slate-400">Loading</span>
) : (
<span className="text-xs text-slate-500">
Showing{" "}
<span className="font-bold text-primary">
{filteredCount.toLocaleString()}
</span>{" "}
of{" "}
<span className="font-bold text-primary">
{totalCount.toLocaleString()}
</span>{" "}
properties
</span>
)}
{hasActiveFilters && (
<button
onClick={clearAll}
className="text-xs text-slate-400 hover:text-primary underline"
>
Clear all
</button>
)}
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-2">
{/* Filters toggle */}
<button
onClick={() => setSidebarOpen((o) => !o)}
className={[
"flex items-center gap-1.5 h-8 px-3 rounded-lg border text-xs font-semibold transition shrink-0",
sidebarOpen
? "bg-primary text-white border-primary"
: "border-slate-200 bg-white text-primary hover:bg-slate-50",
].join(" ")}
title={sidebarOpen ? "Hide filters" : "Show filters"}
>
<FunnelIcon className="h-3.5 w-3.5" />
Filters
</button>
{/* Edit Columns dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-white text-xs font-semibold text-primary hover:bg-slate-50 transition">
<ViewColumnsIcon className="h-3.5 w-3.5" />
Edit Columns
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs">
Optional Columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{OPTIONAL_COLUMN_IDS.map((colId) => {
const isVisible = columnVisibility[colId] !== false;
return (
<DropdownMenuItem
key={colId}
className="flex items-center gap-2 cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setColumnVisibility((prev) => ({
...prev,
[colId]: !isVisible,
}));
}}
>
<input
type="checkbox"
checked={isVisible}
readOnly
className="h-3.5 w-3.5 accent-black"
/>
<span className="text-xs">
{OPTIONAL_COLUMN_LABELS[colId]}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* Export (stub) */}
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition">
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
</button>
</div>
</div>
{/* Quick filters row */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<button
onClick={() => setSidebarOpen((o) => !o)}
className="flex items-center gap-1.5 h-9 px-3 rounded-md border border-gray-300 text-sm text-gray-600 hover:bg-gray-50 transition shrink-0"
title={sidebarOpen ? "Hide filters" : "Show filters"}
>
<FunnelIcon className="h-4 w-4" />
{sidebarOpen ? "Hide filters" : "Filters"}
</button>
<QuickFilterDropdown
label="Address"
placeholder="Contains…"
committedValue={committedAddress}
isOpen={openFilter === "address"}
onOpen={() => setOpenFilter(openFilter === "address" ? null : "address")}
onOpen={() =>
setOpenFilter(openFilter === "address" ? null : "address")
}
onCommit={(v) => commitFilter("address", v)}
onClear={() => { setCommittedAddress(""); setOpenFilter(null); }}
onClear={() => {
setCommittedAddress("");
setOpenFilter(null);
}}
inputWidth="w-52"
/>
@ -267,9 +432,14 @@ export default function PropertyTable({
placeholder="e.g. E17"
committedValue={committedPostcode}
isOpen={openFilter === "postcode"}
onOpen={() => setOpenFilter(openFilter === "postcode" ? null : "postcode")}
onOpen={() =>
setOpenFilter(openFilter === "postcode" ? null : "postcode")
}
onCommit={(v) => commitFilter("postcode", v)}
onClear={() => { setCommittedPostcode(""); setOpenFilter(null); }}
onClear={() => {
setCommittedPostcode("");
setOpenFilter(null);
}}
inputWidth="w-32"
/>
@ -278,37 +448,32 @@ export default function PropertyTable({
placeholder="Landlord ref…"
committedValue={committedPropertyRef}
isOpen={openFilter === "propertyRef"}
onOpen={() => setOpenFilter(openFilter === "propertyRef" ? null : "propertyRef")}
onOpen={() =>
setOpenFilter(openFilter === "propertyRef" ? null : "propertyRef")
}
onCommit={(v) => commitFilter("propertyRef", v)}
onClear={() => { setCommittedPropertyRef(""); setOpenFilter(null); }}
onClear={() => {
setCommittedPropertyRef("");
setOpenFilter(null);
}}
inputWidth="w-40"
/>
{hasActiveFilters && (
<button
onClick={clearAll}
className="h-9 px-3 text-sm text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
{/* Body: sidebar + table */}
<div className="flex items-start gap-4">
<div className="flex items-start">
{/* Collapsible filter sidebar */}
<div
className={[
"shrink-0 overflow-hidden transition-all duration-300 ease-in-out",
"bg-white rounded-md",
"shrink-0 overflow-hidden transition-all duration-300 ease-in-out rounded-xl mr-2",
sidebarOpen
? "w-72 opacity-100 border border-gray-200"
? "w-72 opacity-100 border border-slate-100 bg-slate-50"
: "w-0 opacity-0",
].join(" ")}
>
<div className="w-72">
<p className="px-3 pt-3 pb-0 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Filters
<p className="px-4 pt-4 pb-0 text-[9px] font-black text-primary uppercase tracking-widest">
Curate Selection
</p>
<PropertyFilters
filterGroups={filterGroups}
@ -318,19 +483,23 @@ export default function PropertyTable({
</div>
{/* Table area */}
<div className="flex-1 min-w-0 bg-white rounded-md border border-gray-200 relative">
<div className="flex-1 min-w-0 bg-white rounded-xl border border-slate-100 shadow-sm relative">
{isFetching && !isLoading && <LoadingOverlay />}
{isLoading ? (
<div className="p-6 text-gray-400">Loading properties</div>
<div className="p-6 text-slate-400 text-sm">
Loading properties
</div>
) : isError ? (
<div className="p-6 text-red-500">Failed to load properties.</div>
<div className="p-6 text-red-500 text-sm">
Failed to load properties.
</div>
) : data.length === 0 && hasActiveFilters ? (
<div className="p-10 text-center text-gray-500">
<p>No properties match your filters.</p>
<div className="p-10 text-center text-slate-500">
<p className="text-sm">No properties match your filters.</p>
<button
onClick={clearAll}
className="mt-3 text-sm text-black underline"
className="mt-3 text-xs text-primary underline"
>
Clear filters
</button>
@ -342,6 +511,12 @@ export default function PropertyTable({
data={data}
columns={columns}
onDeleteProperty={(id) => setDeletePropertyId(id)}
columnVisibility={columnVisibility}
onColumnVisibilityChange={
setColumnVisibility as (
updater: Updater<VisibilityState>,
) => void
}
/>
)}
</div>

View file

@ -6,6 +6,7 @@ import {
SortingState,
PaginationState,
VisibilityState,
Updater,
flexRender,
getCoreRowModel,
getFilteredRowModel,
@ -24,23 +25,10 @@ import {
TableRow,
} from "@/app/shadcn_components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { useState } from "react";
import { DataTablePagination } from "./propertyTablePagination";
import { rankItem } from "@tanstack/match-sorter-utils";
import { Button } from "@/app/shadcn_components/ui/button";
import {
OPTIONAL_COLUMN_IDS,
OPTIONAL_COLUMN_LABELS,
} from "./propertyTableColumns";
/* ----------------------------------------
Optional fuzzy global filter
@ -55,12 +43,16 @@ interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
onDeleteProperty?: (propertyId: number) => void;
columnVisibility: VisibilityState;
onColumnVisibilityChange: (updater: Updater<VisibilityState>) => void;
}
export default function DataTable<TData extends Record<string, any>>({
data,
columns,
onDeleteProperty,
columnVisibility,
onColumnVisibilityChange,
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@ -69,15 +61,6 @@ export default function DataTable<TData extends Record<string, any>>({
pageIndex: 0,
pageSize: 7,
});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
const init: VisibilityState = {};
OPTIONAL_COLUMN_IDS.forEach((id) => {
init[id] = false;
});
return init;
}
);
const table = useReactTable({
data,
@ -92,7 +75,7 @@ export default function DataTable<TData extends Record<string, any>>({
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
onColumnVisibilityChange: setColumnVisibility,
onColumnVisibilityChange,
globalFilterFn: fuzzyFilter,
@ -110,48 +93,7 @@ export default function DataTable<TData extends Record<string, any>>({
});
return (
<div className="rounded-md">
{/* Edit Columns toolbar */}
<div className="flex justify-end px-4 py-2 border-b">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="text-xs gap-1.5">
Edit Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs">
Optional Columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{OPTIONAL_COLUMN_IDS.map((colId) => {
const col = table.getColumn(colId);
if (!col) return null;
return (
<DropdownMenuItem
key={colId}
className="flex items-center gap-2 cursor-pointer"
onSelect={(e) => {
e.preventDefault();
col.toggleVisibility();
}}
>
<input
type="checkbox"
checked={col.getIsVisible()}
readOnly
className="h-3.5 w-3.5 accent-black"
/>
<span className="text-xs">
{OPTIONAL_COLUMN_LABELS[colId]}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-xl">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (