feat(admin): add client-side search, sort, and filter chips to all admin tables
Adds a reusable useTableControls hook and TableControls/SortableTh components, then wires them into all six admin table pages (users, vendors, vessels, sites, accounts, products). Each page now supports a global search bar, clickable sortable column headers with ↑/↓/⇅ indicators, and role/status filter chips — all purely client-side with no URL params or server round-trips. Server pages continue to fetch the full list and pass it as props to a new *-table.tsx Client Component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bff9696b7b
commit
9758dcd8ab
14 changed files with 1155 additions and 439 deletions
111
App/app/(portal)/admin/accounts/accounts-table.tsx
Normal file
111
App/app/(portal)/admin/accounts/accounts-table.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||
import { AddAccountButton, EditAccountButton } from "./account-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteAccount } from "./actions";
|
||||
|
||||
export type AccountRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const CHIPS = ["Active", "Inactive"];
|
||||
|
||||
export function AccountsTable({
|
||||
accounts,
|
||||
suggestedCode,
|
||||
}: {
|
||||
accounts: AccountRow[];
|
||||
suggestedCode: string;
|
||||
}) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<AccountRow>({
|
||||
rows: accounts,
|
||||
defaultSortKey: "code",
|
||||
searchText: (a) =>
|
||||
[a.code, a.name, a.description ?? "", a.isActive ? "active" : "inactive"].join(" "),
|
||||
chipMatch: (a, chip) => {
|
||||
if (chip.toLowerCase() === "active") return a.isActive;
|
||||
if (chip.toLowerCase() === "inactive") return !a.isActive;
|
||||
return false;
|
||||
},
|
||||
sortValue: (a, key) => {
|
||||
if (key === "isActive") return a.isActive ? "Active" : "Inactive";
|
||||
const val = a[key as keyof AccountRow];
|
||||
if (val === null || val === undefined) return "";
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Account Management</h1>
|
||||
<AddAccountButton suggestedCode={suggestedCode} />
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search accounts…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Code</SortableTh>
|
||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Name</SortableTh>
|
||||
<SortableTh sortKey="description" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Description</SortableTh>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof AccountRow)}>Status</SortableTh>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
|
||||
No accounts match your search.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((account) => (
|
||||
<tr key={account.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs font-medium text-neutral-700">{account.code}</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{account.name}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 text-xs">{account.description ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
account.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{account.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditAccountButton account={{
|
||||
id: account.id,
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
description: account.description,
|
||||
isActive: account.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteAccount.bind(null, account.id)} label={account.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,8 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AddAccountButton, EditAccountButton } from "./account-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteAccount } from "./actions";
|
||||
import { nextId } from "@/lib/id-generators";
|
||||
import { AccountsTable } from "./accounts-table";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Account Management" };
|
||||
|
|
@ -21,58 +19,15 @@ export default async function AdminAccountsPage() {
|
|||
const suggestedCode = nextId("ACC", accounts.map((a) => a.code));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Account Management</h1>
|
||||
<AddAccountButton suggestedCode={suggestedCode} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Description</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{accounts.map((account) => (
|
||||
<tr key={account.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs font-medium text-neutral-700">{account.code}</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{account.name}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 text-xs">{account.description ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
account.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{account.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditAccountButton account={{
|
||||
id: account.id,
|
||||
code: account.code,
|
||||
name: account.name,
|
||||
description: account.description,
|
||||
isActive: account.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteAccount.bind(null, account.id)} label={account.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{accounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">No accounts yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<AccountsTable
|
||||
suggestedCode={suggestedCode}
|
||||
accounts={accounts.map((a) => ({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
description: a.description ?? null,
|
||||
isActive: a.isActive,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { AddProductButton, ToggleProductButton } from "./product-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteProduct } from "./actions";
|
||||
import { ProductsTable } from "./products-table";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Item Catalogue" };
|
||||
|
|
@ -27,93 +23,19 @@ export default async function AdminProductsPage() {
|
|||
const canManage = hasPermission(session.user.role, "manage_products") && session.user.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Item Catalogue</h1>
|
||||
{canManage && <AddProductButton />}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Description</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Vendors</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Last Price</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Last Vendor</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||
{canManage && <th className="px-4 py-3"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{products.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canManage ? 9 : 8} className="px-4 py-8 text-center text-neutral-400">
|
||||
No items yet. Items are added automatically when a PO is marked as paid.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-500">{product.code}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">
|
||||
{product.description ?? <span className="italic text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">
|
||||
{product._count.vendorPrices > 0
|
||||
? product._count.vendorPrices
|
||||
: <span className="text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700">
|
||||
{product.lastPrice !== null
|
||||
? formatCurrency(Number(product.lastPrice))
|
||||
: <span className="text-neutral-400 italic">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{product.lastVendor?.name ?? <span className="italic text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(product.updatedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
product.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{product.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
{canManage && (
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<ToggleProductButton product={{
|
||||
id: product.id,
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
isActive: product.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteProduct.bind(null, product.id)} label={product.name} />
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-neutral-400">
|
||||
Items and vendor prices are updated automatically when a PO is marked as paid.
|
||||
</p>
|
||||
</div>
|
||||
<ProductsTable
|
||||
canManage={canManage}
|
||||
products={products.map((p) => ({
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
description: p.description ?? null,
|
||||
lastPrice: p.lastPrice !== null ? Number(p.lastPrice) : null,
|
||||
lastVendorName: p.lastVendor?.name ?? null,
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
isActive: p.isActive,
|
||||
vendorPriceCount: p._count.vendorPrices,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
152
App/app/(portal)/admin/products/products-table.tsx
Normal file
152
App/app/(portal)/admin/products/products-table.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||
import { AddProductButton, ToggleProductButton } from "./product-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteProduct } from "./actions";
|
||||
|
||||
export type ProductRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lastPrice: number | null;
|
||||
lastVendorName: string | null;
|
||||
updatedAt: string; // ISO string
|
||||
isActive: boolean;
|
||||
vendorPriceCount: number;
|
||||
};
|
||||
|
||||
const CHIPS = ["Active", "Inactive"];
|
||||
|
||||
export function ProductsTable({
|
||||
products,
|
||||
canManage,
|
||||
}: {
|
||||
products: ProductRow[];
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<ProductRow>({
|
||||
rows: products,
|
||||
defaultSortKey: "name",
|
||||
searchText: (p) =>
|
||||
[p.code, p.name, p.description ?? "", p.lastVendorName ?? "", p.isActive ? "active" : "inactive"].join(" "),
|
||||
chipMatch: (p, chip) => {
|
||||
if (chip.toLowerCase() === "active") return p.isActive;
|
||||
if (chip.toLowerCase() === "inactive") return !p.isActive;
|
||||
return false;
|
||||
},
|
||||
sortValue: (p, key) => {
|
||||
if (key === "isActive") return p.isActive ? "Active" : "Inactive";
|
||||
if (key === "lastVendorName") return p.lastVendorName ?? "";
|
||||
if (key === "lastPrice") return p.lastPrice ?? -Infinity;
|
||||
const val = p[key as keyof ProductRow];
|
||||
if (val === null || val === undefined) return "";
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Item Catalogue</h1>
|
||||
{canManage && <AddProductButton />}
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search items…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Name</SortableTh>
|
||||
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Code</SortableTh>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Description</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Vendors</th>
|
||||
<SortableTh sortKey="lastPrice" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)} className="text-right">Last Price</SortableTh>
|
||||
<SortableTh sortKey="lastVendorName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Last Vendor</SortableTh>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Updated</th>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProductRow)}>Status</SortableTh>
|
||||
{canManage && <th className="px-4 py-3"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canManage ? 9 : 8} className="px-4 py-8 text-center text-neutral-400">
|
||||
No items match your search.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/products/${product.id}`}
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-500">{product.code}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">
|
||||
{product.description ?? <span className="italic text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">
|
||||
{product.vendorPriceCount > 0
|
||||
? product.vendorPriceCount
|
||||
: <span className="text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-700">
|
||||
{product.lastPrice !== null
|
||||
? formatCurrency(product.lastPrice)
|
||||
: <span className="text-neutral-400 italic">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{product.lastVendorName ?? <span className="italic text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(product.updatedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
product.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{product.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
{canManage && (
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<ToggleProductButton product={{
|
||||
id: product.id,
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
isActive: product.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteProduct.bind(null, product.id)} label={product.name} />
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-neutral-400">
|
||||
Items and vendor prices are updated automatically when a PO is marked as paid.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,7 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { AddSiteButton, EditSiteButton } from "./site-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteSite } from "./actions";
|
||||
import { SitesTable } from "./sites-table";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Sites" };
|
||||
|
|
@ -25,71 +22,19 @@ export default async function SitesPage() {
|
|||
const canEdit = session.user.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Sites</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Ports, depots and offices with inventory</p>
|
||||
</div>
|
||||
{canEdit && <AddSiteButton />}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Address</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Cost Centres</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||
{canEdit && <th className="px-4 py-3"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{sites.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 8 : 7} className="px-4 py-8 text-center text-neutral-400">
|
||||
No sites yet. Add your first port, depot or office.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sites.map((site) => (
|
||||
<tr key={site.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/sites/${site.id}`} className="font-medium text-primary-600 hover:underline">
|
||||
{site.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-500">{site.code}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">{site.address ?? <span className="italic text-neutral-400">—</span>}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">{site._count.vessels || <span className="text-neutral-400">—</span>}</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">{site._count.inventory || <span className="text-neutral-400">—</span>}</td>
|
||||
<td className="px-4 py-3 text-xs text-neutral-500">
|
||||
{site.latitude && site.longitude
|
||||
? `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}`
|
||||
: <span className="italic text-neutral-400">Not set</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${site.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
|
||||
{site.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />
|
||||
<ConfirmDeleteButton onDelete={deleteSite.bind(null, site.id)} label={site.name} />
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<SitesTable
|
||||
canEdit={canEdit}
|
||||
sites={sites.map((s) => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
address: s.address ?? null,
|
||||
latitude: s.latitude ?? null,
|
||||
longitude: s.longitude ?? null,
|
||||
isActive: s.isActive,
|
||||
vesselCount: s._count.vessels,
|
||||
inventoryCount: s._count.inventory,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
143
App/app/(portal)/admin/sites/sites-table.tsx
Normal file
143
App/app/(portal)/admin/sites/sites-table.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||
import { AddSiteButton, EditSiteButton } from "./site-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteSite } from "./actions";
|
||||
|
||||
export type SiteRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
isActive: boolean;
|
||||
vesselCount: number;
|
||||
inventoryCount: number;
|
||||
};
|
||||
|
||||
const CHIPS = ["Active", "Inactive"];
|
||||
|
||||
export function SitesTable({
|
||||
sites,
|
||||
canEdit,
|
||||
}: {
|
||||
sites: SiteRow[];
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<SiteRow>({
|
||||
rows: sites,
|
||||
defaultSortKey: "name",
|
||||
searchText: (s) =>
|
||||
[s.code, s.name, s.address ?? "", s.isActive ? "active" : "inactive"].join(" "),
|
||||
chipMatch: (s, chip) => {
|
||||
if (chip.toLowerCase() === "active") return s.isActive;
|
||||
if (chip.toLowerCase() === "inactive") return !s.isActive;
|
||||
return false;
|
||||
},
|
||||
sortValue: (s, key) => {
|
||||
if (key === "isActive") return s.isActive ? "Active" : "Inactive";
|
||||
const val = s[key as keyof SiteRow];
|
||||
if (val === null || val === undefined) return "";
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Sites</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Ports, depots and offices with inventory</p>
|
||||
</div>
|
||||
{canEdit && <AddSiteButton />}
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search sites…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Name</SortableTh>
|
||||
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Code</SortableTh>
|
||||
<SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Address</SortableTh>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Cost Centres</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Status</SortableTh>
|
||||
{canEdit && <th className="px-4 py-3"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 8 : 7} className="px-4 py-8 text-center text-neutral-400">
|
||||
No sites match your search.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((site) => (
|
||||
<tr key={site.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/sites/${site.id}`} className="font-medium text-primary-600 hover:underline">
|
||||
{site.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-500">{site.code}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">
|
||||
{site.address ?? <span className="italic text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">
|
||||
{site.vesselCount || <span className="text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">
|
||||
{site.inventoryCount || <span className="text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-neutral-500">
|
||||
{site.latitude && site.longitude
|
||||
? `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}`
|
||||
: <span className="italic text-neutral-400">Not set</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
site.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{site.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditSiteButton site={{
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
code: site.code,
|
||||
address: site.address,
|
||||
latitude: site.latitude,
|
||||
longitude: site.longitude,
|
||||
isActive: site.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteSite.bind(null, site.id)} label={site.name} />
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,25 +2,11 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { AddUserButton, EditUserButton } from "./user-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { GrantSuperUserButton } from "./grant-superuser-button";
|
||||
import { deleteUser } from "./actions";
|
||||
import { UsersTable } from "./users-table";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "User Management" };
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
TECHNICAL: "Technical",
|
||||
MANNING: "Manning",
|
||||
ACCOUNTS: "Accounts",
|
||||
MANAGER: "Manager",
|
||||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
};
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
|
@ -30,65 +16,16 @@ export default async function AdminUsersPage() {
|
|||
const users = await db.user.findMany({ orderBy: { createdAt: "desc" } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">User Management</h1>
|
||||
<AddUserButton />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Employee ID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Email</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Role</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Created</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{user.employeeId}</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{user.name}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">
|
||||
{ROLE_LABELS[user.role] ?? user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
user.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3 flex-wrap">
|
||||
{user.role !== "SUPERUSER" && user.role !== "ADMIN" && (
|
||||
<GrantSuperUserButton userId={user.id} />
|
||||
)}
|
||||
<EditUserButton user={{
|
||||
id: user.id,
|
||||
employeeId: user.employeeId,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteUser.bind(null, user.id)} label={user.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<UsersTable
|
||||
users={users.map((u) => ({
|
||||
id: u.id,
|
||||
employeeId: u.employeeId,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
isActive: u.isActive,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
App/app/(portal)/admin/users/users-table.tsx
Normal file
133
App/app/(portal)/admin/users/users-table.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||
import { AddUserButton, EditUserButton } from "./user-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { GrantSuperUserButton } from "./grant-superuser-button";
|
||||
import { deleteUser } from "./actions";
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
createdAt: string; // serialised as ISO string from server
|
||||
};
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
TECHNICAL: "Technical",
|
||||
MANNING: "Manning",
|
||||
ACCOUNTS: "Accounts",
|
||||
MANAGER: "Manager",
|
||||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
};
|
||||
|
||||
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];
|
||||
|
||||
export function UsersTable({ users }: { users: UserRow[] }) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<UserRow>({
|
||||
rows: users,
|
||||
defaultSortKey: "employeeId",
|
||||
searchText: (u) =>
|
||||
[u.employeeId, u.name, u.email, ROLE_LABELS[u.role] ?? u.role, u.isActive ? "active" : "inactive"].join(" "),
|
||||
chipMatch: (u, chip) => {
|
||||
const chipLower = chip.toLowerCase();
|
||||
if (chipLower === "active") return u.isActive;
|
||||
if (chipLower === "inactive") return !u.isActive;
|
||||
return (ROLE_LABELS[u.role] ?? u.role).toLowerCase() === chipLower;
|
||||
},
|
||||
sortValue: (u, key) => {
|
||||
if (key === "role") return ROLE_LABELS[u.role] ?? u.role;
|
||||
if (key === "isActive") return u.isActive ? "Active" : "Inactive";
|
||||
const v = u[key as keyof UserRow];
|
||||
return typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? v : String(v);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">User Management</h1>
|
||||
<AddUserButton />
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search users…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="employeeId" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Employee ID</SortableTh>
|
||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Name</SortableTh>
|
||||
<SortableTh sortKey="email" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Email</SortableTh>
|
||||
<SortableTh sortKey="role" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Role</SortableTh>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof UserRow)}>Status</SortableTh>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Created</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-neutral-400">
|
||||
No users match your search.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{user.employeeId}</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{user.name}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">
|
||||
{ROLE_LABELS[user.role] ?? user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
user.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500">
|
||||
{new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(new Date(user.createdAt))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3 flex-wrap">
|
||||
{user.role !== "SUPERUSER" && user.role !== "ADMIN" && (
|
||||
<GrantSuperUserButton userId={user.id} />
|
||||
)}
|
||||
<EditUserButton user={{
|
||||
id: user.id,
|
||||
employeeId: user.employeeId,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteUser.bind(null, user.id)} label={user.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
App/app/(portal)/admin/vendors/page.tsx
vendored
113
App/app/(portal)/admin/vendors/page.tsx
vendored
|
|
@ -2,11 +2,8 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { AddVendorButton, EditVendorButton } from "./vendor-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteVendor } from "./actions";
|
||||
import { nextId } from "@/lib/id-generators";
|
||||
import { VendorsTable } from "./vendors-table";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Vendor Registry" };
|
||||
|
|
@ -27,92 +24,26 @@ export default async function AdminVendorsPage() {
|
|||
const suggestedVendorId = nextId("VND", vendors.map((v) => v.vendorId));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Vendor Registry</h1>
|
||||
<AddVendorButton suggestedId={suggestedVendorId} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vendor ID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Verified</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{vendors.map((vendor) => (
|
||||
<tr key={vendor.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">
|
||||
{vendor.vendorId ?? <span className="text-warning-700 italic">Pending</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/vendors/${vendor.id}`}
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{vendor.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{vendor.contacts.length > 0 ? (
|
||||
<>
|
||||
<span>{vendor.contacts[0].name}</span>
|
||||
{vendor.contacts[0].email && (
|
||||
<span className="block text-xs text-neutral-400">{vendor.contacts[0].email}</span>
|
||||
)}
|
||||
{vendor.contacts.length > 1 && (
|
||||
<span className="block text-xs text-neutral-400">+{vendor.contacts.length - 1} more</span>
|
||||
)}
|
||||
</>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">
|
||||
{vendor._count.vendorPrices > 0 ? vendor._count.vendorPrices : <span className="text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"
|
||||
}`}>
|
||||
{vendor.isVerified ? "Verified" : "Unverified"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
vendor.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{vendor.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditVendorButton vendor={{
|
||||
id: vendor.id,
|
||||
name: vendor.name,
|
||||
vendorId: vendor.vendorId,
|
||||
address: vendor.address ?? null,
|
||||
pincode: vendor.pincode ?? null,
|
||||
gstin: vendor.gstin ?? null,
|
||||
isActive: vendor.isActive,
|
||||
contacts: vendor.contacts.map((c) => ({
|
||||
name: c.name, role: c.role ?? "", mobile: c.mobile ?? "",
|
||||
email: c.email ?? "", isPrimary: c.isPrimary,
|
||||
})),
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteVendor.bind(null, vendor.id)} label={vendor.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<VendorsTable
|
||||
suggestedVendorId={suggestedVendorId}
|
||||
vendors={vendors.map((v) => ({
|
||||
id: v.id,
|
||||
vendorId: v.vendorId,
|
||||
name: v.name,
|
||||
gstin: v.gstin ?? null,
|
||||
address: v.address ?? null,
|
||||
pincode: v.pincode ?? null,
|
||||
isVerified: v.isVerified,
|
||||
isActive: v.isActive,
|
||||
itemCount: v._count.vendorPrices,
|
||||
contacts: v.contacts.map((c) => ({
|
||||
name: c.name,
|
||||
role: c.role ?? "",
|
||||
mobile: c.mobile ?? "",
|
||||
email: c.email ?? "",
|
||||
isPrimary: c.isPrimary,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
165
App/app/(portal)/admin/vendors/vendors-table.tsx
vendored
Normal file
165
App/app/(portal)/admin/vendors/vendors-table.tsx
vendored
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||
import { AddVendorButton, EditVendorButton } from "./vendor-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteVendor } from "./actions";
|
||||
|
||||
type ContactRow = {
|
||||
name: string;
|
||||
role: string;
|
||||
mobile: string;
|
||||
email: string;
|
||||
isPrimary: boolean;
|
||||
};
|
||||
|
||||
export type VendorRow = {
|
||||
id: string;
|
||||
vendorId: string | null;
|
||||
name: string;
|
||||
gstin: string | null;
|
||||
address: string | null;
|
||||
pincode: string | null;
|
||||
isVerified: boolean;
|
||||
isActive: boolean;
|
||||
itemCount: number;
|
||||
contacts: ContactRow[];
|
||||
};
|
||||
|
||||
const CHIPS = ["Verified", "Unverified", "Active", "Inactive"];
|
||||
|
||||
export function VendorsTable({
|
||||
vendors,
|
||||
suggestedVendorId,
|
||||
}: {
|
||||
vendors: VendorRow[];
|
||||
suggestedVendorId: string;
|
||||
}) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<VendorRow>({
|
||||
rows: vendors,
|
||||
defaultSortKey: "name",
|
||||
searchText: (v) =>
|
||||
[v.vendorId ?? "", v.name, v.gstin ?? "", v.address ?? "", v.isVerified ? "verified" : "unverified", v.isActive ? "active" : "inactive"].join(" "),
|
||||
chipMatch: (v, chip) => {
|
||||
const cl = chip.toLowerCase();
|
||||
if (cl === "verified") return v.isVerified;
|
||||
if (cl === "unverified") return !v.isVerified;
|
||||
if (cl === "active") return v.isActive;
|
||||
if (cl === "inactive") return !v.isActive;
|
||||
return false;
|
||||
},
|
||||
sortValue: (v, key) => {
|
||||
if (key === "isVerified") return v.isVerified ? "Verified" : "Unverified";
|
||||
if (key === "isActive") return v.isActive ? "Active" : "Inactive";
|
||||
const val = v[key as keyof VendorRow];
|
||||
if (val === null || val === undefined) return "";
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Vendor Registry</h1>
|
||||
<AddVendorButton suggestedId={suggestedVendorId} />
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search vendors…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="vendorId" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Vendor ID</SortableTh>
|
||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Name</SortableTh>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items</th>
|
||||
<SortableTh sortKey="isVerified" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Verified</SortableTh>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VendorRow)}>Status</SortableTh>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-neutral-400">
|
||||
No vendors match your search.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((vendor) => (
|
||||
<tr key={vendor.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">
|
||||
{vendor.vendorId ?? <span className="text-warning-700 italic">Pending</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/vendors/${vendor.id}`}
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{vendor.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600">
|
||||
{vendor.contacts.length > 0 ? (
|
||||
<>
|
||||
<span>{vendor.contacts[0].name}</span>
|
||||
{vendor.contacts[0].email && (
|
||||
<span className="block text-xs text-neutral-400">{vendor.contacts[0].email}</span>
|
||||
)}
|
||||
{vendor.contacts.length > 1 && (
|
||||
<span className="block text-xs text-neutral-400">+{vendor.contacts.length - 1} more</span>
|
||||
)}
|
||||
</>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-neutral-600">
|
||||
{vendor.itemCount > 0 ? vendor.itemCount : <span className="text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"
|
||||
}`}>
|
||||
{vendor.isVerified ? "Verified" : "Unverified"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
vendor.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{vendor.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditVendorButton vendor={{
|
||||
id: vendor.id,
|
||||
name: vendor.name,
|
||||
vendorId: vendor.vendorId,
|
||||
address: vendor.address,
|
||||
pincode: vendor.pincode,
|
||||
gstin: vendor.gstin,
|
||||
isActive: vendor.isActive,
|
||||
contacts: vendor.contacts,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteVendor.bind(null, vendor.id)} label={vendor.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,9 +2,7 @@ import { auth } from "@/auth";
|
|||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AddVesselButton, EditVesselButton } from "./vessel-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteVessel } from "./actions";
|
||||
import { VesselsTable } from "./vessels-table";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Cost Centre Management" };
|
||||
|
|
@ -15,58 +13,20 @@ export default async function AdminVesselsPage() {
|
|||
|
||||
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
||||
|
||||
const vessels = await db.vessel.findMany({ orderBy: { name: "asc" } });
|
||||
const vessels = await db.vessel.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: { site: { select: { name: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Cost Centre Management</h1>
|
||||
<AddVesselButton />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{vessels.map((vessel) => (
|
||||
<tr key={vessel.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{vessel.code}</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{vessel.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
vessel.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{vessel.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditVesselButton vessel={{
|
||||
id: vessel.id,
|
||||
name: vessel.name,
|
||||
code: vessel.code,
|
||||
isActive: vessel.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteVessel.bind(null, vessel.id)} label={vessel.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{vessels.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">No cost centres yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<VesselsTable
|
||||
vessels={vessels.map((v) => ({
|
||||
id: v.id,
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
siteName: v.site?.name ?? null,
|
||||
isActive: v.isActive,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
107
App/app/(portal)/admin/vessels/vessels-table.tsx
Normal file
107
App/app/(portal)/admin/vessels/vessels-table.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||
import { AddVesselButton, EditVesselButton } from "./vessel-form";
|
||||
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
|
||||
import { deleteVessel } from "./actions";
|
||||
|
||||
export type VesselRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
siteName: string | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const CHIPS = ["Active", "Inactive"];
|
||||
|
||||
export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<VesselRow>({
|
||||
rows: vessels,
|
||||
defaultSortKey: "code",
|
||||
searchText: (v) =>
|
||||
[v.code, v.name, v.siteName ?? "", v.isActive ? "active" : "inactive"].join(" "),
|
||||
chipMatch: (v, chip) => {
|
||||
if (chip.toLowerCase() === "active") return v.isActive;
|
||||
if (chip.toLowerCase() === "inactive") return !v.isActive;
|
||||
return false;
|
||||
},
|
||||
sortValue: (v, key) => {
|
||||
if (key === "isActive") return v.isActive ? "Active" : "Inactive";
|
||||
if (key === "siteName") return v.siteName ?? "";
|
||||
const val = v[key as keyof VesselRow];
|
||||
if (val === null || val === undefined) return "";
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Cost Centre Management</h1>
|
||||
<AddVesselButton />
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search cost centres…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Code</SortableTh>
|
||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Name</SortableTh>
|
||||
<SortableTh sortKey="siteName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Site</SortableTh>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Status</SortableTh>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
|
||||
No cost centres match your search.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((vessel) => (
|
||||
<tr key={vessel.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{vessel.code}</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{vessel.name}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">
|
||||
{vessel.siteName ?? <span className="italic text-neutral-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
vessel.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{vessel.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-3">
|
||||
<EditVesselButton vessel={{
|
||||
id: vessel.id,
|
||||
name: vessel.name,
|
||||
code: vessel.code,
|
||||
isActive: vessel.isActive,
|
||||
}} />
|
||||
<ConfirmDeleteButton onDelete={deleteVessel.bind(null, vessel.id)} label={vessel.name} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
App/components/ui/table-controls.tsx
Normal file
141
App/components/ui/table-controls.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SortDir } from "./use-table-controls";
|
||||
|
||||
// ─── Search bar ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface TableSearchProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function TableSearch({ value, onChange, placeholder = "Search…" }: TableSearchProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-9 w-64 rounded-lg border border-neutral-300 bg-white pl-9 pr-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Filter chips ────────────────────────────────────────────────────────────
|
||||
|
||||
interface FilterChipsProps {
|
||||
chips: string[];
|
||||
active: string[];
|
||||
onToggle: (chip: string) => void;
|
||||
}
|
||||
|
||||
export function FilterChips({ chips, active, onToggle }: FilterChipsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chips.map((chip) => {
|
||||
const isActive = active.includes(chip);
|
||||
return (
|
||||
<button
|
||||
key={chip}
|
||||
type="button"
|
||||
onClick={() => onToggle(chip)}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "border-primary-200 bg-primary-50 text-primary-700"
|
||||
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
|
||||
)}
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TableControls wrapper ────────────────────────────────────────────────────
|
||||
|
||||
interface TableControlsProps {
|
||||
search: string;
|
||||
onSearch: (v: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
chips: string[];
|
||||
activeFilters: string[];
|
||||
onToggleFilter: (chip: string) => void;
|
||||
}
|
||||
|
||||
export function TableControls({
|
||||
search,
|
||||
onSearch,
|
||||
searchPlaceholder,
|
||||
chips,
|
||||
activeFilters,
|
||||
onToggleFilter,
|
||||
}: TableControlsProps) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
<TableSearch value={search} onChange={onSearch} placeholder={searchPlaceholder} />
|
||||
{chips.length > 0 && (
|
||||
<FilterChips chips={chips} active={activeFilters} onToggle={onToggleFilter} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sortable column header ───────────────────────────────────────────────────
|
||||
|
||||
interface SortableThProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
||||
sortKey: string;
|
||||
activeSortKey: string | null;
|
||||
sortDir: SortDir;
|
||||
onSort: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SortableTh({
|
||||
sortKey,
|
||||
activeSortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: SortableThProps) {
|
||||
const isActive = activeSortKey === sortKey;
|
||||
|
||||
return (
|
||||
<th
|
||||
{...rest}
|
||||
className={cn(
|
||||
"px-4 py-3 text-left font-medium text-neutral-600 cursor-pointer select-none hover:bg-neutral-50",
|
||||
className
|
||||
)}
|
||||
onClick={() => onSort(sortKey)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{children}
|
||||
<span className="text-neutral-400 text-xs">
|
||||
{isActive ? (sortDir === "asc" ? "↑" : "↓") : "⇅"}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
114
App/components/ui/use-table-controls.ts
Normal file
114
App/components/ui/use-table-controls.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export interface TableControlsResult<T> {
|
||||
search: string;
|
||||
setSearch: (v: string) => void;
|
||||
sortKey: keyof T | null;
|
||||
sortDir: SortDir;
|
||||
toggleSort: (key: keyof T) => void;
|
||||
activeFilters: string[];
|
||||
toggleFilter: (chip: string) => void;
|
||||
filtered: T[];
|
||||
}
|
||||
|
||||
export interface UseTableControlsOptions<T> {
|
||||
/** All rows from the server. */
|
||||
rows: T[];
|
||||
/**
|
||||
* Return a flat string of all searchable text for a row.
|
||||
* Concatenate every visible column value, lower-cased.
|
||||
*/
|
||||
searchText: (row: T) => string;
|
||||
/**
|
||||
* Return true if the row matches the given active chip label.
|
||||
* Called once per active chip; row must match ALL active chips.
|
||||
*/
|
||||
chipMatch: (row: T, chip: string) => boolean;
|
||||
/**
|
||||
* Return a comparable primitive for a sort key so the hook can sort.
|
||||
* Strings are compared case-insensitively; numbers/booleans natively.
|
||||
*/
|
||||
sortValue?: (row: T, key: keyof T) => string | number | boolean | null | undefined;
|
||||
/** Default sort column. Pass null to start unsorted. */
|
||||
defaultSortKey?: keyof T | null;
|
||||
/** Default sort direction. */
|
||||
defaultSortDir?: SortDir;
|
||||
}
|
||||
|
||||
export function useTableControls<T>({
|
||||
rows,
|
||||
searchText,
|
||||
chipMatch,
|
||||
sortValue,
|
||||
defaultSortKey = null,
|
||||
defaultSortDir = "asc",
|
||||
}: UseTableControlsOptions<T>): TableControlsResult<T> {
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortKey, setSortKey] = useState<keyof T | null>(defaultSortKey);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(defaultSortDir);
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
|
||||
const toggleSort = (key: keyof T) => {
|
||||
setSortKey((prev) => {
|
||||
if (prev === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
return key;
|
||||
}
|
||||
setSortDir("asc");
|
||||
return key;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFilter = (chip: string) => {
|
||||
setActiveFilters((prev) =>
|
||||
prev.includes(chip) ? prev.filter((c) => c !== chip) : [...prev, chip]
|
||||
);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = rows;
|
||||
|
||||
// 1. Search
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) {
|
||||
result = result.filter((row) => searchText(row).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
// 2. Chip filters — row must satisfy every active chip
|
||||
if (activeFilters.length > 0) {
|
||||
result = result.filter((row) => activeFilters.every((chip) => chipMatch(row, chip)));
|
||||
}
|
||||
|
||||
// 3. Sort
|
||||
if (sortKey !== null && sortValue) {
|
||||
result = [...result].sort((a, b) => {
|
||||
const av = sortValue(a, sortKey) ?? "";
|
||||
const bv = sortValue(b, sortKey) ?? "";
|
||||
let cmp = 0;
|
||||
if (typeof av === "string" && typeof bv === "string") {
|
||||
cmp = av.toLowerCase().localeCompare(bv.toLowerCase());
|
||||
} else {
|
||||
cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [rows, search, activeFilters, sortKey, sortDir, searchText, chipMatch, sortValue]);
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
sortKey,
|
||||
sortDir,
|
||||
toggleSort,
|
||||
activeFilters,
|
||||
toggleFilter,
|
||||
filtered,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue