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 { 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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