Files
starr-philproperties/src/utils/table.util.jsx
T
kennethobsequio fbef7cb6e6 ready to test
Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-22 10:07:20 +08:00

365 lines
14 KiB
React

/***********************************************************************************************************************************************************************
* File Name : table.util.jsx
* Type : Utility
* Description : Shared helpers for the generic admin DataTable system.
* Provides:
* - pageSizes constant for the page-size selector
* - BADGE_STYLES + EnumBadge for enum-type column rendering
* - formatDate (date-only, "MMM d, yyyy") used as the default
* renderer for "date"-type attributes app-wide
* - renderCell, the default per-type cell renderer dispatched
* by buildColumns when no cellOverride is supplied
* - ColumnFilter, the per-column filter UI (enum/date/text)
* - SortIcon, the header sort indicator
* - longTextCell, a reusable cellOverride factory for long
* values (UUIDs, free text notes, etc.) — truncates the
* value and shows an "Eye · View" trigger that opens a
* shadcn Dialog with the full value
* - buildColumns, which turns server-provided attribute
* metadata into TanStack column definitions
*
* Author: rgrgogu
* Modified by: Kenneth Obsequio (@lash0000)
* Date Created: May 6, 2026
* Date Modified: Jun 17, 2026
***********************************************************************************************************************************************************************/
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 {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Search, ArrowUp, ArrowDown, ArrowUpDown, Eye } 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 = {
// ── User status ───────────────────────────────
"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",
// ── Registration / account type ───────────────
"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",
// ── Asset — file_type ─────────────────────────
"image": "bg-sky-100 text-sky-700 border-sky-200",
"video": "bg-purple-100 text-purple-700 border-purple-200",
"document": "bg-amber-100 text-amber-700 border-amber-200",
"other": "bg-zinc-100 text-zinc-600 border-zinc-200",
// ── Asset — storage_provider ──────────────────
"chibisafe": "bg-pink-100 text-pink-700 border-pink-200",
"local": "bg-zinc-100 text-zinc-600 border-zinc-200",
"s3": "bg-orange-100 text-orange-700 border-orange-200",
"gcs": "bg-blue-50 text-blue-600 border-blue-200",
"cloudinary": "bg-indigo-100 text-indigo-700 border-indigo-200",
// ── Asset — access_level ──────────────────────
"public": "bg-emerald-100 text-emerald-700 border-emerald-200",
"private": "bg-rose-100 text-rose-700 border-rose-200",
"restricted": "bg-amber-100 text-amber-700 border-amber-200",
// ── Asset — is_public ─────────────────────────
"true": "bg-emerald-100 text-emerald-700 border-emerald-200",
"false": "bg-zinc-100 text-zinc-600 border-zinc-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 if (attr.field === "is_public")
return <EnumBadge value={value ? "Public" : "Private"} />;
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);
}}
>
<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" />;
}
// ── Long text cell — truncate + "Eye · View" dialog trigger ────────────────────
//
// Generic, reusable across any table/column. Any value longer than the
// threshold renders truncated with a small "View" indicator; clicking opens
// a shadcn Dialog showing the full value. Values at or under the threshold
// render plainly, same as the default text cell.
//
// Usage in any columns.config.jsx:
// import { longTextCell } from "@/utils/table.util";
// const cellOverrides = {
// completion_id: longTextCell("Completion ID"),
// note: longTextCell("Note"),
// };
const LONG_TEXT_THRESHOLD = 24; // characters — like UUIDs are 36, short notes may be under this
function LongTextCell({ value, label = "Full value" }) {
const [open, setOpen] = useState(false);
if (value === null || value === undefined || value === "") {
return <span className="text-muted-foreground/40">-</span>;
}
const str = String(value);
if (str.length <= LONG_TEXT_THRESHOLD) {
return <span className="text-sm">{str}</span>;
}
return (
<>
<Button
type="button"
onClick={() => setOpen(true)}
variant="ghost"
size="sm"
>
<div className="truncate max-w-[160px]">{str}</div>
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<Eye className="size-3.5" /> View
</div>
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{label}</DialogTitle>
</DialogHeader>
<p className="text-sm break-all bg-muted rounded-md p-3">{str}</p>
</DialogContent>
</Dialog>
</>
);
}
export function longTextCell(label) {
return (info) => <LongTextCell value={info.getValue()} label={label} />;
}
// ── 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 },
})
);
}