Files
starr-philproperties/src/utils/table.util.jsx
T
2026-05-07 23:35:31 +08:00

254 lines
8.5 KiB
React

import { useState, useEffect } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { format } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Search } from "lucide-react";
export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000]
// ── Badge style map for enum values ───────────────────────────────────────────
export const BADGE_STYLES = {
"Active": "bg-emerald-100 text-emerald-800 border-emerald-200",
"Not Active": "bg-rose-100 text-rose-700 border-rose-200",
"Verified": "bg-blue-100 text-blue-800 border-blue-200",
"Not Verified": "bg-amber-100 text-amber-700 border-amber-200",
"system": "bg-violet-50 text-violet-700 border-violet-200",
"google": "bg-orange-50 text-orange-700 border-orange-200",
"admin": "bg-red-100 text-red-700 border-red-200",
"user": "bg-blue-100 text-blue-700 border-blue-200",
"staff": "bg-green-100 text-green-700 border-green-200",
};
function EnumBadge({ value }) {
return (
<Badge
variant="outline"
className={`text-[11px] font-medium px-2 py-0.5 ${BADGE_STYLES[value] ?? ""}`}
>
{value}
</Badge>
);
}
export function formatDate(value) {
if (!value) return null;
try { return format(new Date(value), "MMM d, yyyy"); }
catch { return value; }
}
function renderCell(attr, value) {
if (value === null || value === undefined || value === "") {
return <span className="text-muted-foreground/40">-</span>;
}
switch (attr.type) {
case "enum":
if (attr.field === "is_active")
return <EnumBadge value={value ? "Active" : "Not Active"} />;
else if (attr.field === "is_verified")
return <EnumBadge value={value ? "Verified" : "Not Verified"} />;
else
return <EnumBadge value={value} />;
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>;
default: return <span className="text-sm">{value}</span>;
}
}
export function ColumnFilter({ column, attr }) {
const columnValue = column.getFilterValue() ?? "";
const [inputValue, setInputValue] = useState(columnValue);
// keep in sync when external reset happens
useEffect(() => {
setInputValue(columnValue);
}, [columnValue]);
// ─────────────────────────────────────────────
// ENUM (AUTO APPLY)
// ─────────────────────────────────────────────
if (attr.type === "enum") {
return (
<Select
value={inputValue || "__all__"}
onValueChange={(v) => {
const value = v === "__all__" ? "" : v;
setInputValue(value);
column.setFilterValue(value); // ✅ auto apply
}}
>
<SelectTrigger className="h-7 text-xs w-full">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
{attr.options?.choices?.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ─────────────────────────────────────────────
// DATE (SINGLE)
// ─────────────────────────────────────────────
if (attr.type === "date") {
const value =
inputValue &&
typeof inputValue === "object" &&
("from" in inputValue || "to" in inputValue)
? inputValue
: null;
const isRange = !!value;
return (
<div className="flex flex-col gap-1">
{/* MODE TOGGLE */}
<div className="flex items-center gap-2 text-xs">
<label className="flex items-center gap-1">
<input
type="radio"
checked={!isRange}
onChange={() => {
setInputValue("");
column.setFilterValue("");
}}
/>
Single
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={isRange}
onChange={() => {
setInputValue({ from: "", to: "" });
column.setFilterValue({ from: "", to: "" });
}}
/>
Range
</label>
</div>
{/* SINGLE DATE */}
{!isRange && (
<Input
type="date"
value={inputValue || ""}
onChange={(e) => {
const val = e.target.value;
setInputValue(val);
column.setFilterValue(val);
}}
className="h-7 text-xs"
/>
)}
{/* RANGE */}
{isRange && (
<div className="flex gap-1">
<Input
type="date"
value={value.from}
onChange={(e) => {
const next = {
from: e.target.value,
to: value.to || "",
};
setInputValue(next);
column.setFilterValue(next);
}}
className="h-7 text-xs"
/>
<Input
type="date"
value={value.to}
onChange={(e) => {
const next = {
from: value.from || "",
to: e.target.value,
};
setInputValue(next);
column.setFilterValue(next);
}}
className="h-7 text-xs"
/>
</div>
)}
</div>
);
}
// ─────────────────────────────────────────────
// TEXT FILTER (manual apply)
// ─────────────────────────────────────────────
return (
<div className="flex gap-1">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
column.setFilterValue(inputValue);
}
}}
placeholder="Filter..."
className="h-7 text-xs w-full"
/>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() => column.setFilterValue(inputValue)}
>
<Search className="size-3.5" />
</Button>
</div>
);
}
// ── Sort icon ──────────────────────────────────────────────────────────────────
export function SortIcon({ column }) {
const s = column.getIsSorted();
if (s === "asc") return <ArrowUp className="ml-1 h-3 w-3 text-primary shrink-0" />;
if (s === "desc") return <ArrowDown className="ml-1 h-3 w-3 text-primary shrink-0" />;
return <ArrowUpDown className="ml-1 h-3 w-3 text-muted-foreground/40 shrink-0" />;
}
// ── Build columns dynamically from attributes ──────────────────────────────────
const columnHelper = createColumnHelper();
export function buildColumns(attributes, { cellOverrides = {} } = {}) {
return attributes.map((attr) =>
columnHelper.accessor(attr.field, {
id: attr.field,
header: attr.name,
enableSorting: true,
enableColumnFilter: true,
filterFn: (row, colId, filterValue) => {
if (!filterValue) return true;
const val = String(row.getValue(colId) ?? "").toLowerCase();
return val.includes(String(filterValue).toLowerCase());
},
cell: (info) =>
cellOverrides[attr.field] // ← check override first
? cellOverrides[attr.field](info)
: renderCell(attr, info.getValue()),
meta: { attr },
})
);
}