/*********************************************************************************************************************************************************************** * 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", "Banned": "bg-red-100 text-red-700 border-red-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", // ── Generic boolean labels (BOOLEAN_FIELD_LABELS) ───────── "Yes": "bg-emerald-100 text-emerald-700 border-emerald-200", "No": "bg-zinc-100 text-zinc-600 border-zinc-200", }; function EnumBadge({ value }) { return ( {value} ); } export function formatDate(value) { if (!value) return null; try { return format(new Date(value), "MMM d, yyyy"); } catch { return value; } } // Human-readable labels for boolean-backed enum fields — [falseLabel, trueLabel]. // Shared by renderCell (table cells) and ColumnFilter (filter dropdown), so a // "true"/"false" boolean column never surfaces its raw value to the admin. export const BOOLEAN_FIELD_LABELS = { is_active: ["Not Active", "Active"], is_verified: ["Not Verified", "Verified"], is_public: ["Private", "Public"], is_banned: ["Not Banned", "Banned"], is_required: ["No", "Yes"], }; function renderCell(attr, value) { if (value === null || value === undefined || value === "") { return -; } switch (attr.type) { case "enum": { const boolLabels = BOOLEAN_FIELD_LABELS[attr.field]; if (boolLabels) return ; return ; } case "date": return {formatDate(value)}; case "number": return {value}; default: return {value}; } } 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") { const boolLabels = BOOLEAN_FIELD_LABELS[attr.field]; const choiceLabel = (c) => boolLabels ? boolLabels[c === "true" ? 1 : 0] : c; return ( ); } // ───────────────────────────────────────────── // DATE (SINGLE) // ───────────────────────────────────────────── if (attr.type === "date") { const value = inputValue && typeof inputValue === "object" && ("from" in inputValue || "to" in inputValue) ? inputValue : null; const isRange = !!value; return (
{/* MODE TOGGLE */}
{/* SINGLE DATE */} {!isRange && ( { const val = e.target.value; setInputValue(val); column.setFilterValue(val); }} className="h-7 text-xs" /> )} {/* RANGE */} {isRange && (
{ const next = { from: e.target.value, to: value.to || "", }; setInputValue(next); column.setFilterValue(next); }} className="h-7 text-xs" /> { const next = { from: value.from || "", to: e.target.value, }; setInputValue(next); column.setFilterValue(next); }} className="h-7 text-xs" />
)}
); } // ───────────────────────────────────────────── // TEXT FILTER (manual apply) // ───────────────────────────────────────────── return (
setInputValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { column.setFilterValue(inputValue); } }} placeholder="Filter..." className="h-7 text-xs w-full" />
); } // ── Sort icon ────────────────────────────────────────────────────────────────── export function SortIcon({ column }) { const s = column.getIsSorted(); if (s === "asc") return ; if (s === "desc") return ; return ; } // ── 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 -; } const str = String(value); if (str.length <= LONG_TEXT_THRESHOLD) { return {str}; } return ( <> {label}

{str}

); } export function longTextCell(label) { return (info) => ; } // ── 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 }, }) ); }