diff --git a/App/app/(portal)/admin/accounts/accounts-table.tsx b/App/app/(portal)/admin/accounts/accounts-table.tsx new file mode 100644 index 0000000..2704033 --- /dev/null +++ b/App/app/(portal)/admin/accounts/accounts-table.tsx @@ -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({ + 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 ( +
+
+

Account Management

+ +
+ + + +
+ + + + toggleSort(k as keyof AccountRow)}>Code + toggleSort(k as keyof AccountRow)}>Name + toggleSort(k as keyof AccountRow)}>Description + toggleSort(k as keyof AccountRow)}>Status + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((account) => ( + + + + + + + + ))} + +
+ No accounts match your search. +
{account.code}{account.name}{account.description ?? "—"} + + {account.isActive ? "Active" : "Inactive"} + + + + + + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/accounts/page.tsx b/App/app/(portal)/admin/accounts/page.tsx index a03de27..899aeb3 100644 --- a/App/app/(portal)/admin/accounts/page.tsx +++ b/App/app/(portal)/admin/accounts/page.tsx @@ -2,10 +2,8 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; -import { AddAccountButton, EditAccountButton } from "./account-form"; -import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; -import { deleteAccount } from "./actions"; import { nextId } from "@/lib/id-generators"; +import { AccountsTable } from "./accounts-table"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Account Management" }; @@ -21,58 +19,15 @@ export default async function AdminAccountsPage() { const suggestedCode = nextId("ACC", accounts.map((a) => a.code)); return ( -
-
-

Account Management

- -
- -
- - - - - - - - - - - - {accounts.map((account) => ( - - - - - - - - ))} - {accounts.length === 0 && ( - - - - )} - -
CodeNameDescriptionStatus
{account.code}{account.name}{account.description ?? "—"} - - {account.isActive ? "Active" : "Inactive"} - - - - - - -
No accounts yet.
-
-
+ ({ + id: a.id, + code: a.code, + name: a.name, + description: a.description ?? null, + isActive: a.isActive, + }))} + /> ); } diff --git a/App/app/(portal)/admin/products/page.tsx b/App/app/(portal)/admin/products/page.tsx index ec8c03f..b7d9ac4 100644 --- a/App/app/(portal)/admin/products/page.tsx +++ b/App/app/(portal)/admin/products/page.tsx @@ -2,11 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; -import Link from "next/link"; -import { formatCurrency, formatDate } from "@/lib/utils"; -import { AddProductButton, ToggleProductButton } from "./product-form"; -import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; -import { deleteProduct } from "./actions"; +import { ProductsTable } from "./products-table"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Item Catalogue" }; @@ -27,93 +23,19 @@ export default async function AdminProductsPage() { const canManage = hasPermission(session.user.role, "manage_products") && session.user.role === "ADMIN"; return ( -
-
-

Item Catalogue

- {canManage && } -
- -
- - - - - - - - - - - - {canManage && } - - - - {products.length === 0 && ( - - - - )} - {products.map((product) => ( - - - - - - - - - - {canManage && ( - - )} - - ))} - -
NameCodeDescriptionVendorsLast PriceLast VendorUpdatedStatus
- No items yet. Items are added automatically when a PO is marked as paid. -
- - {product.name} - - {product.code} - {product.description ?? } - - {product._count.vendorPrices > 0 - ? product._count.vendorPrices - : } - - {product.lastPrice !== null - ? formatCurrency(Number(product.lastPrice)) - : } - - {product.lastVendor?.name ?? } - {formatDate(product.updatedAt)} - - {product.isActive ? "Active" : "Inactive"} - - - - - - -
-
- -

- Items and vendor prices are updated automatically when a PO is marked as paid. -

-
+ ({ + id: p.id, + code: p.code, + name: p.name, + description: p.description ?? null, + lastPrice: p.lastPrice !== null ? Number(p.lastPrice) : null, + lastVendorName: p.lastVendor?.name ?? null, + updatedAt: p.updatedAt.toISOString(), + isActive: p.isActive, + vendorPriceCount: p._count.vendorPrices, + }))} + /> ); } diff --git a/App/app/(portal)/admin/products/products-table.tsx b/App/app/(portal)/admin/products/products-table.tsx new file mode 100644 index 0000000..6e20625 --- /dev/null +++ b/App/app/(portal)/admin/products/products-table.tsx @@ -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({ + 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 ( +
+
+

Item Catalogue

+ {canManage && } +
+ + + +
+ + + + toggleSort(k as keyof ProductRow)}>Name + toggleSort(k as keyof ProductRow)}>Code + + + toggleSort(k as keyof ProductRow)} className="text-right">Last Price + toggleSort(k as keyof ProductRow)}>Last Vendor + + toggleSort(k as keyof ProductRow)}>Status + {canManage && } + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((product) => ( + + + + + + + + + + {canManage && ( + + )} + + ))} + +
DescriptionVendorsUpdated
+ No items match your search. +
+ + {product.name} + + {product.code} + {product.description ?? } + + {product.vendorPriceCount > 0 + ? product.vendorPriceCount + : } + + {product.lastPrice !== null + ? formatCurrency(product.lastPrice) + : } + + {product.lastVendorName ?? } + {formatDate(product.updatedAt)} + + {product.isActive ? "Active" : "Inactive"} + + + + + + +
+
+ +

+ Items and vendor prices are updated automatically when a PO is marked as paid. +

+
+ ); +} diff --git a/App/app/(portal)/admin/sites/page.tsx b/App/app/(portal)/admin/sites/page.tsx index ec966cc..4d74021 100644 --- a/App/app/(portal)/admin/sites/page.tsx +++ b/App/app/(portal)/admin/sites/page.tsx @@ -2,10 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; -import Link from "next/link"; -import { AddSiteButton, EditSiteButton } from "./site-form"; -import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; -import { deleteSite } from "./actions"; +import { SitesTable } from "./sites-table"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Sites" }; @@ -25,71 +22,19 @@ export default async function SitesPage() { const canEdit = session.user.role === "ADMIN"; return ( -
-
-
-

Sites

-

Ports, depots and offices with inventory

-
- {canEdit && } -
- -
- - - - - - - - - - - {canEdit && } - - - - {sites.length === 0 && ( - - - - )} - {sites.map((site) => ( - - - - - - - - - {canEdit && ( - - )} - - ))} - -
NameCodeAddressCost CentresItems trackedLocationStatus
- No sites yet. Add your first port, depot or office. -
- - {site.name} - - {site.code}{site.address ?? }{site._count.vessels || }{site._count.inventory || } - {site.latitude && site.longitude - ? `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}` - : Not set} - - - {site.isActive ? "Active" : "Inactive"} - - - - - - -
-
-
+ ({ + id: s.id, + code: s.code, + name: s.name, + address: s.address ?? null, + latitude: s.latitude ?? null, + longitude: s.longitude ?? null, + isActive: s.isActive, + vesselCount: s._count.vessels, + inventoryCount: s._count.inventory, + }))} + /> ); } diff --git a/App/app/(portal)/admin/sites/sites-table.tsx b/App/app/(portal)/admin/sites/sites-table.tsx new file mode 100644 index 0000000..f70bdeb --- /dev/null +++ b/App/app/(portal)/admin/sites/sites-table.tsx @@ -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({ + 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 ( +
+
+
+

Sites

+

Ports, depots and offices with inventory

+
+ {canEdit && } +
+ + + +
+ + + + toggleSort(k as keyof SiteRow)}>Name + toggleSort(k as keyof SiteRow)}>Code + toggleSort(k as keyof SiteRow)}>Address + + + + toggleSort(k as keyof SiteRow)}>Status + {canEdit && } + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((site) => ( + + + + + + + + + {canEdit && ( + + )} + + ))} + +
Cost CentresItems trackedLocation
+ No sites match your search. +
+ + {site.name} + + {site.code} + {site.address ?? } + + {site.vesselCount || } + + {site.inventoryCount || } + + {site.latitude && site.longitude + ? `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}` + : Not set} + + + {site.isActive ? "Active" : "Inactive"} + + + + + + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/users/page.tsx b/App/app/(portal)/admin/users/page.tsx index 6bf7da3..078cb49 100644 --- a/App/app/(portal)/admin/users/page.tsx +++ b/App/app/(portal)/admin/users/page.tsx @@ -2,25 +2,11 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; -import { formatDate } from "@/lib/utils"; -import { AddUserButton, EditUserButton } from "./user-form"; -import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; -import { GrantSuperUserButton } from "./grant-superuser-button"; -import { deleteUser } from "./actions"; +import { UsersTable } from "./users-table"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "User Management" }; -const ROLE_LABELS: Record = { - TECHNICAL: "Technical", - MANNING: "Manning", - ACCOUNTS: "Accounts", - MANAGER: "Manager", - SUPERUSER: "SuperUser", - AUDITOR: "Auditor", - ADMIN: "Admin", -}; - export default async function AdminUsersPage() { const session = await auth(); if (!session?.user) redirect("/login"); @@ -30,65 +16,16 @@ export default async function AdminUsersPage() { const users = await db.user.findMany({ orderBy: { createdAt: "desc" } }); return ( -
-
-

User Management

- -
- -
- - - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - - - ))} - -
Employee IDNameEmailRoleStatusCreated
{user.employeeId}{user.name}{user.email} - - {ROLE_LABELS[user.role] ?? user.role} - - - - {user.isActive ? "Active" : "Inactive"} - - {formatDate(user.createdAt)} - - {user.role !== "SUPERUSER" && user.role !== "ADMIN" && ( - - )} - - - -
-
-
+ ({ + id: u.id, + employeeId: u.employeeId, + name: u.name, + email: u.email, + role: u.role, + isActive: u.isActive, + createdAt: u.createdAt.toISOString(), + }))} + /> ); } diff --git a/App/app/(portal)/admin/users/users-table.tsx b/App/app/(portal)/admin/users/users-table.tsx new file mode 100644 index 0000000..63f551d --- /dev/null +++ b/App/app/(portal)/admin/users/users-table.tsx @@ -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 = { + 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({ + 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 ( +
+
+

User Management

+ +
+ + + +
+ + + + toggleSort(k as keyof UserRow)}>Employee ID + toggleSort(k as keyof UserRow)}>Name + toggleSort(k as keyof UserRow)}>Email + toggleSort(k as keyof UserRow)}>Role + toggleSort(k as keyof UserRow)}>Status + + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((user) => ( + + + + + + + + + + ))} + +
Created
+ No users match your search. +
{user.employeeId}{user.name}{user.email} + + {ROLE_LABELS[user.role] ?? user.role} + + + + {user.isActive ? "Active" : "Inactive"} + + + {new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(new Date(user.createdAt))} + + + {user.role !== "SUPERUSER" && user.role !== "ADMIN" && ( + + )} + + + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/vendors/page.tsx b/App/app/(portal)/admin/vendors/page.tsx index 594e702..98f6093 100644 --- a/App/app/(portal)/admin/vendors/page.tsx +++ b/App/app/(portal)/admin/vendors/page.tsx @@ -2,11 +2,8 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; -import Link from "next/link"; -import { AddVendorButton, EditVendorButton } from "./vendor-form"; -import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; -import { deleteVendor } from "./actions"; import { nextId } from "@/lib/id-generators"; +import { VendorsTable } from "./vendors-table"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Vendor Registry" }; @@ -27,92 +24,26 @@ export default async function AdminVendorsPage() { const suggestedVendorId = nextId("VND", vendors.map((v) => v.vendorId)); return ( -
-
-

Vendor Registry

- -
- -
- - - - - - - - - - - - - - {vendors.map((vendor) => ( - - - - - - - - - - ))} - -
Vendor IDNameContactItemsVerifiedStatus
- {vendor.vendorId ?? Pending} - - - {vendor.name} - - - {vendor.contacts.length > 0 ? ( - <> - {vendor.contacts[0].name} - {vendor.contacts[0].email && ( - {vendor.contacts[0].email} - )} - {vendor.contacts.length > 1 && ( - +{vendor.contacts.length - 1} more - )} - - ) : "—"} - - {vendor._count.vendorPrices > 0 ? vendor._count.vendorPrices : } - - - {vendor.isVerified ? "Verified" : "Unverified"} - - - - {vendor.isActive ? "Active" : "Inactive"} - - - - ({ - name: c.name, role: c.role ?? "", mobile: c.mobile ?? "", - email: c.email ?? "", isPrimary: c.isPrimary, - })), - }} /> - - -
-
-
+ ({ + id: v.id, + vendorId: v.vendorId, + name: v.name, + gstin: v.gstin ?? null, + address: v.address ?? null, + pincode: v.pincode ?? null, + isVerified: v.isVerified, + isActive: v.isActive, + itemCount: v._count.vendorPrices, + contacts: v.contacts.map((c) => ({ + name: c.name, + role: c.role ?? "", + mobile: c.mobile ?? "", + email: c.email ?? "", + isPrimary: c.isPrimary, + })), + }))} + /> ); } diff --git a/App/app/(portal)/admin/vendors/vendors-table.tsx b/App/app/(portal)/admin/vendors/vendors-table.tsx new file mode 100644 index 0000000..ca43bdd --- /dev/null +++ b/App/app/(portal)/admin/vendors/vendors-table.tsx @@ -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({ + 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 ( +
+
+

Vendor Registry

+ +
+ + + +
+ + + + toggleSort(k as keyof VendorRow)}>Vendor ID + toggleSort(k as keyof VendorRow)}>Name + + + toggleSort(k as keyof VendorRow)}>Verified + toggleSort(k as keyof VendorRow)}>Status + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((vendor) => ( + + + + + + + + + + ))} + +
ContactItems
+ No vendors match your search. +
+ {vendor.vendorId ?? Pending} + + + {vendor.name} + + + {vendor.contacts.length > 0 ? ( + <> + {vendor.contacts[0].name} + {vendor.contacts[0].email && ( + {vendor.contacts[0].email} + )} + {vendor.contacts.length > 1 && ( + +{vendor.contacts.length - 1} more + )} + + ) : "—"} + + {vendor.itemCount > 0 ? vendor.itemCount : } + + + {vendor.isVerified ? "Verified" : "Unverified"} + + + + {vendor.isActive ? "Active" : "Inactive"} + + + + + + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/vessels/page.tsx b/App/app/(portal)/admin/vessels/page.tsx index 5bf8b4c..b7f661c 100644 --- a/App/app/(portal)/admin/vessels/page.tsx +++ b/App/app/(portal)/admin/vessels/page.tsx @@ -2,9 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { redirect } from "next/navigation"; -import { AddVesselButton, EditVesselButton } from "./vessel-form"; -import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button"; -import { deleteVessel } from "./actions"; +import { VesselsTable } from "./vessels-table"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Cost Centre Management" }; @@ -15,58 +13,20 @@ export default async function AdminVesselsPage() { if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard"); - const vessels = await db.vessel.findMany({ orderBy: { name: "asc" } }); + const vessels = await db.vessel.findMany({ + orderBy: { name: "asc" }, + include: { site: { select: { name: true } } }, + }); return ( -
-
-

Cost Centre Management

- -
- -
- - - - - - - - - - - {vessels.map((vessel) => ( - - - - - - - ))} - {vessels.length === 0 && ( - - - - )} - -
CodeNameStatus
{vessel.code}{vessel.name} - - {vessel.isActive ? "Active" : "Inactive"} - - - - - - -
No cost centres yet.
-
-
+ ({ + id: v.id, + code: v.code, + name: v.name, + siteName: v.site?.name ?? null, + isActive: v.isActive, + }))} + /> ); } diff --git a/App/app/(portal)/admin/vessels/vessels-table.tsx b/App/app/(portal)/admin/vessels/vessels-table.tsx new file mode 100644 index 0000000..406dcee --- /dev/null +++ b/App/app/(portal)/admin/vessels/vessels-table.tsx @@ -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({ + 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 ( +
+
+

Cost Centre Management

+ +
+ + + +
+ + + + toggleSort(k as keyof VesselRow)}>Code + toggleSort(k as keyof VesselRow)}>Name + toggleSort(k as keyof VesselRow)}>Site + toggleSort(k as keyof VesselRow)}>Status + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((vessel) => ( + + + + + + + + ))} + +
+ No cost centres match your search. +
{vessel.code}{vessel.name} + {vessel.siteName ?? } + + + {vessel.isActive ? "Active" : "Inactive"} + + + + + + +
+
+
+ ); +} diff --git a/App/components/ui/table-controls.tsx b/App/components/ui/table-controls.tsx new file mode 100644 index 0000000..5b281d7 --- /dev/null +++ b/App/components/ui/table-controls.tsx @@ -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 ( +
+ + + + 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" + /> +
+ ); +} + +// ─── Filter chips ──────────────────────────────────────────────────────────── + +interface FilterChipsProps { + chips: string[]; + active: string[]; + onToggle: (chip: string) => void; +} + +export function FilterChips({ chips, active, onToggle }: FilterChipsProps) { + return ( +
+ {chips.map((chip) => { + const isActive = active.includes(chip); + return ( + + ); + })} +
+ ); +} + +// ─── 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 ( +
+ + {chips.length > 0 && ( + + )} +
+ ); +} + +// ─── Sortable column header ─────────────────────────────────────────────────── + +interface SortableThProps extends React.ThHTMLAttributes { + 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 ( + onSort(sortKey)} + > + + {children} + + {isActive ? (sortDir === "asc" ? "↑" : "↓") : "⇅"} + + + + ); +} diff --git a/App/components/ui/use-table-controls.ts b/App/components/ui/use-table-controls.ts new file mode 100644 index 0000000..cf1e66a --- /dev/null +++ b/App/components/ui/use-table-controls.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useState, useMemo } from "react"; + +export type SortDir = "asc" | "desc"; + +export interface TableControlsResult { + 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 { + /** 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({ + rows, + searchText, + chipMatch, + sortValue, + defaultSortKey = null, + defaultSortDir = "asc", +}: UseTableControlsOptions): TableControlsResult { + const [search, setSearch] = useState(""); + const [sortKey, setSortKey] = useState(defaultSortKey); + const [sortDir, setSortDir] = useState(defaultSortDir); + const [activeFilters, setActiveFilters] = useState([]); + + 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, + }; +}