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:
Hardik 2026-05-29 02:46:52 +05:30
parent bff9696b7b
commit 9758dcd8ab
14 changed files with 1155 additions and 439 deletions

View 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>
);
}

View file

@ -2,10 +2,8 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; 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 { nextId } from "@/lib/id-generators";
import { AccountsTable } from "./accounts-table";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Account Management" }; 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)); const suggestedCode = nextId("ACC", accounts.map((a) => a.code));
return ( return (
<div> <AccountsTable
<div className="mb-6 flex items-center justify-between"> suggestedCode={suggestedCode}
<h1 className="text-2xl font-semibold text-neutral-900">Account Management</h1> accounts={accounts.map((a) => ({
<AddAccountButton suggestedCode={suggestedCode} /> id: a.id,
</div> code: a.code,
name: a.name,
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> description: a.description ?? null,
<table className="w-full text-sm"> isActive: a.isActive,
<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>
); );
} }

View file

@ -2,11 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import { ProductsTable } from "./products-table";
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 type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Item Catalogue" }; 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"; const canManage = hasPermission(session.user.role, "manage_products") && session.user.role === "ADMIN";
return ( return (
<div> <ProductsTable
<div className="mb-6 flex items-center justify-between"> canManage={canManage}
<h1 className="text-2xl font-semibold text-neutral-900">Item Catalogue</h1> products={products.map((p) => ({
{canManage && <AddProductButton />} id: p.id,
</div> code: p.code,
name: p.name,
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> description: p.description ?? null,
<table className="w-full text-sm"> lastPrice: p.lastPrice !== null ? Number(p.lastPrice) : null,
<thead className="bg-neutral-50 border-b border-neutral-200"> lastVendorName: p.lastVendor?.name ?? null,
<tr> updatedAt: p.updatedAt.toISOString(),
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th> isActive: p.isActive,
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th> vendorPriceCount: p._count.vendorPrices,
<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>
); );
} }

View 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>
);
}

View file

@ -2,10 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import { SitesTable } from "./sites-table";
import { AddSiteButton, EditSiteButton } from "./site-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteSite } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Sites" }; export const metadata: Metadata = { title: "Sites" };
@ -25,71 +22,19 @@ export default async function SitesPage() {
const canEdit = session.user.role === "ADMIN"; const canEdit = session.user.role === "ADMIN";
return ( return (
<div> <SitesTable
<div className="mb-6 flex items-center justify-between"> canEdit={canEdit}
<div> sites={sites.map((s) => ({
<h1 className="text-2xl font-semibold text-neutral-900">Sites</h1> id: s.id,
<p className="text-sm text-neutral-500 mt-0.5">Ports, depots and offices with inventory</p> code: s.code,
</div> name: s.name,
{canEdit && <AddSiteButton />} address: s.address ?? null,
</div> latitude: s.latitude ?? null,
longitude: s.longitude ?? null,
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> isActive: s.isActive,
<table className="w-full text-sm"> vesselCount: s._count.vessels,
<thead className="bg-neutral-50 border-b border-neutral-200"> inventoryCount: s._count.inventory,
<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>
); );
} }

View 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>
);
}

View file

@ -2,25 +2,11 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { formatDate } from "@/lib/utils"; import { UsersTable } from "./users-table";
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 type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "User Management" }; 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() { export default async function AdminUsersPage() {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
@ -30,65 +16,16 @@ export default async function AdminUsersPage() {
const users = await db.user.findMany({ orderBy: { createdAt: "desc" } }); const users = await db.user.findMany({ orderBy: { createdAt: "desc" } });
return ( return (
<div> <UsersTable
<div className="mb-6 flex items-center justify-between"> users={users.map((u) => ({
<h1 className="text-2xl font-semibold text-neutral-900">User Management</h1> id: u.id,
<AddUserButton /> employeeId: u.employeeId,
</div> name: u.name,
email: u.email,
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> role: u.role,
<table className="w-full text-sm"> isActive: u.isActive,
<thead className="bg-neutral-50 border-b border-neutral-200"> createdAt: u.createdAt.toISOString(),
<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>
); );
} }

View 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>
);
}

View file

@ -2,11 +2,8 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; 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 { nextId } from "@/lib/id-generators";
import { VendorsTable } from "./vendors-table";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendor Registry" }; 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)); const suggestedVendorId = nextId("VND", vendors.map((v) => v.vendorId));
return ( return (
<div> <VendorsTable
<div className="mb-6 flex items-center justify-between"> suggestedVendorId={suggestedVendorId}
<h1 className="text-2xl font-semibold text-neutral-900">Vendor Registry</h1> vendors={vendors.map((v) => ({
<AddVendorButton suggestedId={suggestedVendorId} /> id: v.id,
</div> vendorId: v.vendorId,
name: v.name,
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> gstin: v.gstin ?? null,
<table className="w-full text-sm"> address: v.address ?? null,
<thead className="bg-neutral-50 border-b border-neutral-200"> pincode: v.pincode ?? null,
<tr> isVerified: v.isVerified,
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vendor ID</th> isActive: v.isActive,
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th> itemCount: v._count.vendorPrices,
<th className="px-4 py-3 text-left font-medium text-neutral-600">Contact</th> contacts: v.contacts.map((c) => ({
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items</th> name: c.name,
<th className="px-4 py-3 text-left font-medium text-neutral-600">Verified</th> role: c.role ?? "",
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th> mobile: c.mobile ?? "",
<th className="px-4 py-3"></th> email: c.email ?? "",
</tr> isPrimary: c.isPrimary,
</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>
); );
} }

View 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>
);
}

View file

@ -2,9 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AddVesselButton, EditVesselButton } from "./vessel-form"; import { VesselsTable } from "./vessels-table";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteVessel } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Cost Centre Management" }; 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"); 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 ( return (
<div> <VesselsTable
<div className="mb-6 flex items-center justify-between"> vessels={vessels.map((v) => ({
<h1 className="text-2xl font-semibold text-neutral-900">Cost Centre Management</h1> id: v.id,
<AddVesselButton /> code: v.code,
</div> name: v.name,
siteName: v.site?.name ?? null,
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> isActive: v.isActive,
<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>
); );
} }

View 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>
);
}

View 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>
);
}

View 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,
};
}