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>
This commit is contained in:
Hardik 2026-05-30 03:27:31 +05:30
parent cc7251e6b7
commit 0d17672ea9
16 changed files with 803 additions and 223 deletions

View file

@ -5,40 +5,80 @@ import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog"; import { AdminDialog } from "@/components/ui/admin-dialog";
import { createAccount, updateAccount } from "./actions"; import { createAccount, updateAccount } from "./actions";
type ParentOption = { id: string; code: string; name: string; parentId: string | null };
type AccountRow = { type AccountRow = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
description: string | null; description: string | null;
parentId: string | null;
isActive: boolean; isActive: boolean;
}; };
function AccountFormFields({ account, suggestedCode }: { account?: AccountRow; suggestedCode?: string }) { const INPUT = "w-full 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";
function AccountFormFields({
account,
allAccounts,
}: {
account?: AccountRow;
allAccounts: ParentOption[];
}) {
// Only allow parent to be a top-level (parentId == null) or sub-category (parentId != null but has no grandparent)
// i.e. depth 0 or depth 1 nodes can be parents
const topLevel = allAccounts.filter((a) => a.parentId === null && (!account || a.id !== account.id));
const subLevel = allAccounts.filter((a) => a.parentId !== null && (!account || a.id !== account.id));
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Accounting Code *</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Code * <span className="font-normal text-neutral-400">(6 digits)</span></label>
<input name="code" defaultValue={account?.code ?? suggestedCode} required <input
className="w-full 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" name="code"
placeholder="e.g. ACC-001" /> defaultValue={account?.code}
required
maxLength={6}
pattern="\d{6}"
placeholder="e.g. 100101"
className={INPUT}
/>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
<input name="name" defaultValue={account?.name} required <input name="name" defaultValue={account?.name} required className={INPUT} />
className="w-full 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> </div>
</div> </div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Parent Category</label>
<select name="parentId" defaultValue={account?.parentId ?? ""} className={INPUT}>
<option value=""> Top-level category </option>
{topLevel.length > 0 && (
<optgroup label="Top categories">
{topLevel.map((a) => (
<option key={a.id} value={a.id}>{a.code} {a.name}</option>
))}
</optgroup>
)}
{subLevel.length > 0 && (
<optgroup label="Sub-categories">
{subLevel.map((a) => (
<option key={a.id} value={a.id}>{a.code} {a.name}</option>
))}
</optgroup>
)}
</select>
</div>
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
<input name="description" defaultValue={account?.description ?? ""} <input name="description" defaultValue={account?.description ?? ""} className={INPUT} placeholder="Optional" />
className="w-full 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> </div>
</div> </div>
); );
} }
export function AddAccountButton({ suggestedCode }: { suggestedCode?: string }) { export function AddAccountButton({ allAccounts }: { allAccounts: ParentOption[] }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@ -61,7 +101,7 @@ export function AddAccountButton({ suggestedCode }: { suggestedCode?: string })
</button> </button>
<AdminDialog title="Add Accounting Code" open={open} onClose={() => setOpen(false)}> <AdminDialog title="Add Accounting Code" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<AccountFormFields suggestedCode={suggestedCode} /> <AccountFormFields allAccounts={allAccounts} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} <button type="button" onClick={() => setOpen(false)}
@ -81,10 +121,12 @@ export function AddAccountButton({ suggestedCode }: { suggestedCode?: string })
export function EditAccountButton({ export function EditAccountButton({
account, account,
allAccounts,
open: controlledOpen, open: controlledOpen,
onOpenChange, onOpenChange,
}: { }: {
account: AccountRow; account: AccountRow;
allAccounts: ParentOption[];
open?: boolean; open?: boolean;
onOpenChange?: (v: boolean) => void; onOpenChange?: (v: boolean) => void;
}) { }) {
@ -118,7 +160,7 @@ export function EditAccountButton({
)} )}
<AdminDialog title="Edit Accounting Code" open={open} onClose={() => setOpen(false)}> <AdminDialog title="Edit Accounting Code" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<AccountFormFields account={account} /> <AccountFormFields account={account} allAccounts={allAccounts} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} <button type="button" onClick={() => setOpen(false)}

View file

@ -1,25 +1,31 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTableControls } from "@/components/ui/use-table-controls";
import { TableControls, SortableTh } from "@/components/ui/table-controls";
import { AddAccountButton, EditAccountButton } from "./account-form"; import { AddAccountButton, EditAccountButton } from "./account-form";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu"; import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { deleteAccount, toggleAccountActive } from "./actions"; import { deleteAccount, toggleAccountActive } from "./actions";
export type AccountRow = { type AccountItem = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
description: string | null; description: string | null;
isActive: boolean; isActive: boolean;
parentId: string | null;
children: AccountItem[];
}; };
const CHIPS = ["Active", "Inactive"]; type ParentOption = { id: string; code: string; name: string; parentId: string | null };
function AccountActionsMenu({ account }: { account: AccountRow }) { function AccountActionsMenu({
account,
allAccounts,
}: {
account: AccountItem;
allAccounts: ParentOption[];
}) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false); const [toggleOpen, setToggleOpen] = useState(false);
@ -34,29 +40,23 @@ function AccountActionsMenu({ account }: { account: AccountRow }) {
<RowActionsSeparator /> <RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem> <RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu> </RowActionsMenu>
<EditAccountButton <EditAccountButton
account={{ account={{ id: account.id, code: account.code, name: account.name, description: account.description, parentId: account.parentId, isActive: account.isActive }}
id: account.id, allAccounts={allAccounts}
code: account.code,
name: account.name,
description: account.description,
isActive: account.isActive,
}}
open={editOpen} open={editOpen}
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
/> />
<DeleteConfirmDialog <DeleteConfirmDialog
open={deleteOpen} open={deleteOpen}
onOpenChange={setDeleteOpen} onOpenChange={setDeleteOpen}
label={account.name} label={`${account.code}${account.name}`}
onConfirm={() => deleteAccount(account.id)} onConfirm={() => deleteAccount(account.id)}
/> />
<ConfirmDialog <ConfirmDialog
open={toggleOpen} open={toggleOpen}
onOpenChange={setToggleOpen} onOpenChange={setToggleOpen}
title={account.isActive ? `Deactivate ${account.name}?` : `Activate ${account.name}?`} title={account.isActive ? `Deactivate ${account.name}?` : `Activate ${account.name}?`}
description={account.isActive ? `${account.name} will be hidden from account selections.` : `${account.name} will become available for account selections.`} 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"} confirmLabel={account.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleAccountActive(account.id)} onConfirm={() => toggleAccountActive(account.id)}
/> />
@ -64,87 +64,156 @@ function AccountActionsMenu({ account }: { account: AccountRow }) {
); );
} }
export function AccountsTable({ function SubCategorySection({
accounts, sub,
suggestedCode, allAccounts,
}: { }: {
accounts: AccountRow[]; sub: AccountItem;
suggestedCode: string; allAccounts: ParentOption[];
}) { }) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } = const [open, setOpen] = useState(false);
useTableControls<AccountRow>({ const isLeaf = sub.children.length === 0;
rows: accounts,
defaultSortKey: "code", return (
searchText: (a) => <div className="border-b border-neutral-100 last:border-0">
[a.code, a.name, a.description ?? "", a.isActive ? "active" : "inactive"].join(" "), {/* Sub-category header row */}
chipMatch: (a, chip) => { <div
if (chip.toLowerCase() === "active") return a.isActive; className={`flex items-center gap-2 px-4 py-2 bg-neutral-50 ${!isLeaf ? "cursor-pointer hover:bg-neutral-100" : ""}`}
if (chip.toLowerCase() === "inactive") return !a.isActive; onClick={() => !isLeaf && setOpen((v) => !v)}
return false; >
}, {!isLeaf && (
sortValue: (a, key) => { <span className="text-neutral-400 text-xs w-3">{open ? "▾" : "▸"}</span>
if (key === "isActive") return a.isActive ? "Active" : "Inactive"; )}
const val = a[key as keyof AccountRow]; {isLeaf && <span className="w-3" />}
if (val === null || val === undefined) return ""; <span className="font-mono text-xs text-neutral-500 w-20 shrink-0">{sub.code}</span>
return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val); <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 ( return (
<div> <div>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Accounting Code Management</h1> <h1 className="text-2xl font-semibold text-neutral-900">Accounting Code Management</h1>
<AddAccountButton suggestedCode={suggestedCode} /> <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>
<TableControls <div className="mb-4">
search={search} <input
onSearch={setSearch} type="text"
searchPlaceholder="Search accounting codes…" value={search}
chips={CHIPS} onChange={(e) => setSearch(e.target.value)}
activeFilters={activeFilters} placeholder="Search by code or name…"
onToggleFilter={toggleFilter} 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 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 w-10"></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">
<AccountActionsMenu account={account} />
</td>
</tr>
))}
</tbody>
</table>
</div> </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> </div>
); );
} }

View file

@ -9,9 +9,10 @@ import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string }; type ActionResult = { ok: true } | { error: string };
const accountSchema = z.object({ const accountSchema = z.object({
code: z.string().min(1, "Account code is required").regex(/^[A-Z0-9]+-\d+$/i, "Code must be in format PREFIX-NUMBER (e.g. ACC-001)"), code: z.string().min(1, "Accounting code is required").regex(/^\d{6}$/, "Code must be exactly 6 digits (e.g. 100101)"),
name: z.string().min(1, "Account name is required"), name: z.string().min(1, "Name is required"),
description: z.string().optional(), description: z.string().optional(),
parentId: z.string().optional(),
}); });
export async function createAccount(formData: FormData): Promise<ActionResult> { export async function createAccount(formData: FormData): Promise<ActionResult> {
@ -24,14 +25,22 @@ export async function createAccount(formData: FormData): Promise<ActionResult> {
code: formData.get("code"), code: formData.get("code"),
name: formData.get("name"), name: formData.get("name"),
description: formData.get("description") || undefined, description: formData.get("description") || undefined,
parentId: (formData.get("parentId") as string) || undefined,
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data; const data = parsed.data;
const exists = await db.account.findUnique({ where: { code: data.code } }); const exists = await db.account.findUnique({ where: { code: data.code } });
if (exists) return { error: "An account with that code already exists" }; if (exists) return { error: "An accounting code with that number already exists" };
await db.account.create({ data: { code: data.code, name: data.name, description: data.description ?? null } }); await db.account.create({
data: {
code: data.code,
name: data.name,
description: data.description ?? null,
parentId: data.parentId ?? null,
},
});
revalidatePath("/admin/accounts"); revalidatePath("/admin/accounts");
return { ok: true }; return { ok: true };
} }
@ -49,14 +58,23 @@ export async function updateAccount(formData: FormData): Promise<ActionResult> {
code: formData.get("code"), code: formData.get("code"),
name: formData.get("name"), name: formData.get("name"),
description: formData.get("description") || undefined, description: formData.get("description") || undefined,
parentId: (formData.get("parentId") as string) || undefined,
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data; const data = parsed.data;
const conflict = await db.account.findFirst({ where: { code: data.code, id: { not: id } } }); const conflict = await db.account.findFirst({ where: { code: data.code, id: { not: id } } });
if (conflict) return { error: "Another account already uses that code" }; if (conflict) return { error: "Another accounting code already uses that number" };
await db.account.update({ where: { id }, data: { code: data.code, name: data.name, description: data.description ?? null } }); await db.account.update({
where: { id },
data: {
code: data.code,
name: data.name,
description: data.description ?? null,
parentId: data.parentId ?? null,
},
});
revalidatePath("/admin/accounts"); revalidatePath("/admin/accounts");
return { ok: true }; return { ok: true };
} }
@ -66,7 +84,10 @@ export async function deleteAccount(id: string): Promise<ActionResult> {
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) return { error: "Unauthorized" }; if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) return { error: "Unauthorized" };
const inUse = await db.purchaseOrder.findFirst({ where: { accountId: id } }); const inUse = await db.purchaseOrder.findFirst({ where: { accountId: id } });
if (inUse) return { error: "Cannot delete: account is referenced in purchase orders. Remove those POs first." }; if (inUse) return { error: "Cannot delete: this code is referenced in purchase orders." };
const hasChildren = await db.account.findFirst({ where: { parentId: id } });
if (hasChildren) return { error: "Cannot delete: this code has sub-items. Remove those first." };
await db.account.delete({ where: { id } }); await db.account.delete({ where: { id } });
revalidatePath("/admin/accounts"); revalidatePath("/admin/accounts");
@ -80,7 +101,7 @@ export async function toggleAccountActive(accountId: string): Promise<ActionResu
} }
const account = await db.account.findUnique({ where: { id: accountId }, select: { isActive: true } }); const account = await db.account.findUnique({ where: { id: accountId }, select: { isActive: true } });
if (!account) return { error: "Account not found" }; if (!account) return { error: "Accounting code not found" };
await db.account.update({ where: { id: accountId }, data: { isActive: !account.isActive } }); await db.account.update({ where: { id: accountId }, data: { isActive: !account.isActive } });
revalidatePath("/admin/accounts"); revalidatePath("/admin/accounts");

View file

@ -2,7 +2,6 @@ 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 { nextId } from "@/lib/id-generators";
import { AccountsTable } from "./accounts-table"; import { AccountsTable } from "./accounts-table";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -14,20 +13,25 @@ export default async function AdminAccountsPage() {
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard"); if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const accounts = await db.account.findMany({ orderBy: { code: "asc" } }); // Fetch full tree: top-level categories with sub-categories and their items
const topCategories = await db.account.findMany({
where: { parentId: null },
orderBy: { code: "asc" },
include: {
children: {
orderBy: { code: "asc" },
include: {
children: { orderBy: { code: "asc" } },
},
},
},
});
const suggestedCode = nextId("ACC", accounts.map((a) => a.code)); // Flat list of all accounts for the parent selector in forms
const allAccounts = await db.account.findMany({
orderBy: { code: "asc" },
select: { id: true, code: true, name: true, parentId: true },
});
return ( return <AccountsTable topCategories={topCategories} allAccounts={allAccounts} />;
<AccountsTable
suggestedCode={suggestedCode}
accounts={accounts.map((a) => ({
id: a.id,
code: a.code,
name: a.name,
description: a.description ?? null,
isActive: a.isActive,
}))}
/>
);
} }

View file

@ -6,8 +6,8 @@ import { managerEditPo } from "./manager-po-edit-actions";
import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po"; import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { Account, Vendor, PurchaseOrder } from "@prisma/client"; import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form"; import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
type SerializedLineItem = { type SerializedLineItem = {
id: string; id: string;
@ -36,7 +36,7 @@ interface Props {
po: PoFull; po: PoFull;
costCentres: CostCentreOption[]; costCentres: CostCentreOption[];
initialCostCentreRef: string; initialCostCentreRef: string;
accounts: Account[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
} }
@ -169,7 +169,11 @@ export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accou
<div> <div>
<label className={LABEL}>Accounting Code <span className="text-danger">*</span></label> <label className={LABEL}>Accounting Code <span className="text-danger">*</span></label>
<select name="accountId" required defaultValue={po.accountId} className={INPUT}> <select name="accountId" required defaultValue={po.accountId} className={INPUT}>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)} {accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select> </select>
</div> </div>
<div> <div>

View file

@ -5,7 +5,7 @@ import { notFound, redirect } from "next/navigation";
import { ApprovalActions } from "./approval-actions"; import { ApprovalActions } from "./approval-actions";
import { PoDetail } from "@/components/po/po-detail"; import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form"; import { ManagerEditPoForm } from "./manager-edit-po-form";
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form"; import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
interface Props { interface Props {
@ -46,13 +46,27 @@ export default async function ApprovalDetailPage({ params }: Props) {
}), }),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
]); ]);
if (!po) notFound(); if (!po) notFound();
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`); if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
const accountGroupMap = new Map<string, typeof accounts>();
for (const a of accounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accountGroups: AccountGroup[] = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [ const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })), ...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })), ...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })),
@ -97,7 +111,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
po={serializedPo} po={serializedPo}
costCentres={costCentres} costCentres={costCentres}
initialCostCentreRef={initialCostCentreRef} initialCostCentreRef={initialCostCentreRef}
accounts={accounts} accounts={accountGroups}
vendors={vendors} vendors={vendors}
/> />
</div> </div>

View file

@ -3,8 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { updatePo } from "./actions"; import { updatePo } from "./actions";
import type { Account, Vendor, PurchaseOrder } from "@prisma/client"; import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form"; import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po"; import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
@ -37,7 +37,7 @@ interface Props {
po: PoWithItems; po: PoWithItems;
costCentres: CostCentreOption[]; costCentres: CostCentreOption[];
initialCostCentreRef: string; initialCostCentreRef: string;
accounts: Account[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
managerNoteAuthor?: string | null; managerNoteAuthor?: string | null;
} }
@ -166,8 +166,12 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
onChange={(e) => setDefaultAccountId(e.target.value)} onChange={(e) => setDefaultAccountId(e.target.value)}
className={INPUT_CLS} className={INPUT_CLS}
> >
<option value="">Select account</option> <option value="">Select accounting code</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)} {accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select> </select>
</div> </div>
<div> <div>
@ -227,7 +231,7 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
items={lineItems} items={lineItems}
onChange={setLineItems} onChange={setLineItems}
multiAccount={multiAccount} multiAccount={multiAccount}
accounts={accounts.map((a) => ({ id: a.id, name: a.name, code: a.code }))} accounts={accounts.flatMap((g) => g.items.map((a) => ({ id: a.id, name: a.name, code: a.code })))}
defaultAccountId={defaultAccountId || undefined} defaultAccountId={defaultAccountId || undefined}
/> />
</section> </section>

View file

@ -2,7 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { EditPoForm } from "./edit-po-form"; import { EditPoForm } from "./edit-po-form";
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form"; import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
interface Props { interface Props {
@ -33,7 +33,11 @@ export default async function EditPoPage({ params }: Props) {
const [vessels, sites, accounts, vendors, noteAction] = await Promise.all([ const [vessels, sites, accounts, vendors, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
po.status === "EDITS_REQUESTED" po.status === "EDITS_REQUESTED"
? db.pOAction.findFirst({ ? db.pOAction.findFirst({
@ -44,6 +48,16 @@ export default async function EditPoPage({ params }: Props) {
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
const accountGroupMap = new Map<string, typeof accounts>();
for (const a of accounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accountGroups: AccountGroup[] = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [ const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })), ...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })), ...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })),
@ -68,7 +82,7 @@ export default async function EditPoPage({ params }: Props) {
<h1 className="text-2xl font-semibold text-neutral-900">Edit Purchase Order</h1> <h1 className="text-2xl font-semibold text-neutral-900">Edit Purchase Order</h1>
<p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p> <p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p>
</div> </div>
<EditPoForm po={serializedPo} costCentres={costCentres} initialCostCentreRef={initialCostCentreRef} accounts={accounts} vendors={vendors} managerNoteAuthor={noteAction?.actor.name ?? null} /> <EditPoForm po={serializedPo} costCentres={costCentres} initialCostCentreRef={initialCostCentreRef} accounts={accountGroups} vendors={vendors} managerNoteAuthor={noteAction?.actor.name ?? null} />
</div> </div>
); );
} }

View file

@ -2,8 +2,8 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Account, Vendor } from "@prisma/client"; import type { Vendor } from "@prisma/client";
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form"; import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { importPo } from "./actions"; import { importPo } from "./actions";
import type { ParsedImport } from "@/app/api/po/import/route"; import type { ParsedImport } from "@/app/api/po/import/route";
import { formatCurrency } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils";
@ -13,7 +13,7 @@ const INPUT_CLS =
interface Props { interface Props {
costCentres: CostCentreOption[]; costCentres: CostCentreOption[];
accounts: Account[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
} }
@ -66,7 +66,7 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
? `${parsed.vendorName} — Import` ? `${parsed.vendorName} — Import`
: "Imported Purchase Order", : "Imported Purchase Order",
costCentreRef: costCentres[0]?.ref ?? "", costCentreRef: costCentres[0]?.ref ?? "",
accountId: accounts[0]?.id ?? "", accountId: accounts[0]?.items[0]?.id ?? "",
vendorId: matchedVendor?.id ?? "", vendorId: matchedVendor?.id ?? "",
}); });
} catch { } catch {
@ -211,8 +211,12 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
required required
className={INPUT_CLS} className={INPUT_CLS}
> >
<option value="">Select account</option> <option value="">Select accounting code</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)} {accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select> </select>
</div> </div>
</div> </div>

View file

@ -2,7 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ImportForm } from "./import-form"; import { ImportForm } from "./import-form";
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form"; import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Import Purchase Order" }; export const metadata: Metadata = { title: "Import Purchase Order" };
@ -17,10 +17,24 @@ export default async function ImportPoPage() {
const [vessels, sites, accounts, vendors] = await Promise.all([ const [vessels, sites, accounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}),
db.vendor.findMany({ orderBy: { name: "asc" } }), db.vendor.findMany({ orderBy: { name: "asc" } }),
]); ]);
const accountGroupMap = new Map<string, typeof accounts>();
for (const a of accounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accountGroups: AccountGroup[] = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [ const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })), ...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })), ...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })),
@ -35,7 +49,7 @@ export default async function ImportPoPage() {
You then select the cost centre, accounting code, and confirm before saving as a draft. You then select the cost centre, accounting code, and confirm before saving as a draft.
</p> </p>
</div> </div>
<ImportForm costCentres={costCentres} accounts={accounts} vendors={vendors} /> <ImportForm costCentres={costCentres} accounts={accountGroups} vendors={vendors} />
</div> </div>
); );
} }

View file

@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createPo } from "./actions"; import { createPo } from "./actions";
import type { Account, Vendor } from "@prisma/client"; import type { Vendor } from "@prisma/client";
import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { FileUploader } from "@/components/po/file-uploader"; import { FileUploader } from "@/components/po/file-uploader";
import { uploadAndLinkFiles } from "@/lib/upload-files"; import { uploadAndLinkFiles } from "@/lib/upload-files";
@ -11,6 +11,7 @@ import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po"; import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
export type CostCentreOption = { ref: string; label: string; group: "Vessels" | "Sites" }; export type CostCentreOption = { ref: string; label: string; group: "Vessels" | "Sites" };
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
const INPUT_CLS = const INPUT_CLS =
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; "w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
@ -19,7 +20,7 @@ const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit
interface Props { interface Props {
costCentres: CostCentreOption[]; costCentres: CostCentreOption[];
accounts: Account[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
initialLineItems?: LineItemInput[]; initialLineItems?: LineItemInput[];
initialVendorId?: string; initialVendorId?: string;
@ -125,8 +126,12 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
onChange={(e) => setDefaultAccountId(e.target.value)} onChange={(e) => setDefaultAccountId(e.target.value)}
className={INPUT_CLS} className={INPUT_CLS}
> >
<option value="">Select account</option> <option value="">Select accounting code</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)} {accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select> </select>
</div> </div>
<div> <div>
@ -191,7 +196,7 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
items={lineItems} items={lineItems}
onChange={setLineItems} onChange={setLineItems}
multiAccount={multiAccount} multiAccount={multiAccount}
accounts={accounts.map((a) => ({ id: a.id, name: a.name, code: a.code }))} accounts={accounts.flatMap((g) => g.items.map((a) => ({ id: a.id, name: a.name, code: a.code })))}
defaultAccountId={defaultAccountId || undefined} defaultAccountId={defaultAccountId || undefined}
/> />
</section> </section>

View file

@ -49,13 +49,36 @@ export default async function NewPoPage({ searchParams }: Props) {
} }
} }
const [vessels, sites, accounts, vendors] = await Promise.all([ const [vessels, sites, leafAccounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: {
id: true, code: true, name: true,
parent: {
select: {
name: true, code: true,
parent: { select: { name: true, code: true } },
},
},
},
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
]); ]);
// Build grouped account list for optgroups: "TOP CAT > Sub Cat" → items
const accountGroupMap = new Map<string, typeof leafAccounts>();
for (const a of leafAccounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accounts = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [ const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}` as const, label: `${v.code}${v.name}`, group: "Vessels" as const })), ...vessels.map((v) => ({ ref: `v:${v.id}` as const, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}` as const, label: `${s.code}${s.name}`, group: "Sites" as const })), ...sites.map((s) => ({ ref: `s:${s.id}` as const, label: `${s.code}${s.name}`, group: "Sites" as const })),

View file

@ -0,0 +1,387 @@
// Accounting Codes (Rev. 01/251227) — hierarchical structure
// Each entry: [code, name, parentCode | null]
// parentCode null = top-level category
// parentCode ending in 0000 = top-level → sub-category
// parentCode ending in 00 = sub-category → leaf item
export type ACEntry = { code: string; name: string; parentCode: string | null };
export const ACCOUNTING_CODES: ACEntry[] = [
// ── CAPITAL EXPENSES ─────────────────────────────────────────────────────────
{ code: "100000", name: "CAPITAL EXPENSES", parentCode: null },
{ code: "100100", name: "DREDGER COST", parentCode: "100000" },
{ code: "100101", name: "Dredger cost-Shipyard", parentCode: "100100" },
{ code: "100102", name: "Custom duty", parentCode: "100100" },
{ code: "100103", name: "Stamp duty", parentCode: "100100" },
{ code: "100104", name: "CHA Charges", parentCode: "100100" },
{ code: "100105", name: "Transportation by sea- Dredger", parentCode: "100100" },
{ code: "100106", name: "Transportation by road- Dredger", parentCode: "100100" },
{ code: "100107", name: "Dredger cost- Others", parentCode: "100100" },
{ code: "100200", name: "PIPELINE COST", parentCode: "100000" },
{ code: "100201", name: "Pipeline Cost", parentCode: "100200" },
{ code: "100202", name: "Pipeline- Nuts & Bolts", parentCode: "100200" },
{ code: "100203", name: "Pipeline- Packing", parentCode: "100200" },
{ code: "100204", name: "Pipeline- Repairs", parentCode: "100200" },
{ code: "100205", name: "Transportation by sea- Pipeline", parentCode: "100200" },
{ code: "100206", name: "Transportation by road- Pipeline", parentCode: "100200" },
{ code: "100207", name: "Pipeline cost- Others", parentCode: "100200" },
{ code: "100300", name: "HOSES COST", parentCode: "100000" },
{ code: "100301", name: "Hoses Cost", parentCode: "100300" },
{ code: "100302", name: "Hoses- Nuts & Bolts", parentCode: "100300" },
{ code: "100303", name: "Hoses- Packing", parentCode: "100300" },
{ code: "100304", name: "Hoses- Repairs", parentCode: "100300" },
{ code: "100305", name: "Transportation by sea- Hoses", parentCode: "100300" },
{ code: "100306", name: "Transportation by road- Hoses", parentCode: "100300" },
{ code: "100307", name: "Hoses cost- Others", parentCode: "100300" },
{ code: "100400", name: "FLOATS COST", parentCode: "100000" },
{ code: "100401", name: "Floats Cost", parentCode: "100400" },
{ code: "100402", name: "Floats- Nuts & Bolts", parentCode: "100400" },
{ code: "100403", name: "Floats- Packing", parentCode: "100400" },
{ code: "100404", name: "Floats- Repairs", parentCode: "100400" },
{ code: "100405", name: "Transportation by sea- Floats", parentCode: "100400" },
{ code: "100406", name: "Transportation by road- Floats", parentCode: "100400" },
{ code: "100407", name: "Floats cost- Others", parentCode: "100400" },
{ code: "100500", name: "DOCKING EXPENSES (CAPITAL)", parentCode: "100000" },
{ code: "100501", name: "Dock charges", parentCode: "100500" },
{ code: "100502", name: "Towing charges", parentCode: "100500" },
{ code: "100503", name: "Drydock- Agency charges", parentCode: "100500" },
{ code: "100504", name: "Drydock- Port charges", parentCode: "100500" },
{ code: "100505", name: "Drydock- Manpower", parentCode: "100500" },
{ code: "100506", name: "Drydock- Paints", parentCode: "100500" },
{ code: "100507", name: "Drydock- Steel", parentCode: "100500" },
{ code: "100508", name: "Drydock- Pipelines", parentCode: "100500" },
{ code: "100509", name: "Drydock- Welding consumables", parentCode: "100500" },
{ code: "100510", name: "Drydock- Gases", parentCode: "100500" },
{ code: "100511", name: "Drydock- Cranage", parentCode: "100500" },
{ code: "100512", name: "Drydock- Workshop charges", parentCode: "100500" },
{ code: "100513", name: "Drydock- Technician visit charges", parentCode: "100500" },
{ code: "100514", name: "Drydock- Transportation", parentCode: "100500" },
{ code: "100515", name: "Drydock- Supdt. Travel Expenses", parentCode: "100500" },
{ code: "100516", name: "Drydock- Supdt. Boarding & Lodging", parentCode: "100500" },
{ code: "100517", name: "Drydock- Stores", parentCode: "100500" },
{ code: "100600", name: "MAJOR REPAIRS", parentCode: "100000" },
{ code: "100601", name: "Spares- DP Engine", parentCode: "100600" },
{ code: "100602", name: "Spares- Hyd. Engine", parentCode: "100600" },
{ code: "100603", name: "Spares- Dredge Pump", parentCode: "100600" },
{ code: "100604", name: "Spares- Deck generator", parentCode: "100600" },
{ code: "100605", name: "Spares- Cutter", parentCode: "100600" },
{ code: "100606", name: "Spares- Winches", parentCode: "100600" },
{ code: "100607", name: "Spares- Hydraulics", parentCode: "100600" },
{ code: "100608", name: "Spares- Gear box", parentCode: "100600" },
{ code: "100609", name: "Spares- Transmission system", parentCode: "100600" },
{ code: "100610", name: "Spares- Attached pumps & Coolers", parentCode: "100600" },
{ code: "100611", name: "Spares- Safety Equipment", parentCode: "100600" },
{ code: "100612", name: "Spares- Others", parentCode: "100600" },
{ code: "100613", name: "Spares- Steel Wires", parentCode: "100600" },
{ code: "100614", name: "Spares- Electricals", parentCode: "100600" },
{ code: "100615", name: "Spares- Electronics", parentCode: "100600" },
{ code: "100616", name: "Spares- Gauges & Instrumentation", parentCode: "100600" },
{ code: "100617", name: "Spares- Controls and Automation", parentCode: "100600" },
{ code: "100618", name: "Technician expenses", parentCode: "100600" },
{ code: "100619", name: "Workshop charges", parentCode: "100600" },
{ code: "100620", name: "Transportation", parentCode: "100600" },
{ code: "100621", name: "Other cost", parentCode: "100600" },
{ code: "100622", name: "Equipment rent", parentCode: "100600" },
{ code: "100623", name: "Equipment diesel", parentCode: "100600" },
{ code: "100700", name: "OTHER EQUIPMENT COST", parentCode: "100000" },
{ code: "100701", name: "Dredger equipment", parentCode: "100700" },
{ code: "100702", name: "Office equipment", parentCode: "100700" },
{ code: "100703", name: "Site equipment", parentCode: "100700" },
{ code: "100704", name: "Transportation- Other Equipment", parentCode: "100700" },
{ code: "100800", name: "OTHER INVESTMENT EXPENSES", parentCode: "100000" },
{ code: "100801", name: "Purchase cost", parentCode: "100800" },
{ code: "100802", name: "Upgradation cost", parentCode: "100800" },
// ── BUSINESS DEVELOPMENT EXP ─────────────────────────────────────────────────
{ code: "200000", name: "BUSINESS DEVELOPMENT EXP", parentCode: null },
{ code: "200100", name: "TENDER FEE", parentCode: "200000" },
{ code: "200101", name: "Tender fee payment", parentCode: "200100" },
{ code: "200200", name: "EMD", parentCode: "200000" },
{ code: "200201", name: "Earnest Money Deposit", parentCode: "200200" },
{ code: "200300", name: "DOCUMENTATION FEE", parentCode: "200000" },
{ code: "200301", name: "Documentation fee payment", parentCode: "200300" },
{ code: "200400", name: "BUSINESS TRAVEL", parentCode: "200000" },
{ code: "200401", name: "Air/train ticket", parentCode: "200400" },
{ code: "200402", name: "Boarding & Lodging", parentCode: "200400" },
{ code: "200403", name: "Local travel", parentCode: "200400" },
{ code: "200404", name: "Food expense", parentCode: "200400" },
{ code: "200500", name: "ENTERTAINMENT EXPENSES", parentCode: "200000" },
{ code: "200501", name: "Client entertainment expenses BD", parentCode: "200500" },
// ── OFFICE ADMINISTRATION ─────────────────────────────────────────────────────
{ code: "300000", name: "OFFICE ADMINISTRATION", parentCode: null },
{ code: "300100", name: "OFFICE RENT", parentCode: "300000" },
{ code: "300101", name: "Premises rent", parentCode: "300100" },
{ code: "300200", name: "OFFICE PROPERTY TAX", parentCode: "300000" },
{ code: "300201", name: "Property tax payment", parentCode: "300200" },
{ code: "300300", name: "OFFICE BILLS", parentCode: "300000" },
{ code: "300301", name: "Electricity bill- Office", parentCode: "300300" },
{ code: "300302", name: "Water bill- Office", parentCode: "300300" },
{ code: "300400", name: "OFFICE MAINTENANCE EXPENSES", parentCode: "300000" },
{ code: "300401", name: "Society dues", parentCode: "300400" },
{ code: "300402", name: "AMC charges", parentCode: "300400" },
{ code: "300403", name: "Other repairs- Office", parentCode: "300400" },
{ code: "300404", name: "Refreshment/Pantry expenses", parentCode: "300400" },
{ code: "300405", name: "Housekeeping expenses", parentCode: "300400" },
{ code: "300406", name: "Other Expenses- Office", parentCode: "300400" },
{ code: "300500", name: "OFFICE STATIONERY", parentCode: "300000" },
{ code: "300501", name: "Stationery- Office", parentCode: "300500" },
{ code: "300600", name: "COMPUTERS & PERIPHERALS", parentCode: "300000" },
{ code: "300601", name: "Software expenses", parentCode: "300600" },
{ code: "300602", name: "Hardware expenses", parentCode: "300600" },
{ code: "300700", name: "OFFICE COMMUNICATION", parentCode: "300000" },
{ code: "300701", name: "Courier & Postage", parentCode: "300700" },
{ code: "300702", name: "Telephone and Data", parentCode: "300700" },
{ code: "300703", name: "Mobile bills", parentCode: "300700" },
{ code: "300800", name: "OFFICE VEHICLE EXPENSES", parentCode: "300000" },
{ code: "300801", name: "Vehicle hire", parentCode: "300800" },
{ code: "300802", name: "Fuel", parentCode: "300800" },
{ code: "300803", name: "Repairs", parentCode: "300800" },
{ code: "300804", name: "Insurance premium", parentCode: "300800" },
{ code: "300805", name: "Tax & Tolls", parentCode: "300800" },
{ code: "300900", name: "OFFICE STAFF TRAVEL", parentCode: "300000" },
{ code: "300901", name: "Travel Expense- Official", parentCode: "300900" },
{ code: "300902", name: "Travel allowance", parentCode: "300900" },
{ code: "301000", name: "OFFICE STAFF SALARY", parentCode: "300000" },
{ code: "301001", name: "Office Staff Wages", parentCode: "301000" },
{ code: "301100", name: "OFFICE STAFF PF", parentCode: "300000" },
{ code: "301101", name: "Office Staff EPF- Company Cost", parentCode: "301100" },
{ code: "301200", name: "OFFICE STAFF BONUS", parentCode: "300000" },
{ code: "301201", name: "Annual Bonus", parentCode: "301200" },
{ code: "301202", name: "Performance Bonus", parentCode: "301200" },
{ code: "301300", name: "OFFICE STAFF INSURANCE PREM", parentCode: "300000" },
{ code: "301301", name: "Mediclaim Policy Premium", parentCode: "301300" },
{ code: "301302", name: "Term Policy Premium", parentCode: "301300" },
{ code: "301400", name: "CONSULTANT HIRE CHARGES", parentCode: "300000" },
{ code: "301401", name: "Consultant service charges", parentCode: "301400" },
{ code: "301402", name: "Consultant travel charges", parentCode: "301400" },
{ code: "301403", name: "Consultant- Other charges", parentCode: "301400" },
{ code: "301500", name: "BANKING CHARGES", parentCode: "300000" },
{ code: "301501", name: "Bank charges- Regular", parentCode: "301500" },
{ code: "301502", name: "Processing fee / Account renewal", parentCode: "301500" },
{ code: "301503", name: "Bank charges- Remittance", parentCode: "301500" },
{ code: "301504", name: "Bank charges- POD", parentCode: "301500" },
{ code: "301505", name: "Bank charges- Others", parentCode: "301500" },
{ code: "301600", name: "BANK INTEREST CHARGES", parentCode: "300000" },
{ code: "301601", name: "Bank Interest on hypothecated loan", parentCode: "301600" },
{ code: "301602", name: "Bank Interest on unsecured loan", parentCode: "301600" },
{ code: "301603", name: "Bank penalty & other charges", parentCode: "301600" },
{ code: "301604", name: "Bank Interest on CC/OD Loan", parentCode: "301600" },
{ code: "301700", name: "MCA DUES", parentCode: "300000" },
{ code: "301701", name: "MCA Fee", parentCode: "301700" },
{ code: "301800", name: "OFFICE AUDIT EXPENSES", parentCode: "300000" },
{ code: "301801", name: "Audit fee", parentCode: "301800" },
{ code: "301802", name: "Surveyor travel expenses", parentCode: "301800" },
{ code: "301803", name: "Surveyor entertainment expenses", parentCode: "301800" },
{ code: "301900", name: "TRAINING & DEVELOPMENT", parentCode: "300000" },
{ code: "301901", name: "Training fee", parentCode: "301900" },
{ code: "301902", name: "Other expenses", parentCode: "301900" },
{ code: "302000", name: "LEGAL EXP", parentCode: "300000" },
{ code: "302001", name: "Advocate fee", parentCode: "302000" },
{ code: "302002", name: "Arbitrator fee", parentCode: "302000" },
{ code: "302003", name: "Court charges", parentCode: "302000" },
{ code: "302004", name: "Fines & Penalties", parentCode: "302000" },
{ code: "302005", name: "Stamp duty", parentCode: "302000" },
{ code: "302006", name: "Legal- Travel & Logistics", parentCode: "302000" },
{ code: "302007", name: "Legal cost- Others", parentCode: "302000" },
{ code: "302100", name: "GUEST HOUSE EXPENSES", parentCode: "300000" },
{ code: "302101", name: "Guest house rent", parentCode: "302100" },
{ code: "302102", name: "Electricity bill", parentCode: "302100" },
{ code: "302103", name: "Maintenance cost", parentCode: "302100" },
{ code: "302104", name: "Other expenses", parentCode: "302100" },
{ code: "302200", name: "MEMBERSHIP & SUBSCRIPTION CHARGES", parentCode: "300000" },
{ code: "302201", name: "Subscription Fee", parentCode: "302200" },
{ code: "302300", name: "LICENSE RENEWAL CHARGES", parentCode: "300000" },
{ code: "302301", name: "License renewal fee", parentCode: "302300" },
{ code: "302400", name: "OFFICE STAFF WELFARE EXPENSES", parentCode: "300000" },
{ code: "302401", name: "Office staff Medical Expenses", parentCode: "302400" },
{ code: "302402", name: "Office staff recreation expenses", parentCode: "302400" },
{ code: "302500", name: "SECURITY DEPOSIT", parentCode: "300000" },
{ code: "302501", name: "Security deposit for site equipment", parentCode: "302500" },
{ code: "302502", name: "Security deposit for Office Equipment", parentCode: "302500" },
// ── PROJECT EXPENSES ──────────────────────────────────────────────────────────
{ code: "400000", name: "PROJECT EXPENSES", parentCode: null },
{ code: "400100", name: "CHARTER HIRE", parentCode: "400000" },
{ code: "400101", name: "Charter hire payment", parentCode: "400100" },
{ code: "400200", name: "MOB & DEMOB", parentCode: "400000" },
{ code: "400201", name: "Manpower", parentCode: "400200" },
{ code: "400202", name: "Cranes/Hydra", parentCode: "400200" },
{ code: "400203", name: "Transportation", parentCode: "400200" },
{ code: "400204", name: "Lashing material", parentCode: "400200" },
{ code: "400205", name: "Towing", parentCode: "400200" },
{ code: "400206", name: "Workshop expenses", parentCode: "400200" },
{ code: "400207", name: "Welding & Cutting", parentCode: "400200" },
{ code: "400208", name: "Berth rent", parentCode: "400200" },
{ code: "400209", name: "Union dues", parentCode: "400200" },
{ code: "400210", name: "Facilitation expenses", parentCode: "400200" },
{ code: "400211", name: "Mob-demob Agency charges", parentCode: "400200" },
{ code: "400212", name: "Mob-demob lumpsum cost", parentCode: "400200" },
{ code: "400213", name: "Equipment Insurance Cost", parentCode: "400200" },
{ code: "400300", name: "SITE EXPENSES", parentCode: "400000" },
{ code: "400301", name: "Victualling expense", parentCode: "400300" },
{ code: "400302", name: "Saloon Stores", parentCode: "400300" },
{ code: "400303", name: "Crew welfare", parentCode: "400300" },
{ code: "400304", name: "Crew transport", parentCode: "400300" },
{ code: "400305", name: "Transportation", parentCode: "400300" },
{ code: "400306", name: "Medical", parentCode: "400300" },
{ code: "400307", name: "Hotel stay and food", parentCode: "400300" },
{ code: "400308", name: "Accommodation rent", parentCode: "400300" },
{ code: "400309", name: "Electricity bill", parentCode: "400300" },
{ code: "400310", name: "Union dues", parentCode: "400300" },
{ code: "400311", name: "Contracted Labour Charges", parentCode: "400300" },
{ code: "400312", name: "Boat expenses", parentCode: "400300" },
{ code: "400313", name: "Vehicle hire", parentCode: "400300" },
{ code: "400314", name: "Vehicle diesel", parentCode: "400300" },
{ code: "400315", name: "Vehicle maintenance", parentCode: "400300" },
{ code: "400316", name: "Vehicle tax/toll/parking", parentCode: "400300" },
{ code: "400317", name: "Stationery", parentCode: "400300" },
{ code: "400318", name: "Computers & Peripherals", parentCode: "400300" },
{ code: "400319", name: "Postage & Courier", parentCode: "400300" },
{ code: "400320", name: "Communication", parentCode: "400300" },
{ code: "400321", name: "Agency charges", parentCode: "400300" },
{ code: "400322", name: "Entertainment", parentCode: "400300" },
{ code: "400323", name: "Sundries", parentCode: "400300" },
{ code: "400324", name: "Covid expenses incl. insurance", parentCode: "400300" },
{ code: "400400", name: "RECLAMATION EXPENSES", parentCode: "400000" },
{ code: "400401", name: "Survey expenses", parentCode: "400400" },
{ code: "400402", name: "Excavator / Hydra Hire", parentCode: "400400" },
{ code: "400403", name: "Excavator Diesel", parentCode: "400400" },
{ code: "400404", name: "Excavator Lubes", parentCode: "400400" },
{ code: "400405", name: "Excavator Spares", parentCode: "400400" },
{ code: "400406", name: "Excavator Repairs", parentCode: "400400" },
{ code: "400407", name: "Pipeline repairs & maintenance", parentCode: "400400" },
{ code: "400408", name: "Transportation (Reclaim)", parentCode: "400400" },
{ code: "400409", name: "Reclamation stores & consumables", parentCode: "400400" },
{ code: "400500", name: "PORT DUES", parentCode: "400000" },
{ code: "400501", name: "Port dues charges", parentCode: "400500" },
{ code: "400502", name: "Bank charges for PBG", parentCode: "400500" },
{ code: "400600", name: "PERFORMANCE BANK GUARANTEE", parentCode: "400000" },
{ code: "400601", name: "Value of PBG", parentCode: "400600" },
{ code: "400602", name: "Banking charges", parentCode: "400600" },
{ code: "400700", name: "BANKING CHARGES", parentCode: "400000" },
{ code: "400800", name: "DOCUMENTATION CHARGES", parentCode: "400000" },
{ code: "400900", name: "PROJECT RELATED TRAVEL", parentCode: "400000" },
{ code: "400901", name: "Office staff travel", parentCode: "400900" },
{ code: "400902", name: "Other staff travel", parentCode: "400900" },
{ code: "400903", name: "Office staff hotel & food expense", parentCode: "400900" },
{ code: "400904", name: "Other staff hotel & food expense", parentCode: "400900" },
{ code: "401000", name: "REPAIRS & MAINTENANCE", parentCode: "400000" },
{ code: "401001", name: "Spares- DP Engine", parentCode: "401000" },
{ code: "401002", name: "Spares- Hyd. Engine", parentCode: "401000" },
{ code: "401003", name: "Spares- Dredge Pump", parentCode: "401000" },
{ code: "401004", name: "Spares- Deck generator", parentCode: "401000" },
{ code: "401005", name: "Spares- Cutter", parentCode: "401000" },
{ code: "401006", name: "Spares- Winches", parentCode: "401000" },
{ code: "401007", name: "Spares- Hydraulics", parentCode: "401000" },
{ code: "401008", name: "Spares- Gear box", parentCode: "401000" },
{ code: "401009", name: "Spares- Transmission system", parentCode: "401000" },
{ code: "401010", name: "Spares- Attached pumps & Coolers", parentCode: "401000" },
{ code: "401011", name: "Spares- Safety Equipment", parentCode: "401000" },
{ code: "401012", name: "Spares- Others", parentCode: "401000" },
{ code: "401013", name: "Spares- Steel Wires", parentCode: "401000" },
{ code: "401014", name: "Spares- Electricals", parentCode: "401000" },
{ code: "401015", name: "Spares- Electronics", parentCode: "401000" },
{ code: "401016", name: "Spares- Gauges & Instrumentation", parentCode: "401000" },
{ code: "401017", name: "Spares- Controls and Automation", parentCode: "401000" },
{ code: "401018", name: "Stores- Steel", parentCode: "401000" },
{ code: "401019", name: "Stores- Pipelines", parentCode: "401000" },
{ code: "401020", name: "Stores- Consumables", parentCode: "401000" },
{ code: "401021", name: "Stores- Ropes", parentCode: "401000" },
{ code: "401022", name: "Stores- Lifting gear", parentCode: "401000" },
{ code: "401023", name: "Stores- Safety Equipment & PPE", parentCode: "401000" },
{ code: "401024", name: "Stores- Cleaning gear", parentCode: "401000" },
{ code: "401025", name: "Stores- Welding consumables", parentCode: "401000" },
{ code: "401026", name: "Stores- Gases", parentCode: "401000" },
{ code: "401027", name: "Stores- Paints", parentCode: "401000" },
{ code: "401028", name: "Stores- Electrical", parentCode: "401000" },
{ code: "401029", name: "Stores- Nuts & Bolts", parentCode: "401000" },
{ code: "401030", name: "Stores- Tools", parentCode: "401000" },
{ code: "401031", name: "Stores- Others", parentCode: "401000" },
{ code: "401032", name: "Workshop charges", parentCode: "401000" },
{ code: "401033", name: "Technician visit charges", parentCode: "401000" },
{ code: "401034", name: "Travel & Transportation", parentCode: "401000" },
{ code: "401035", name: "Crane/Hydra charges for repairs", parentCode: "401000" },
{ code: "401100", name: "CLIENT ENTERTAINMENT", parentCode: "400000" },
{ code: "401101", name: "Client entertainment expenses Project", parentCode: "401100" },
// ── MANNING ───────────────────────────────────────────────────────────────────
{ code: "500000", name: "MANNING", parentCode: null },
{ code: "500100", name: "CREW WAGES", parentCode: "500000" },
{ code: "500101", name: "Salary", parentCode: "500100" },
{ code: "500102", name: "Employee Provident Fund Contribution", parentCode: "500100" },
{ code: "500103", name: "Seaman Provident Fund Contribution", parentCode: "500100" },
{ code: "500104", name: "Levy to Seaman Employment Office", parentCode: "500100" },
{ code: "500105", name: "Crew Welfare to Seaman Employment Office", parentCode: "500100" },
{ code: "500106", name: "ESIC Contribution", parentCode: "500100" },
{ code: "500107", name: "WC Premium", parentCode: "500100" },
{ code: "500108", name: "Income Tax to Seafarer", parentCode: "500100" },
{ code: "500109", name: "Professional Tax to Seafarer", parentCode: "500100" },
{ code: "500110", name: "Bonus to Seafarer", parentCode: "500100" },
{ code: "500111", name: "Labour Welfare Fund Contribution", parentCode: "500100" },
{ code: "500200", name: "SIGN-ON/OFF", parentCode: "500000" },
{ code: "500201", name: "Interstate travel", parentCode: "500200" },
{ code: "500202", name: "Local Travel", parentCode: "500200" },
{ code: "500203", name: "Medical", parentCode: "500200" },
{ code: "500204", name: "Hotel", parentCode: "500200" },
{ code: "500205", name: "Food allowance", parentCode: "500200" },
{ code: "500206", name: "Personal Protective Equipment", parentCode: "500200" },
{ code: "500207", name: "License fee etc.", parentCode: "500200" },
{ code: "500208", name: "Course/ Training fee", parentCode: "500200" },
{ code: "500209", name: "Travelling Allowance", parentCode: "500200" },
{ code: "500210", name: "Covid related expenses", parentCode: "500200" },
{ code: "500211", name: "International travel Air Fare", parentCode: "500200" },
{ code: "500212", name: "Crew Agency charges", parentCode: "500200" },
{ code: "500213", name: "Visa charges", parentCode: "500200" },
{ code: "500214", name: "Meet n Greet Charges", parentCode: "500200" },
{ code: "500215", name: "Change of Command Fee", parentCode: "500200" },
// ── TECHNICAL ─────────────────────────────────────────────────────────────────
{ code: "600000", name: "TECHNICAL", parentCode: null },
{ code: "600100", name: "DOCKING EXPENSES (TECHNICAL)", parentCode: "600000" },
{ code: "600101", name: "Drydock- Stores n Consumables", parentCode: "600100" },
{ code: "600102", name: "Drydock- Ropes n wires", parentCode: "600100" },
{ code: "600103", name: "Drydock- Lifting gear", parentCode: "600100" },
{ code: "600104", name: "Drydock- Safety Equipment & PPE", parentCode: "600100" },
{ code: "600105", name: "Drydock- Cleaning gear", parentCode: "600100" },
{ code: "600106", name: "Drydock- Nuts n bolts", parentCode: "600100" },
{ code: "600107", name: "Drydock- Electrical stores", parentCode: "600100" },
{ code: "600108", name: "Drydock- Tools", parentCode: "600100" },
{ code: "600109", name: "Drydock- Cranage", parentCode: "600100" },
{ code: "600110", name: "Drydock- Transportation", parentCode: "600100" },
{ code: "600200", name: "SURVEY & CERTIFICATION", parentCode: "600000" },
{ code: "600201", name: "Flag Survey fee", parentCode: "600200" },
{ code: "600202", name: "Classification Society fee", parentCode: "600200" },
{ code: "600203", name: "Survey travel expenses", parentCode: "600200" },
{ code: "600204", name: "Entertainment expenses", parentCode: "600200" },
{ code: "600300", name: "INSURANCE PREMIUM", parentCode: "600000" },
{ code: "600301", name: "H&M Insurance premium", parentCode: "600300" },
{ code: "600302", name: "Loss of Hire Insurance", parentCode: "600300" },
{ code: "600303", name: "P&I Insurance", parentCode: "600300" },
{ code: "600304", name: "War risk", parentCode: "600300" },
{ code: "600305", name: "Other insurance", parentCode: "600300" },
// ── BUNKER, LUBES & WATER ─────────────────────────────────────────────────────
{ code: "700000", name: "BUNKER, LUBES & WATER", parentCode: null },
{ code: "700100", name: "FUEL OIL", parentCode: "700000" },
{ code: "700101", name: "Diesel", parentCode: "700100" },
{ code: "700102", name: "Bio-diesel", parentCode: "700100" },
{ code: "700103", name: "CNG/LNG", parentCode: "700100" },
{ code: "700104", name: "Other fuels", parentCode: "700100" },
{ code: "700105", name: "Transportation (fuel oil)", parentCode: "700100" },
{ code: "700200", name: "OTHER OILS ETC.", parentCode: "700000" },
{ code: "700201", name: "Engine oil", parentCode: "700200" },
{ code: "700202", name: "Hydraulic Oil", parentCode: "700200" },
{ code: "700203", name: "Gear Oil", parentCode: "700200" },
{ code: "700204", name: "Grease", parentCode: "700200" },
{ code: "700205", name: "Coolant", parentCode: "700200" },
{ code: "700206", name: "Distil Water", parentCode: "700200" },
{ code: "700207", name: "Transportation (Other oils)", parentCode: "700200" },
{ code: "700300", name: "ANALYSIS EXPENSE", parentCode: "700000" },
{ code: "700301", name: "Fuel oil analysis", parentCode: "700300" },
{ code: "700302", name: "Engine oil analysis", parentCode: "700300" },
{ code: "700303", name: "Hydraulic oil analysis", parentCode: "700300" },
{ code: "700304", name: "Coolant analysis", parentCode: "700300" },
{ code: "700305", name: "Other oils analysis", parentCode: "700300" },
{ code: "700306", name: "Courier charges", parentCode: "700300" },
];

View file

@ -0,0 +1,5 @@
-- AlterTable: add parentId for hierarchical accounting codes (3-level: TopCategory > SubCategory > Item)
ALTER TABLE "Account" ADD COLUMN "parentId" TEXT;
ALTER TABLE "Account" ADD CONSTRAINT "Account_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -125,6 +125,10 @@ model Account {
description String? description String?
isActive Boolean @default(true) isActive Boolean @default(true)
parentId String?
parent Account? @relation("AccountHierarchy", fields: [parentId], references: [id])
children Account[] @relation("AccountHierarchy")
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
lineItems POLineItem[] lineItems POLineItem[]
} }

View file

@ -1,5 +1,6 @@
import { PrismaClient, Role } from "@prisma/client"; import { PrismaClient, Role } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { ACCOUNTING_CODES } from "./accounting-codes-data";
const db = new PrismaClient(); const db = new PrismaClient();
@ -177,78 +178,43 @@ async function main() {
const mvCallisto = await findOrCreateVessel("MV Callisto", siteGOA.id, "SITE-010"); const mvCallisto = await findOrCreateVessel("MV Callisto", siteGOA.id, "SITE-010");
await findOrCreateVessel("MV Doris", siteCHE.id, "SITE-011"); await findOrCreateVessel("MV Doris", siteCHE.id, "SITE-011");
// ─── Accounts ──────────────────────────────────────────────────────────────── // ─── Accounting Codes (hierarchical) ─────────────────────────────────────────
const accTechOps = await db.account.upsert({ // Seed in two passes: first create all entries without parentId, then link parents
where: { code: "TECH-OPS" }, const codeIdMap = new Map<string, string>();
update: {},
create: { code: "TECH-OPS", name: "Technical Operations", description: "Engine, deck equipment and spare parts" },
});
const accCrewMgt = await db.account.upsert({ // Pass 1: upsert all entries without parentId to get their IDs
where: { code: "CREW-MGT" }, for (const entry of ACCOUNTING_CODES) {
update: {}, const rec = await db.account.upsert({
create: { code: "CREW-MGT", name: "Crew Management", description: "Manning and crew welfare" }, where: { code: entry.code },
update: { name: entry.name },
create: { code: entry.code, name: entry.name },
}); });
codeIdMap.set(entry.code, rec.id);
}
const accFuel = await db.account.upsert({ // Pass 2: link parent relationships
where: { code: "FUEL-BNK" }, for (const entry of ACCOUNTING_CODES) {
update: {}, if (entry.parentCode) {
create: { code: "FUEL-BNK", name: "Fuel & Bunkers", description: "Fuel procurement and bunkering" }, const parentId = codeIdMap.get(entry.parentCode);
if (parentId) {
await db.account.update({
where: { code: entry.code },
data: { parentId },
}); });
}
}
}
const accSafety = await db.account.upsert({ // Convenience variables for PO seed data below (map to real leaf codes)
where: { code: "SAFETY" }, const accTechOps = { id: codeIdMap.get("401012")! }; // Spares- Others
update: {}, const accCrewMgt = { id: codeIdMap.get("500101")! }; // Salary
create: { code: "SAFETY", name: "Safety & Lifesaving", description: "LSA, firefighting, immersion suits, EPIRBs" }, const accFuel = { id: codeIdMap.get("700101")! }; // Diesel
}); const accSafety = { id: codeIdMap.get("401023")! }; // Stores- Safety Equipment & PPE
const accPaint = { id: codeIdMap.get("401027")! }; // Stores- Paints
const accPaint = await db.account.upsert({ const accElect = { id: codeIdMap.get("401028")! }; // Stores- Electrical
where: { code: "PAINT-MAINT" }, const accNavig = { id: codeIdMap.get("600201")! }; // Flag Survey fee
update: {}, const accStores = { id: codeIdMap.get("401031")! }; // Stores- Others
create: { code: "PAINT-MAINT", name: "Paint & Maintenance", description: "Hull painting, surface prep, coatings" }, const accDeck = { id: codeIdMap.get("401030")! }; // Stores- Tools
});
const accElect = await db.account.upsert({
where: { code: "ELECT" },
update: {},
create: { code: "ELECT", name: "Electrical Systems", description: "Navigation lights, batteries, marine cable" },
});
const accNavig = await db.account.upsert({
where: { code: "NAVIG" },
update: {},
create: { code: "NAVIG", name: "Navigation & Charts", description: "ECDIS updates, chart folios, publications" },
});
await db.account.upsert({
where: { code: "PROVISION" },
update: {},
create: { code: "PROVISION", name: "Crew Provisions", description: "Food, water and domestic supplies" },
});
const accStores = await db.account.upsert({
where: { code: "GEN-STORES" },
update: {},
create: { code: "GEN-STORES", name: "General Stores", description: "Consumables, cleaning materials, PPE" },
});
await db.account.upsert({
where: { code: "CHEM-TREAT" },
update: {},
create: { code: "CHEM-TREAT", name: "Chemicals & Treatments", description: "Boiler water treatment, biocides, cleaners" },
});
const accDeck = await db.account.upsert({
where: { code: "DECK-EQUIP" },
update: {},
create: { code: "DECK-EQUIP", name: "Deck Equipment", description: "Mooring, anchor, deck machinery" },
});
await db.account.upsert({
where: { code: "ROPE-RIGG" },
update: {},
create: { code: "ROPE-RIGG", name: "Rope & Rigging", description: "Mooring ropes, pilot ladders, wire rope" },
});
// ─── Vendors ───────────────────────────────────────────────────────────────── // ─── Vendors ─────────────────────────────────────────────────────────────────
const v1 = await db.vendor.upsert({ const v1 = await db.vendor.upsert({