- 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>
219 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|