pelagia-portal/App/app/(portal)/admin/accounts/accounts-table.tsx
Hardik 0d17672ea9 feat(accounts): hierarchical accounting codes with 6-digit format and category tree
- Account model gains parentId (self-referential, 3 levels: TopCategory → SubCategory → Item)
- DB migration: adds parentId FK column to Account table
- Code format changed from PREFIX-NNN to 6-digit numeric (e.g. 100101)
- Seeded all 300+ accounting codes from the official chart (Rev. 01/251227) across
  7 top categories: Capital Expenses, Business Development, Office Admin, Project
  Expenses, Manning, Technical, Bunker/Lubes
- Admin Accounting Code page: collapsible tree view (top category > sub-category > items),
  inline search, Add/Edit dialogs with parent selector and 6-digit code field
- All PO forms (new, edit, import, manager-edit): accounting code dropdown now shows
  only leaf items grouped in <optgroup> by sub-category, labelled "TopCat › SubCat"
- Seed data updated: old flat account codes replaced by mapped leaf codes from new hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 03:27:31 +05:30

219 lines
8.1 KiB
TypeScript

"use client";
import { useState } from "react";
import { AddAccountButton, EditAccountButton } from "./account-form";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { deleteAccount, toggleAccountActive } from "./actions";
type AccountItem = {
id: string;
code: string;
name: string;
description: string | null;
isActive: boolean;
parentId: string | null;
children: AccountItem[];
};
type ParentOption = { id: string; code: string; name: string; parentId: string | null };
function AccountActionsMenu({
account,
allAccounts,
}: {
account: AccountItem;
allAccounts: ParentOption[];
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
return (
<>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
<RowActionsItem onClick={() => setToggleOpen(true)}>
{account.isActive ? "Deactivate" : "Activate"}
</RowActionsItem>
<RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<EditAccountButton
account={{ id: account.id, code: account.code, name: account.name, description: account.description, parentId: account.parentId, isActive: account.isActive }}
allAccounts={allAccounts}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
label={`${account.code}${account.name}`}
onConfirm={() => deleteAccount(account.id)}
/>
<ConfirmDialog
open={toggleOpen}
onOpenChange={setToggleOpen}
title={account.isActive ? `Deactivate ${account.name}?` : `Activate ${account.name}?`}
description={account.isActive ? `${account.name} will be hidden from new purchase orders.` : `${account.name} will become available for new purchase orders.`}
confirmLabel={account.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleAccountActive(account.id)}
/>
</>
);
}
function SubCategorySection({
sub,
allAccounts,
}: {
sub: AccountItem;
allAccounts: ParentOption[];
}) {
const [open, setOpen] = useState(false);
const isLeaf = sub.children.length === 0;
return (
<div className="border-b border-neutral-100 last:border-0">
{/* Sub-category header row */}
<div
className={`flex items-center gap-2 px-4 py-2 bg-neutral-50 ${!isLeaf ? "cursor-pointer hover:bg-neutral-100" : ""}`}
onClick={() => !isLeaf && setOpen((v) => !v)}
>
{!isLeaf && (
<span className="text-neutral-400 text-xs w-3">{open ? "▾" : "▸"}</span>
)}
{isLeaf && <span className="w-3" />}
<span className="font-mono text-xs text-neutral-500 w-20 shrink-0">{sub.code}</span>
<span className="text-sm font-medium text-neutral-700 flex-1">{sub.name}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${sub.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{sub.isActive ? "Active" : "Inactive"}
</span>
<AccountActionsMenu account={sub} allAccounts={allAccounts} />
</div>
{/* Leaf items under this sub-category */}
{!isLeaf && open && (
<div>
{sub.children.map((item) => (
<div key={item.id} className="flex items-center gap-2 px-4 py-2 pl-12 hover:bg-neutral-50 border-t border-neutral-100 first:border-0">
<span className="font-mono text-xs text-neutral-400 w-20 shrink-0">{item.code}</span>
<span className="text-sm text-neutral-800 flex-1">{item.name}</span>
{item.description && (
<span className="text-xs text-neutral-400 max-w-xs truncate">{item.description}</span>
)}
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${item.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{item.isActive ? "Active" : "Inactive"}
</span>
<AccountActionsMenu account={item} allAccounts={allAccounts} />
</div>
))}
</div>
)}
</div>
);
}
function TopCategorySection({
category,
allAccounts,
}: {
category: AccountItem;
allAccounts: ParentOption[];
}) {
const [open, setOpen] = useState(false);
const isLeaf = category.children.length === 0;
return (
<div className="rounded-lg border border-neutral-200 overflow-hidden mb-2">
{/* Top-category header */}
<div
className={`flex items-center gap-3 px-4 py-3 bg-neutral-100 ${!isLeaf ? "cursor-pointer hover:bg-neutral-200" : ""}`}
onClick={() => !isLeaf && setOpen((v) => !v)}
>
{!isLeaf && (
<span className="text-neutral-500 text-sm">{open ? "▾" : "▸"}</span>
)}
{isLeaf && <span className="w-3" />}
<span className="font-mono text-xs font-semibold text-neutral-600 w-20 shrink-0">{category.code}</span>
<span className="text-sm font-semibold text-neutral-900 flex-1 uppercase tracking-wide">{category.name}</span>
<span className="text-xs text-neutral-400">{category.children.length} sub-categories</span>
<AccountActionsMenu account={category} allAccounts={allAccounts} />
</div>
{open && (
<div>
{category.children.map((sub) => (
<SubCategorySection key={sub.id} sub={sub} allAccounts={allAccounts} />
))}
</div>
)}
</div>
);
}
export function AccountsTable({
topCategories,
allAccounts,
}: {
topCategories: AccountItem[];
allAccounts: ParentOption[];
}) {
const [search, setSearch] = useState("");
// Flat filtered view when searching
const flatAll = allAccounts.filter((a) =>
search.trim()
? a.code.includes(search) || a.name.toLowerCase().includes(search.toLowerCase())
: false
);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Accounting Code Management</h1>
<p className="text-sm text-neutral-500 mt-0.5">{allAccounts.length} codes across {topCategories.length} top categories</p>
</div>
<AddAccountButton allAccounts={allAccounts} />
</div>
<div className="mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by code or name…"
className="w-full max-w-sm rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
</div>
{search.trim() ? (
/* Flat search results */
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
{flatAll.length === 0 ? (
<p className="px-4 py-8 text-center text-neutral-400">No codes match your search.</p>
) : (
flatAll.map((a) => (
<div key={a.id} className="flex items-center gap-3 px-4 py-2.5 border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<span className="font-mono text-xs text-neutral-500 w-20 shrink-0">{a.code}</span>
<span className="text-sm text-neutral-900 flex-1">{a.name}</span>
</div>
))
)}
</div>
) : (
/* Hierarchical tree view */
<div>
{topCategories.map((cat) => (
<TopCategorySection key={cat.id} category={cat} allAccounts={allAccounts} />
))}
{topCategories.length === 0 && (
<p className="text-center text-neutral-400 py-12">No accounting codes yet. Add a top-level category to get started.</p>
)}
</div>
)}
</div>
);
}