mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
365 lines
14 KiB
React
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 },
|
|
})
|
|
);
|
|
} |