Compare commits

...

4 commits

Author SHA1 Message Date
4c6b9c670f chore(forgejo): add issue template 2026-06-11 16:04:22 +05:30
2a3fad3eb9 mockup(Reports): add graphs for reporting as mockup 2026-06-11 16:04:06 +05:30
add0f3c19c feat(payments): compulsory payment date when Accounts records payment
- New PurchaseOrder.paymentDate field (migration 20260531000002)
- Backfill: existing POs use paidAt, else the earliest payment action date
- Accounts must enter a payment date with the payment reference
- Date input pre-selected to today, max=today (no future dates)
- Validated server-side (required + not in future) in processPaymentSchema
- paymentDate stored on both full and partial payments; paidAt set from it
- Shown on PO detail (Payment Date) and payment history (prefers paymentDate)
- Integration tests updated; added future-date rejection test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:59:25 +05:30
eb402e03ef fix(profile+vendors): profile reachable for all roles incl SSO; submitter vendor creation
Profile (fixes Safari/SSO no-password redirect):
- User lookup falls back to email when JWT id is stale (SSO users)
- generateDownloadUrl wrapped in try/catch so storage never crashes the page
- Signature gate now uses approve_po permission (approvers only)
- SSO/no-password users see a Set Password form (current-password field hidden)

Vendors:
- New create_vendor permission for all PO roles incl. submitters
- Submitters create UNVERIFIED vendors (no Vendor ID); simple form mode
- verifyVendor action + Verify menu item (manage_vendors)
- Vendors auto-verify when a PO closes with them (receipt confirm + import)
- Add Vendor button on /inventory/vendors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:53:33 +05:30
20 changed files with 1097 additions and 67 deletions

View file

@ -0,0 +1,35 @@
name: Issue
about: Track a bug or feature from dev review, user feedback, or tests
title: "[Issue]: "
labels:
- triage
body:
- type: input
id: raised-by
attributes:
label: Raised by
description: Who or what surfaced this (name, "user feedback", "CI/test", "dev review")
placeholder: e.g. Hardik / dev review
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: What's the issue or feature? Include steps to reproduce if it's a bug.
placeholder: Describe it here...
validations:
required: true
- type: dropdown
id: priority
attributes:
label: Priority
options:
- P0 — Critical (broken / blocking)
- P1 — High
- P2 — Medium
- P3 — Low
validations:
required: true

View file

@ -52,10 +52,14 @@ async function resolveLatLng(pincode?: string) {
export async function createVendor(formData: FormData): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
if (!session?.user || !hasPermission(session.user.role, "create_vendor")) {
return { error: "Unauthorized" };
}
// Submitters (no manage_vendors) may create vendors, but they stay UNVERIFIED
// until a PO closes with them or a Manager/Accounts/Admin approves them.
const canVerify = hasPermission(session.user.role, "manage_vendors");
const parsed = vendorSchema.safeParse({
name: formData.get("name"),
vendorId: formData.get("vendorId") || undefined,
@ -66,8 +70,10 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const data = parsed.data;
if (data.vendorId) {
const exists = await db.vendor.findUnique({ where: { vendorId: data.vendorId } });
// Only verifiers may assign a Vendor ID (which marks a vendor as verified).
const vendorId = canVerify ? data.vendorId : undefined;
if (vendorId) {
const exists = await db.vendor.findUnique({ where: { vendorId } });
if (exists) return { error: "A vendor with that Vendor ID already exists" };
}
@ -77,18 +83,33 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
await db.vendor.create({
data: {
name: data.name,
vendorId: data.vendorId ?? null,
vendorId: vendorId ?? null,
address: data.address ?? null,
pincode: data.pincode ?? null,
gstin: data.gstin ?? null,
latitude,
longitude,
isVerified: !!data.vendorId,
isVerified: canVerify ? !!vendorId : false,
contacts: contacts.length > 0 ? { create: contacts } : undefined,
},
});
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
return { ok: true };
}
/** Approve / verify a vendor — Manager, Accounts or Admin only. */
export async function verifyVendor(vendorId: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vendors")) {
return { error: "Unauthorized" };
}
await db.vendor.update({ where: { id: vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
revalidatePath(`/admin/vendors/${vendorId}`);
return { ok: true };
}

View file

@ -113,7 +113,7 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
);
}
function VendorFormFields({ vendor, suggestedVendorId }: { vendor?: VendorRow; suggestedVendorId?: string }) {
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? "");
const [address, setAddress] = useState(vendor?.address ?? "");
@ -217,11 +217,18 @@ function VendorFormFields({ vendor, suggestedVendorId }: { vendor?: VendorRow; s
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor Name *</label>
<input name="name" value={name} onChange={(e) => setName(e.target.value)} required className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID <span className="text-neutral-400 font-normal">(leave blank if unverified)</span></label>
<input name="vendorId" defaultValue={vendor?.vendorId ?? suggestedVendorId ?? ""} className={INPUT} placeholder="VND-001" />
</div>
{!simple && (
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID <span className="text-neutral-400 font-normal">(leave blank if unverified)</span></label>
<input name="vendorId" defaultValue={vendor?.vendorId ?? suggestedVendorId ?? ""} className={INPUT} placeholder="VND-001" />
</div>
)}
</div>
{simple && (
<p className="rounded-lg bg-warning-50 border border-warning-200 px-3 py-2 text-xs text-warning-800">
This vendor will be created as <strong>unverified</strong>. It becomes verified once a PO is closed with it, or after a Manager/Accounts review.
</p>
)}
{/* Address + Pincode */}
<div>
@ -243,7 +250,7 @@ function VendorFormFields({ vendor, suggestedVendorId }: { vendor?: VendorRow; s
);
}
export function AddVendorButton({ suggestedId }: { suggestedId?: string }) {
export function AddVendorButton({ suggestedId, simple = false }: { suggestedId?: string; simple?: boolean }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
@ -264,7 +271,7 @@ export function AddVendorButton({ suggestedId }: { suggestedId?: string }) {
</button>
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields suggestedVendorId={suggestedId} />
<VendorFormFields suggestedVendorId={suggestedId} simple={simple} />
{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">
<button type="button" onClick={() => setOpen(false)}

View file

@ -8,7 +8,7 @@ import { AddVendorButton, EditVendorButton } from "./vendor-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 { deleteVendor, toggleVendorActive } from "./actions";
import { deleteVendor, toggleVendorActive, verifyVendor } from "./actions";
type ContactRow = {
name: string;
@ -37,11 +37,15 @@ function VendorActionsMenu({ vendor }: { vendor: VendorRow }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
const [verifyOpen, setVerifyOpen] = useState(false);
return (
<>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
{!vendor.isVerified && (
<RowActionsItem onClick={() => setVerifyOpen(true)}>Verify vendor</RowActionsItem>
)}
<RowActionsItem onClick={() => setToggleOpen(true)}>
{vendor.isActive ? "Deactivate" : "Activate"}
</RowActionsItem>
@ -49,6 +53,15 @@ function VendorActionsMenu({ vendor }: { vendor: VendorRow }) {
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<ConfirmDialog
open={verifyOpen}
onOpenChange={setVerifyOpen}
title={`Verify ${vendor.name}?`}
description="Marks this vendor as verified and approved for use across the portal."
confirmLabel="Verify"
onConfirm={() => verifyVendor(vendor.id)}
/>
<EditVendorButton
vendor={{
id: vendor.id,

View file

@ -2,7 +2,9 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { distanceKm } from "@/lib/geo";
import { hasPermission } from "@/lib/permissions";
import { VendorsTable } from "./vendors-table";
import { AddVendorButton } from "@/app/(portal)/admin/vendors/vendor-form";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vendors" };
@ -62,11 +64,18 @@ export default async function InventoryVendorsPage({ searchParams }: Props) {
};
});
// Submitters can add vendors here; without manage_vendors the vendor stays unverified.
const canCreate = hasPermission(session.user.role, "create_vendor");
const canVerify = hasPermission(session.user.role, "manage_vendors");
return (
<div className="max-w-6xl">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Vendors</h1>
<p className="mt-1 text-sm text-neutral-500">Browse vendors and their distance from your working site.</p>
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Vendors</h1>
<p className="mt-1 text-sm text-neutral-500">Browse vendors and their distance from your working site.</p>
</div>
{canCreate && <AddVendorButton simple={!canVerify} />}
</div>
<VendorsTable
vendors={rows}

View file

@ -140,16 +140,20 @@ export async function markPaid({
poId,
paymentRef,
paymentAmount,
paymentDate,
}: {
poId: string;
paymentRef: string;
paymentAmount?: number; // if omitted, treat as full remaining amount
paymentDate: string; // ISO date (yyyy-mm-dd) entered by Accounts
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const parsed = processPaymentSchema.safeParse({ paymentRef, paymentAmount });
if (!parsed.success) return { error: "Payment reference is required." };
const parsed = processPaymentSchema.safeParse({ paymentRef, paymentAmount, paymentDate });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const enteredPaymentDate = parsed.data.paymentDate;
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
@ -175,14 +179,15 @@ export async function markPaid({
where: { id: poId },
data: {
status: "PAID_DELIVERED",
paidAt: new Date(),
paidAt: enteredPaymentDate,
paymentDate: enteredPaymentDate,
paymentRef: parsed.data.paymentRef,
paidAmount: newPaidAmount,
actions: {
create: {
actionType: "PAYMENT_SENT",
actorId: session.user.id,
metadata: { paymentRef: parsed.data.paymentRef },
metadata: { paymentRef: parsed.data.paymentRef, paymentDate: enteredPaymentDate.toISOString() },
},
},
},
@ -206,6 +211,7 @@ export async function markPaid({
data: {
status: "PARTIALLY_PAID",
paymentRef: parsed.data.paymentRef,
paymentDate: enteredPaymentDate,
paidAmount: newPaidAmount,
actions: {
create: {
@ -215,6 +221,7 @@ export async function markPaid({
paymentRef: parsed.data.paymentRef,
paymentAmount: paying,
totalPaid: newPaidAmount,
paymentDate: enteredPaymentDate.toISOString(),
},
},
},

View file

@ -130,7 +130,7 @@ export default async function PaymentHistoryPage({ searchParams }: Props) {
{formatCurrency(Number(po.totalAmount), po.currency)}
</td>
<td className="px-4 py-3 text-neutral-500">
{po.paidAt ? formatDate(po.paidAt) : "—"}
{po.paymentDate ? formatDate(po.paymentDate) : po.paidAt ? formatDate(po.paidAt) : "—"}
</td>
</tr>
))}

View file

@ -12,14 +12,23 @@ interface Props {
paidAmount?: number;
}
// Today's date as a local yyyy-mm-dd string (for <input type="date"> default + max)
function todayLocal(): string {
const d = new Date();
const off = d.getTimezoneOffset();
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
}
export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
const router = useRouter();
const [ref, setRef] = useState("");
const [amount, setAmount] = useState<string>("");
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const remaining = totalAmount - paidAmount;
const today = todayLocal();
async function handleProcessPayment() {
setPending(true);
@ -32,6 +41,8 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
async function handleMarkPaid(e: React.FormEvent, forceFullPayment = false) {
e.preventDefault();
if (!ref.trim()) { setError("Payment reference is required."); return; }
if (!paymentDate) { setError("Payment date is required."); return; }
if (paymentDate > today) { setError("Payment date cannot be in the future."); return; }
const paymentAmount = forceFullPayment ? remaining : (parseFloat(amount) || undefined);
@ -46,7 +57,7 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
setPending(true);
setError("");
const result = await markPaid({ poId, paymentRef: ref, paymentAmount });
const result = await markPaid({ poId, paymentRef: ref, paymentAmount, paymentDate });
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); router.refresh(); }
}
@ -88,6 +99,16 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
onChange={(e) => setRef(e.target.value)}
className="flex-1 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"
/>
<input
type="date"
aria-label="Payment date"
title="Payment date"
value={paymentDate}
max={today}
required
onChange={(e) => setPaymentDate(e.target.value)}
className="w-full sm:w-40 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"
/>
<input
type="number"
placeholder={`Amount (max ${remaining.toFixed(2)})`}

View file

@ -148,6 +148,13 @@ export async function confirmReceipt({
revalidatePath(`/admin/sites/${siteId}`);
}
// Closing a PO auto-verifies its vendor (proof of a real, completed transaction).
if (newStatus === "CLOSED" && po.vendorId) {
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
}
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
if (newStatus === "CLOSED") {
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });

View file

@ -185,6 +185,13 @@ export async function importPo(
},
});
// Imported PO is CLOSED → its vendor is proven by a real transaction, so verify it.
if (resolvedVendorId) {
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
}
revalidatePath("/history");
revalidatePath("/dashboard");
return { id: po.id };

View file

@ -2,6 +2,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { buildSignatureKey, uploadBuffer } from "@/lib/storage";
import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs";
@ -53,8 +54,8 @@ export async function saveSignature(formData: FormData): Promise<Result> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (session.user.role !== "MANAGER" && session.user.role !== "SUPERUSER") {
return { error: "Only managers and superusers can upload a signature" };
if (!hasPermission(session.user.role, "approve_po")) {
return { error: "Only approvers can upload a signature" };
}
const file = formData.get("signature") as File | null;

View file

@ -3,7 +3,7 @@
import { useState } from "react";
import { changePassword } from "./actions";
export function ChangePasswordForm() {
export function ChangePasswordForm({ hasPassword = true }: { hasPassword?: boolean }) {
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
@ -36,17 +36,19 @@ export function ChangePasswordForm() {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Current password
</label>
<input
type="password"
name="currentPassword"
autoComplete="current-password"
className="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"
/>
</div>
{hasPassword && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Current password
</label>
<input
type="password"
name="currentPassword"
autoComplete="current-password"
className="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"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
New password
@ -88,7 +90,7 @@ export function ChangePasswordForm() {
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
>
{pending ? "Saving…" : "Change Password"}
{pending ? "Saving…" : hasPassword ? "Change Password" : "Set Password"}
</button>
</form>
);

View file

@ -1,6 +1,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { hasPermission } from "@/lib/permissions";
import { generateDownloadUrl } from "@/lib/storage";
import { ChangePasswordForm } from "./change-password-form";
import { SignatureUploader } from "./signature-uploader";
@ -23,30 +24,44 @@ export default async function ProfilePage() {
const session = await auth();
if (!session?.user) redirect("/login");
const user = await db.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
name: true,
email: true,
employeeId: true,
role: true,
signatureKey: true,
superUserRequests: {
orderBy: { createdAt: "desc" },
take: 1,
select: { status: true, createdAt: true },
},
const userSelect = {
id: true,
name: true,
email: true,
employeeId: true,
role: true,
signatureKey: true,
passwordHash: true,
superUserRequests: {
orderBy: { createdAt: "desc" as const },
take: 1,
select: { status: true, createdAt: true },
},
});
};
// Look up by id, falling back to email. SSO/no-password users can carry a JWT
// whose `id` differs from the DB row; the email fallback keeps the page reachable.
let user = await db.user.findUnique({ where: { id: session.user.id }, select: userSelect });
if (!user && session.user.email) {
user = await db.user.findUnique({ where: { email: session.user.email }, select: userSelect });
}
if (!user) redirect("/login");
const canHaveSignature = user.role === "MANAGER" || user.role === "SUPERUSER";
// Only approvers (those who can approve POs) may upload a signature.
const canHaveSignature = hasPermission(user.role, "approve_po");
const canRequestSuperUser = user.role !== "SUPERUSER" && user.role !== "ADMIN";
// SSO-only users have no password yet; the form lets them set one.
const hasPassword = !!user.passwordHash;
const signatureUrl = user.signatureKey
? await generateDownloadUrl(user.signatureKey)
: null;
// Never let a storage hiccup (missing key, R2 misconfig) crash the profile page.
let signatureUrl: string | null = null;
if (user.signatureKey) {
try {
signatureUrl = await generateDownloadUrl(user.signatureKey);
} catch {
signatureUrl = null;
}
}
const latestRequest = user.superUserRequests[0] ?? null;
@ -84,10 +99,17 @@ export default async function ProfilePage() {
</dl>
</section>
{/* Change Password */}
{/* Change / Set Password */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Change Password</h2>
<ChangePasswordForm />
<h2 className="text-base font-semibold text-neutral-900 mb-1">
{hasPassword ? "Change Password" : "Set Password"}
</h2>
{!hasPassword && (
<p className="mb-4 text-sm text-neutral-500">
You sign in with single sign-on. Optionally set a password to also sign in with email.
</p>
)}
<ChangePasswordForm hasPassword={hasPassword} />
</section>
{/* Signature (managers & superusers) */}

View file

@ -20,6 +20,7 @@ type PoWithRelations = {
dateRequired: Date | null;
managerNote: string | null;
paymentRef: string | null;
paymentDate?: Date | null;
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
piQuotationNo?: string | null;
piQuotationDate?: Date | null;
@ -299,6 +300,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
{po.requisitionNo && <div><dt className="text-neutral-500">Requisition No.</dt><dd className="font-medium text-neutral-900">{po.requisitionNo}</dd></div>}
{po.requisitionDate && <div><dt className="text-neutral-500">Requisition Date</dt><dd className="font-medium text-neutral-900">{formatDate(po.requisitionDate)}</dd></div>}
{po.paymentRef && <div><dt className="text-neutral-500">Payment Ref</dt><dd className="font-mono text-sm text-neutral-900">{po.paymentRef}</dd></div>}
{(po.paymentDate || po.paidAt) && <div><dt className="text-neutral-500">Payment Date</dt><dd className="font-medium text-neutral-900">{formatDate((po.paymentDate ?? po.paidAt)!)}</dd></div>}
</dl>
{po.placeOfDelivery && (
<div className="mt-3 pt-3 border-t border-neutral-100 text-sm">

View file

@ -16,14 +16,15 @@ export type Permission =
| "export_reports"
| "manage_users"
| "manage_vendors"
| "create_vendor"
| "manage_vessels_accounts"
| "manage_products"
| "manage_sites";
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"],
MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"],
ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors"],
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors", "create_vendor"],
MANAGER: [
"create_po",
"submit_po",
@ -37,6 +38,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_analytics",
"export_reports",
"manage_vendors",
"create_vendor",
"manage_vessels_accounts",
"manage_products",
"manage_sites",
@ -55,6 +57,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"confirm_receipt",
"view_analytics",
"export_reports",
"create_vendor",
],
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
ADMIN: [
@ -64,6 +67,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"export_reports",
"manage_users",
"manage_vendors",
"create_vendor",
"manage_vessels_accounts",
"manage_products",
"manage_sites",

View file

@ -65,6 +65,14 @@ export const requestEditsSchema = z.object({
export const processPaymentSchema = z.object({
paymentRef: z.string().min(1, "Payment reference is required"),
paymentAmount: z.number().positive("Payment amount must be greater than 0").optional(),
paymentDate: z.coerce
.date({ required_error: "Payment date is required", invalid_type_error: "Payment date is required" })
.refine((d) => {
// Not in the future — compare against end of today (local)
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
return d.getTime() <= endOfToday.getTime();
}, "Payment date cannot be in the future"),
});
export const confirmReceiptSchema = z.object({

View file

@ -0,0 +1,22 @@
-- Add user-entered payment date to PurchaseOrder
ALTER TABLE "PurchaseOrder" ADD COLUMN "paymentDate" TIMESTAMP(3);
-- Backfill 1: fully-paid POs already carry paidAt — use it as the payment date
UPDATE "PurchaseOrder"
SET "paymentDate" = "paidAt"
WHERE "paidAt" IS NOT NULL AND "paymentDate" IS NULL;
-- Backfill 2: POs that have a payment reference but no payment date yet
-- (e.g. partially-paid) — use the date the payment reference was first recorded,
-- i.e. the earliest PAYMENT_SENT / PARTIAL_PAYMENT_CONFIRMED action.
UPDATE "PurchaseOrder" po
SET "paymentDate" = sub."firstPaymentActionAt"
FROM (
SELECT "poId", MIN("createdAt") AS "firstPaymentActionAt"
FROM "POAction"
WHERE "actionType" IN ('PAYMENT_SENT', 'PARTIAL_PAYMENT_CONFIRMED')
GROUP BY "poId"
) sub
WHERE po."id" = sub."poId"
AND po."paymentDate" IS NULL
AND po."paymentRef" IS NOT NULL;

View file

@ -250,6 +250,7 @@ model PurchaseOrder {
projectCode String?
managerNote String?
paymentRef String?
paymentDate DateTime?
paidAmount Decimal? @db.Decimal(12, 2)
piQuotationNo String?
piQuotationDate DateTime?

View file

@ -19,6 +19,7 @@ import {
} from "./helpers";
const PREFIX = "INTTEST_PAYMENT_";
const TODAY = new Date().toISOString().slice(0, 10); // yyyy-mm-dd, used for payment date
let techId: string;
let managerId: string;
let accountsId: string;
@ -91,13 +92,14 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234" });
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
expect(po?.status).toBe("PAID_DELIVERED");
expect(po?.paymentRef).toBe("NEFT/2026/001234");
expect(po?.paidAt).not.toBeNull();
expect(po?.paymentDate).not.toBeNull();
});
it("creates a PAYMENT_SENT action in the audit trail", async () => {
@ -105,7 +107,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
await markPaid({ poId, paymentRef: "TXN-9999" });
await markPaid({ poId, paymentRef: "TXN-9999", paymentDate: TODAY });
const action = await db.pOAction.findFirst({ where: { poId, actionType: "PAYMENT_SENT" } });
expect(action).not.toBeNull();
@ -117,7 +119,17 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const result = await markPaid({ poId, paymentRef: "" });
const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY });
expect(result).toHaveProperty("error");
});
it("returns error when payment date is in the future", async () => {
const poId = await createApprovedPo(`${PREFIX}PaidFutureDate`);
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
await processPayment({ poId });
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const result = await markPaid({ poId, paymentRef: "FUTURE-REF", paymentDate: future });
expect(result).toHaveProperty("error");
});
@ -128,7 +140,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
vi.mocked(notify).mockClear();
await processPayment({ poId });
await markPaid({ poId, paymentRef: "REF-42" });
await markPaid({ poId, paymentRef: "REF-42", paymentDate: TODAY });
const calls = vi.mocked(notify).mock.calls.map((c) => c[0].event);
expect(calls).toContain("PAYMENT_SENT");
@ -141,7 +153,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
await processPayment({ poId });
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
const result = await markPaid({ poId, paymentRef: "MGR-REF" });
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
expect(result).toHaveProperty("error");
});
});

View file

@ -0,0 +1,829 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PPMS · Reports — UX Mockup</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { 50:"#eff6ff",100:"#dbeafe",500:"#3b82f6",600:"#2563eb",700:"#1d4ed8" },
neutral: { 50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",600:"#525252",700:"#404040",800:"#262626",900:"#171717" },
},
},
},
};
</script>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.nav-active { background:#eff6ff; color:#1d4ed8; }
::-webkit-scrollbar { width:8px; height:8px; }
::-webkit-scrollbar-thumb { background:#d4d4d4; border-radius:4px; }
</style>
</head>
<body class="bg-neutral-50 text-neutral-900">
<div class="flex h-screen overflow-hidden">
<!-- ───────────────────────── Sidebar ───────────────────────── -->
<aside class="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
<div class="flex h-16 items-center gap-2.5 border-b border-neutral-200 px-4">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
<svg class="h-4 w-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="22" x2="12" y2="8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/></svg>
</div>
<span class="text-sm font-semibold text-neutral-900">PPMS</span>
</div>
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-0.5 text-sm">
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-400 cursor-default">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-400 cursor-default">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Closed Purchase Orders
</a>
<div class="pt-4 pb-1 px-3"><p class="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Reports</p></div>
<a onclick="go('cc')" id="nav-cc" class="nav-link flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100 cursor-pointer">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22V8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/></svg>
Cost Centres
</a>
<a onclick="go('ac')" id="nav-ac" class="nav-link flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100 cursor-pointer">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M6 21V8l6-4 6 4v13"/><path d="M9 21v-6h6v6"/></svg>
Accounting Codes
</a>
<div class="pt-4 pb-1 px-3"><p class="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Administration</p></div>
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-400 cursor-default">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
Users
</a>
</nav>
</aside>
<!-- ───────────────────────── Main ───────────────────────── -->
<main class="flex-1 overflow-y-auto">
<header class="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-neutral-200 bg-white/90 px-8 backdrop-blur">
<div class="flex items-center gap-2 text-sm text-neutral-500">
<span>Reports</span>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<span id="crumb-section" class="text-neutral-900 font-medium">Cost Centres</span>
<span id="crumb-detail-wrap" class="hidden items-center gap-2">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<span id="crumb-detail" class="text-neutral-900 font-medium"></span>
</span>
</div>
<div class="h-8 w-8 rounded-full bg-neutral-200"></div>
</header>
<!-- ── PINNED FILTER TOOLBAR ── -->
<div class="sticky top-16 z-20 border-b border-neutral-200 bg-neutral-50/95 px-8 py-3 backdrop-blur">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Granularity</span>
<div class="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
<button data-gran="yearly" class="gran-btn rounded-md px-3 py-1 font-medium text-neutral-500">Yearly</button>
<button data-gran="monthly" class="gran-btn rounded-md px-3 py-1 font-medium text-neutral-500">Monthly</button>
<button data-gran="weekly" class="gran-btn rounded-md px-3 py-1 font-medium text-neutral-500">Weekly</button>
</div>
</div>
<div id="fy-wrap" class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Financial Year</span>
<select id="fy-select" onchange="render()" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none">
<option value="2025">FY 202526</option>
<option value="2024">FY 202425</option>
<option value="2023">FY 202324</option>
</select>
</div>
<div id="fyscope-wrap" class="hidden items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Years</span>
<select id="fyscope-select" onchange="onFyScopeChange(this.value)" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none">
<option value="all">All years</option>
<option value="top2">Top 2</option>
<option value="last2">Last 2</option>
<option value="custom">Custom…</option>
</select>
</div>
<div id="month-wrap" class="hidden items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Month</span>
<select id="month-select" onchange="render()" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"></select>
</div>
<div id="scope-wrap" class="flex items-center gap-2">
<span id="scope-label" class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Show</span>
<select id="scope-select" onchange="onScopeChange(this.value)" class="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none">
<option value="top5">Top 5</option>
<option value="top10">Top 10</option>
<option value="last5">Bottom 5</option>
<option value="all">All</option>
<option value="custom">Custom…</option>
</select>
</div>
<div class="ml-auto flex items-center gap-2">
<button id="addgraph-btn" onclick="goCustom()" class="hidden items-center gap-1.5 rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span id="addgraph-label">Add to graph</span>
</button>
<button class="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
</div>
</div>
</div>
<!-- ════════════ INDEX VIEW ════════════ -->
<section id="view-index" class="px-8 py-6">
<button id="idx-back" onclick="idxBack()" class="mb-4 hidden items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<span id="idx-back-label">Back</span>
</button>
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-3">
<h1 id="idx-title" class="text-2xl font-semibold text-neutral-900">Cost Centres</h1>
<span id="idx-badge" class="hidden rounded-full px-2.5 py-0.5 text-xs font-medium"></span>
</div>
<p id="idx-sub" class="mt-1 text-sm text-neutral-500"></p>
</div>
</div>
<!-- Custom (years) selection panel -->
<div id="scope-panel" class="mb-6 hidden rounded-lg border border-dashed border-neutral-300 bg-white p-4"></div>
<div id="kpi-strip" class="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4"></div>
<div class="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div class="mb-4 flex items-center justify-between">
<p id="cmp-title" class="text-sm font-semibold text-neutral-900">Spend by cost centre</p>
<span id="cmp-period" class="text-xs text-neutral-400">FY 202526</span>
</div>
<div style="height:340px"><canvas id="chart-compare"></canvas></div>
</div>
<!-- Cost-centre flat table -->
<div id="cc-table-wrap" class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<table class="w-full text-sm">
<thead class="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th class="px-5 py-3" id="th-name">Cost Centre</th>
<th class="px-5 py-3">Trend</th>
<th class="px-5 py-3 text-right">Total Spend</th>
<th class="px-5 py-3 text-right" id="th-pct">% of Total</th>
<th class="px-5 py-3 text-right" id="th-count">POs</th>
<th class="px-5 py-3"></th>
</tr>
</thead>
<tbody id="index-rows" class="divide-y divide-neutral-100"></tbody>
</table>
</div>
<!-- Accounting-code accordion tree -->
<div id="ac-accordion" class="hidden"></div>
</section>
<!-- ════════════ DETAIL VIEW ════════════ -->
<section id="view-detail" class="hidden px-8 py-6">
<button onclick="backFromDetail()" class="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<span id="back-label">Back</span>
</button>
<div class="mb-6 flex items-start justify-between">
<div>
<div class="flex items-center gap-3">
<h1 id="det-title" class="text-2xl font-semibold text-neutral-900"></h1>
<span id="det-badge" class="rounded-full px-2.5 py-0.5 text-xs font-medium"></span>
</div>
<p id="det-sub" class="mt-1 text-sm text-neutral-500"></p>
</div>
</div>
<div id="det-kpis" class="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4"></div>
<div class="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div class="mb-4 flex items-center justify-between">
<p id="trend-title" class="text-sm font-semibold text-neutral-900">Spend trend</p>
<span id="trend-gran" class="text-xs text-neutral-400"></span>
</div>
<div style="height:300px"><canvas id="chart-trend"></canvas></div>
</div>
<div class="rounded-lg border border-neutral-200 bg-white p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<p id="topn-title" class="text-sm font-semibold text-neutral-900">Top accounting codes</p>
<div id="topn-controls" class="flex flex-wrap items-center gap-2"></div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-5">
<div class="lg:col-span-3" style="height:300px"><canvas id="chart-topn"></canvas></div>
<div class="lg:col-span-2">
<table class="w-full text-sm">
<thead class="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr><th class="py-2" id="topn-th"></th><th class="py-2 text-right">Spend</th><th class="py-2 text-right">%</th></tr>
</thead>
<tbody id="topn-rows" class="divide-y divide-neutral-100"></tbody>
</table>
</div>
</div>
</div>
</section>
</main>
</div>
<script>
/* ════════════════════════════════════════════════════════════════
MOCK DATA
════════════════════════════════════════════════════════════════ */
const COST_CENTRES = [
{ id:"v1", name:"MV Pelagia Star", type:"Vessel" },
{ id:"v2", name:"MV Ocean Dawn", type:"Vessel" },
{ id:"v3", name:"MV Coral Trident", type:"Vessel" },
{ id:"v4", name:"MV Northern Light", type:"Vessel" },
{ id:"s1", name:"Mumbai HQ", type:"Site" },
{ id:"s2", name:"Kharghar Office", type:"Site" },
{ id:"s3", name:"Chennai Depot", type:"Site" },
];
// 3-tier chart of accounts (Account.parentId hierarchy: Heading → Sub-heading → Leaf)
const ACCOUNTS = [
// ── Headings (tier 1) ──
{ id:"5000", code:"5000", name:"Operating Expenses", parent:null, tier:"Heading" },
{ id:"6000", code:"6000", name:"Administrative Expenses", parent:null, tier:"Heading" },
// ── Sub-headings (tier 2) ──
{ id:"5100", code:"5100", name:"Vessel Running Costs", parent:"5000", tier:"Sub-heading" },
{ id:"5200", code:"5200", name:"Crew Costs", parent:"5000", tier:"Sub-heading" },
{ id:"5300", code:"5300", name:"Port & Logistics", parent:"5000", tier:"Sub-heading" },
{ id:"6100", code:"6100", name:"Office & IT", parent:"6000", tier:"Sub-heading" },
{ id:"6200", code:"6200", name:"Professional Fees", parent:"6000", tier:"Sub-heading" },
// ── Leaves (tier 3) — these carry actual PO spend ──
{ id:"5110", code:"5110", name:"Fuel & Lubricants", parent:"5100", tier:"Leaf" },
{ id:"5120", code:"5120", name:"Spares & Repairs", parent:"5100", tier:"Leaf" },
{ id:"5130", code:"5130", name:"Lube Oils", parent:"5100", tier:"Leaf" },
{ id:"5210", code:"5210", name:"Provisions & Stores", parent:"5200", tier:"Leaf" },
{ id:"5220", code:"5220", name:"Crew Welfare", parent:"5200", tier:"Leaf" },
{ id:"5230", code:"5230", name:"Safety Equipment", parent:"5200", tier:"Leaf" },
{ id:"5310", code:"5310", name:"Port & Canal Dues", parent:"5300", tier:"Leaf" },
{ id:"5320", code:"5320", name:"Freight & Cartage", parent:"5300", tier:"Leaf" },
{ id:"6110", code:"6110", name:"IT & Communications", parent:"6100", tier:"Leaf" },
{ id:"6120", code:"6120", name:"Office Supplies", parent:"6100", tier:"Leaf" },
{ id:"6210", code:"6210", name:"Audit & Legal", parent:"6200", tier:"Leaf" },
{ id:"6220", code:"6220", name:"Consultancy", parent:"6200", tier:"Leaf" },
];
const ACC_BY_ID = Object.fromEntries(ACCOUNTS.map(a=>[a.id,a]));
const acc = (id)=> ACC_BY_ID[id];
const childrenOf = (id)=> ACCOUNTS.filter(a=>a.parent===id); // id=null → headings
const isLeaf = (id)=> childrenOf(id).length===0;
function leavesUnder(id){ const k=childrenOf(id); return k.length? k.flatMap(c=>leavesUnder(c.id)) : [id]; }
function pathTo(id){ if(!id) return []; return [...pathTo(acc(id).parent), id]; }
const LEAF_IDS = ACCOUNTS.filter(a=>a.tier==="Leaf").map(a=>a.id);
const FYS = ["2023","2024","2025"];
const FY_LABEL = { "2023":"FY 202324","2024":"FY 202425","2025":"FY 202526" };
const MONTHS = ["Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec","Jan","Feb","Mar"];
function seeded(a){ return function(){ a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; }
const rnd = seeded(42);
const ccW={}; COST_CENTRES.forEach(c=> ccW[c.id]=0.5+rnd()*1.6);
const acW={}; LEAF_IDS.forEach(l=> acW[l]=0.4+rnd()*1.8);
// DATA[ccId][leafId][fy] = [12 months]
const DATA={};
COST_CENTRES.forEach(cc=>{ DATA[cc.id]={};
LEAF_IDS.forEach(lf=>{ DATA[cc.id][lf]={};
FYS.forEach((fy,fi)=>{ const growth=1+fi*0.18; const arr=[];
for(let m=0;m<12;m++){ const seasonal=1+0.35*Math.sin((m/12)*Math.PI*2+ccW[cc.id]);
let v=6*ccW[cc.id]*acW[lf]*growth*seasonal*(0.7+rnd()*0.6);
if(rnd()<0.18) v*=0.05; arr.push(Math.round(v*10)/10); }
DATA[cc.id][lf][fy]=arr; }); }); });
/* ── helpers ──────────────────────────────────────────────── */
const r1 = n=>Math.round(n*10)/10;
const fmt = n => "₹"+n.toLocaleString("en-IN",{maximumFractionDigits:1})+"L";
const fmtShort = n => n>=100 ? "₹"+(n/100).toFixed(1)+"Cr" : "₹"+n.toFixed(0)+"L";
const shorten=(s,n=14)=> s.length>n? s.slice(0,n-1)+"…":s;
const WEEKS=52;
// full-FY trend series (used by the detail trend chart — unaffected by month/week focus)
function ccTotalFY(cc,fy){ let s=0; LEAF_IDS.forEach(lf=>DATA[cc][lf][fy].forEach(v=>s+=v)); return r1(s); }
function ccMonthly(cc,fy){ return MONTHS.map((_,m)=>{ let s=0; LEAF_IDS.forEach(lf=>s+=DATA[cc][lf][fy][m]); return r1(s); }); }
function ccYearly(cc){ return FYS.map(fy=>ccTotalFY(cc,fy)); }
function ccWeeksOfMonth(cc,fy){ return weeksOfMonth(ccMonthly(cc,fy)[state.month]); }
function nodeTotalFY(id,fy){ let s=0; const lv=leavesUnder(id); COST_CENTRES.forEach(cc=>lv.forEach(lf=>DATA[cc.id][lf][fy].forEach(v=>s+=v))); return r1(s); }
function nodeMonthly(id,fy){ const lv=leavesUnder(id); return MONTHS.map((_,m)=>{ let s=0; lv.forEach(lf=>COST_CENTRES.forEach(cc=>s+=DATA[cc.id][lf][fy][m])); return r1(s); }); }
function nodeYearly(id){ return FYS.map(fy=>nodeTotalFY(id,fy)); }
function nodeWeeksOfMonth(id,fy){ return weeksOfMonth(nodeMonthly(id,fy)[state.month]); }
// Weekly granularity = the weeks of ONE month. Split a month's value into ~4 weekly buckets
// (deterministic noise, normalised so the weeks sum back to the month total).
const WEEKS_PER_MONTH=4;
function weeksOfMonth(monthVal){ const w=seeded(7); const p=[]; let t=0;
for(let k=0;k<WEEKS_PER_MONTH;k++){ const x=0.6+w()*0.8; p.push(x); t+=x; }
return p.map(x=>r1(monthVal*x/t)); }
function weekOfMonthLabels(){ return Array.from({length:WEEKS_PER_MONTH},(_,k)=>`${MONTHS[state.month]} W${k+1}`); }
/* ── focus period ── Monthly → whole FY (all 12 months shown) · Weekly → the selected month */
function fyMonthYear(fy,mIdx){ return (+fy) + (mIdx>=9 ? 1 : 0); } // AprDec → start yr, JanMar → +1
function monthLabel(fy,mIdx){ return `${MONTHS[mIdx]} '${String(fyMonthYear(fy,mIdx)).slice(2)}`; }
function periodLabel(){ return state.gran==="weekly" ? `${monthLabel(state.fy,state.month)} · ${FY_LABEL[state.fy]}` : FY_LABEL[state.fy]; }
// per-leaf spend for the snapshot scope (KPIs / top-N / accordion totals)
function leafSpend(lf,cc,fy){ return state.gran==="weekly" ? DATA[cc][lf][fy][state.month] : DATA[cc][lf][fy].reduce((a,b)=>a+b,0); }
function ccPeriod(cc,fy){ let s=0; LEAF_IDS.forEach(lf=>s+=leafSpend(lf,cc,fy)); return r1(s); }
function nodePeriodCC(id,cc,fy){ let s=0; leavesUnder(id).forEach(lf=>s+=leafSpend(lf,cc,fy)); return s; }
function nodePeriod(id,fy){ let s=0; COST_CENTRES.forEach(cc=>s+=nodePeriodCC(id,cc.id,fy)); return r1(s); }
// breakdowns (period-aware via leafSpend)
function topCCsForNode(id,fy){ return COST_CENTRES.map(cc=>({label:cc.name, value:r1(nodePeriodCC(id,cc.id,fy))})).sort((a,b)=>b.value-a.value); }
function childBreakdown(id,fy){ return childrenOf(id).map(c=>({label:`${c.code} · ${c.name}`, value:nodePeriod(c.id,fy)})).sort((a,b)=>b.value-a.value); }
function topAccountsForCC(cc,fy,tier){ return ACCOUNTS.filter(a=>a.tier===tier).map(a=>({label:`${a.code} · ${a.name}`, value:r1(nodePeriodCC(a.id,cc,fy))})).sort((a,b)=>b.value-a.value); }
/* ════════════════════════════════════════════════════════════════
STATE + RENDER
════════════════════════════════════════════════════════════════ */
const COLORS=["#2563eb","#16a34a","#9333ea","#ea580c","#0891b2","#dc2626","#ca8a04","#4f46e5"];
let state={ section:"cc", gran:"monthly", fy:"2025", view:"index", itemId:null,
month:6, topn:5, ccTier:"Leaf", breakMode:"children",
acPath:[], // accounting drill-down path (ancestor ids)
sel:new Set(), // selected ids for "Add to graph" → custom view
scopeMode:"top5", // entity scope: top5/top10/last5/all
fyScopeMode:"all", fyCustom:new Set(FYS) }; // financial-year scope (yearly mode)
let charts={};
const destroy=id=>{ if(charts[id]){ charts[id].destroy(); delete charts[id]; } };
function tierBadge(tier){
const m={ "Heading":"bg-primary-50 text-primary-700","Sub-heading":"bg-violet-50 text-violet-700","Leaf":"bg-neutral-100 text-neutral-600",
"Vessel":"bg-primary-50 text-primary-700","Site":"bg-emerald-50 text-emerald-700" };
return `<span class="rounded-full px-2 py-0.5 text-xs font-medium ${m[tier]||'bg-neutral-100 text-neutral-600'}">${tier}</span>`;
}
function seg(name,opts,cur){
return `<div class="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-xs">`+
opts.map(o=>`<button data-seg="${name}" data-val="${o.val}" class="seg-btn rounded-md px-2.5 py-1 font-medium ${String(o.val)===String(cur)?'bg-primary-600 text-white':'text-neutral-500 hover:text-neutral-800'}">${o.label}</button>`).join('')+`</div>`;
}
/* ── scope: Top N / Last N for entities (current page) + financial years ── */
function fyGrand(fy){ let s=0; COST_CENTRES.forEach(cc=>LEAF_IDS.forEach(lf=>DATA[cc.id][lf][fy].forEach(v=>s+=v))); return s; }
function acGraphLabel(node){ return node.tier==="Leaf" ? node.code : shorten(node.name,18); } // names for heading/sub, codes for leaf
function scopeLabel(){ return {top5:"Top 5",top10:"Top 10",last5:"Bottom 5",all:"All"}[state.scopeMode]; }
// current accounting drill level: parent whose children we compare (null = top-level headings)
function currentParent(){ return state.acPath.length ? state.acPath[state.acPath.length-1] : null; }
function levelChildren(){ return childrenOf(currentParent()); }
// the entities compared on the page you're at right now
function scopedEntities(isCC){ const fy=state.fy;
if(state.view==="custom"){
return [...state.sel].map(id=> isCC ? COST_CENTRES.find(c=>c.id===id) : acc(id)).filter(Boolean)
.map(it=>({it, rank: isCC?ccPeriod(it.id,fy):nodePeriod(it.id,fy)})).sort((a,b)=>b.rank-a.rank);
}
let pool = isCC ? COST_CENTRES.map(c=>({it:c, rank:ccPeriod(c.id,fy)}))
: levelChildren().map(n=>({it:n, rank:nodePeriod(n.id,fy)}));
pool.sort((a,b)=>b.rank-a.rank);
if(state.scopeMode==="top5") return pool.slice(0,5);
if(state.scopeMode==="top10") return pool.slice(0,10);
if(state.scopeMode==="last5") return pool.slice(-5).reverse();
return pool;
}
function scopedFYs(){
if(state.fyScopeMode==="custom"){ const s=FYS.filter(f=>state.fyCustom.has(f)); return s.length?s:FYS; }
if(state.fyScopeMode==="top2") return [...FYS].sort((a,b)=>fyGrand(b)-fyGrand(a)).slice(0,2).sort();
if(state.fyScopeMode==="last2") return FYS.slice(-2);
return FYS;
}
function onScopeChange(v){ state.scopeMode=v; render(); }
function onFyScopeChange(v){ state.fyScopeMode=v;
if(v==="custom" && state.fyCustom.size===0) FYS.forEach(f=>state.fyCustom.add(f));
render(); }
function toggleCustomFy(fy){ state.fyCustom.has(fy)?state.fyCustom.delete(fy):state.fyCustom.add(fy); render(); }
/* ── drill-down navigation (accounting) + selection / add-to-graph ── */
function acDrill(id){ state.acPath.push(id); state.view="index"; render(); }
function acRowClick(id){ isLeaf(id) ? openItem(id) : acDrill(id); }
function idxBack(){
if(state.view==="custom") state.view="index";
else if(state.section==="ac" && state.acPath.length) state.acPath.pop();
render();
}
function toggleSel(id){ state.sel.has(id)?state.sel.delete(id):state.sel.add(id);
if(state.view==="custom" && state.sel.size===0) state.view="index"; render(); }
function goCustom(){ if(state.sel.size) { state.view="custom"; render(); } }
function chip(active,label,onclick){
return `<button onclick="${onclick}" class="rounded-full border px-2.5 py-1 text-xs font-medium ${active?'border-primary-600 bg-primary-50 text-primary-700':'border-neutral-200 bg-white text-neutral-500 hover:bg-neutral-50'}">${active?'✓ ':''}${label}</button>`;
}
function renderScopePanel(){
const el=document.getElementById("scope-panel");
const fyCustom=state.view==="index"&&state.gran==="yearly"&&state.fyScopeMode==="custom";
if(!fyCustom){ el.classList.add("hidden"); return; }
el.classList.remove("hidden");
el.innerHTML = `<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">Pick financial years</p>
<div class="flex flex-wrap gap-2">`+FYS.map(f=>chip(state.fyCustom.has(f),FY_LABEL[f],`toggleCustomFy('${f}')`)).join("")+`</div>`;
}
function setScopeUI(){
document.getElementById("scope-select").value=state.scopeMode;
document.getElementById("fyscope-select").value=state.fyScopeMode;
// Add-to-graph button reflects current selection
const btn=document.getElementById("addgraph-btn"), custom=state.view==="custom";
btn.style.display = (state.sel.size>0 && !custom) ? "inline-flex" : "none";
document.getElementById("addgraph-label").textContent = `Add to graph (${state.sel.size})`;
}
function setGranUI(){
document.querySelectorAll(".gran-btn").forEach(b=>{ const on=b.dataset.gran===state.gran;
b.className="gran-btn rounded-md px-3 py-1 font-medium "+(on?"bg-primary-600 text-white shadow-sm":"text-neutral-500 hover:text-neutral-800"); });
const yearly=state.gran==="yearly", weekly=state.gran==="weekly", scoped=state.view!=="detail"&&state.view!=="custom";
document.getElementById("fy-wrap").style.display = yearly ? "none" : "flex";
document.getElementById("fyscope-wrap").style.display = yearly ? "flex" : "none";
document.getElementById("month-wrap").style.display = weekly ? "flex" : "none";
document.getElementById("scope-wrap").style.display = scoped ? "flex" : "none"; // Top/Last applies to a browsable page only
populateMonths();
}
function populateMonths(){
const sel=document.getElementById("month-select");
sel.innerHTML = MONTHS.map((m,i)=>`<option value="${i}">${monthLabel(state.fy,i)}</option>`).join("");
sel.value=String(state.month);
}
function setNavUI(){
document.querySelectorAll(".nav-link").forEach(n=>n.classList.remove("nav-active"));
document.getElementById(state.section==="cc"?"nav-cc":"nav-ac").classList.add("nav-active");
}
document.querySelectorAll(".gran-btn").forEach(b=> b.onclick=()=>{ state.gran=b.dataset.gran; render(); });
/* navigation */
function go(section){ state.section=section; state.view="index"; state.itemId=null; state.acPath=[]; state.sel.clear(); render(); }
function openItem(id){ state.view="detail"; state.itemId=id; state.topn=5; state.breakMode="children";
if(state.section==="ac") state.acPath=pathTo(acc(id).parent); // so Back returns to this code's level
render(); }
function backFromDetail(){ state.view="index"; state.itemId=null; render(); }
function render(){
state.fy=document.getElementById("fy-select").value;
const ms=document.getElementById("month-select");
if(state.gran==="weekly" && ms && ms.value!=="") state.month=+ms.value; // pick up a new month choice
setGranUI(); setScopeUI(); setNavUI();
const isCC=state.section==="cc";
document.getElementById("crumb-section").textContent=isCC?"Cost Centres":"Accounting Codes";
if(state.view==="detail"){ document.getElementById("scope-panel").classList.add("hidden"); renderDetail(isCC); }
else { renderScopePanel(); renderIndex(isCC); } // "index" or "custom"
}
/* ── INDEX (also serves drill levels + custom comparison) ────── */
function renderIndex(isCC){
document.getElementById("view-index").classList.remove("hidden");
document.getElementById("view-detail").classList.add("hidden");
document.getElementById("crumb-detail-wrap").classList.add("hidden");
document.getElementById("idx-badge").classList.add("hidden");
const fy=state.fy, yearly=state.gran==="yearly", weekly=state.gran==="weekly", custom=state.view==="custom";
const useTable = isCC && !custom;
document.getElementById("cc-table-wrap").classList.toggle("hidden", !useTable);
document.getElementById("ac-accordion").classList.toggle("hidden", useTable);
const parent = (!isCC && !custom) ? currentParent() : null;
const pNode = parent ? acc(parent) : null;
// ── Back button + title/sub ──
const backBtn=document.getElementById("idx-back");
if(custom){ backBtn.style.display="inline-flex"; document.getElementById("idx-back-label").textContent="Back to browse"; }
else if(!isCC && state.acPath.length){ backBtn.style.display="inline-flex";
document.getElementById("idx-back-label").textContent = state.acPath.length>1 ? `Back to ${acc(state.acPath[state.acPath.length-2]).name}` : "Back to Accounting Codes"; }
else backBtn.style.display="none";
// entities compared on this page (current level / custom set)
const ents=scopedEntities(isCC);
let rows=ents.map(e=>({ it:e.it, total: isCC?ccPeriod(e.it.id,fy):nodePeriod(e.it.id,fy), series: isCC?ccYearly(e.it.id):nodeYearly(e.it.id) }));
rows.sort((a,b)=>b.total-a.total);
const labelFn = isCC ? (r=>shorten(r.it.name,14)) : (r=>acGraphLabel(r.it));
const childTier = (!isCC && !custom) ? (levelChildren()[0]?.tier || "Heading") : null;
let title, sub;
if(custom){ title="Custom comparison"; sub=`Graphing ${rows.length} selected ${isCC?'cost centres':'accounting codes'} together. Use the ✕ to remove, or Back to add more.`; }
else if(isCC){ title="Cost Centres"; sub="Spend comparison across cost centres. Click a row for its report, or tick rows and Add to graph."; }
else if(!pNode){ title="Accounting Codes"; sub="Comparing top-level headings. Click a heading to drill into its sub-headings, or tick rows and Add to graph."; }
else { title=`${pNode.code} · ${pNode.name}`; sub=`Comparing the ${childTier.toLowerCase()}s of ${pNode.name}. Click a row to ${childTier==="Leaf"?"open its report":"drill deeper"}, or tick rows and Add to graph.`; }
document.getElementById("idx-title").textContent=title;
document.getElementById("idx-sub").textContent=sub;
if(useTable){ document.getElementById("th-name").textContent="Cost Centre"; document.getElementById("th-pct").textContent="% of Shown"; document.getElementById("th-count").textContent="POs"; }
const grand=rows.reduce((s,r)=>s+r.total,0);
const top=rows[0]||{it:{name:"—",code:""},total:0,series:[0,0,0]};
const curT=rows.reduce((s,r)=>s+(r.series[2]||0),0), prevT=rows.reduce((s,r)=>s+(r.series[1]||0),0);
const yoy=prevT?((curT-prevT)/prevT*100):0;
const scLabel = custom? `${rows.length} selected` : scopeLabel();
const kpiCount = custom? "Selected" : isCC? "Cost centres" : pNode? `${childTier}s` : "Headings";
document.getElementById("kpi-strip").innerHTML=[
kpi("Total spend", yearly? fmtShort(rows.reduce((s,r)=>s+scopedFYs().reduce((a,f)=>a+r.series[FYS.indexOf(f)],0),0)) : fmtShort(grand), yearly?scopedFYs().map(f=>FY_LABEL[f].replace("FY ","")).join(", "):periodLabel()),
kpi(kpiCount, String(rows.length), custom?"in this graph":scLabel+" shown"),
kpi("Highest spender", shorten(isCC?top.it.name:`${top.it.code} ${top.it.name}`,16), fmtShort(top.total)),
kpi("YoY change",(yoy>=0?"+":"")+yoy.toFixed(1)+"%","vs prior FY",yoy>=0),
].join("");
const what = isCC? "cost centre" : custom? "code" : (childTier||"heading").toLowerCase();
const base = yearly ? `Spend by ${what} — year over year`
: weekly ? `Weekly spend by ${what} — ${monthLabel(fy,state.month)}`
: `Monthly spend by ${what}`;
document.getElementById("cmp-title").textContent = `${base} · ${scLabel}`;
document.getElementById("cmp-period").textContent = yearly? scopedFYs().map(f=>FY_LABEL[f]).join(" · ") : periodLabel();
destroy("compare");
const ctx=document.getElementById("chart-compare");
if(yearly){
const fys=scopedFYs();
charts.compare=new Chart(ctx,{ type:"bar",
data:{ labels:rows.map(labelFn),
datasets:fys.map(f=>{ const i=FYS.indexOf(f); return { label:FY_LABEL[f], data:rows.map(r=>r.series[i]), backgroundColor:COLORS[i], borderRadius:3 }; }) },
options:barOpts(true) });
} else {
const labels = weekly? weekOfMonthLabels() : MONTHS;
const datasets = rows.map((r,i)=>{
const data = isCC ? (weekly? ccWeeksOfMonth(r.it.id,fy) : ccMonthly(r.it.id,fy))
: (weekly? nodeWeeksOfMonth(r.it.id,fy) : nodeMonthly(r.it.id,fy));
const c=COLORS[i%COLORS.length];
return { label: isCC? shorten(r.it.name,18) : acGraphLabel(r.it),
data, borderColor:c, backgroundColor:c, tension:0.35, borderWidth:2, pointRadius: weekly?3:2, pointHoverRadius:5 };
});
charts.compare=new Chart(ctx,{ type:"line", data:{labels,datasets}, options:lineMultiOpts() });
}
if(custom) renderSelList(rows, grand, isCC);
else if(isCC) renderCcTable(rows, grand, fy);
else renderAcList(rows, grand, fy);
requestAnimationFrame(drawSparks);
}
/* selection checkbox + generic metric cells */
function selBox(id){ const on=state.sel.has(id);
return `<button onclick="event.stopPropagation();toggleSel('${id}')" title="Select to graph" class="shrink-0 flex h-4 w-4 items-center justify-center rounded border ${on?'border-primary-600 bg-primary-600 text-white':'border-neutral-300 bg-white hover:border-primary-500'}">${on?'<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>':''}</button>`;
}
function metricCellsRaw(total, series, denom){ const pct=denom?total/denom*100:0;
return `<canvas class="spark shrink-0 hidden sm:block" data-series="${series.join(',')}" width="80" height="24"></canvas>
<span class="w-24 text-right font-medium tabular-nums text-sm text-neutral-900">${fmt(total)}</span>
<div class="hidden md:flex items-center gap-2 w-28 justify-end">
<div class="h-1.5 w-14 overflow-hidden rounded-full bg-neutral-200/70"><div class="h-full rounded-full bg-primary-600" style="width:${Math.min(pct,100)}%"></div></div>
<span class="w-9 text-right tabular-nums text-xs text-neutral-500">${pct.toFixed(0)}%</span></div>`;
}
/* accounting drill-level list (replaces the old accordion) */
function renderAcList(rows, grand, fy){
const html=rows.map(r=>{ const n=r.it, leaf=isLeaf(n.id);
const tail = leaf
? `<svg class="h-4 w-4 shrink-0 text-neutral-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><rect x="7" y="9" width="3" height="9"/><rect x="14" y="5" width="3" height="13"/></svg>`
: `<svg class="h-4 w-4 shrink-0 text-neutral-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>`;
return `<div class="group/row flex items-center gap-3 border-b border-neutral-100 bg-white px-4 py-3 last:border-0 hover:bg-primary-50/40">
${selBox(n.id)}
<div onclick="acRowClick('${n.id}')" class="flex flex-1 items-center gap-3 min-w-0 cursor-pointer">
<span class="font-mono text-xs text-neutral-500 w-14 shrink-0">${n.code}</span>
<span class="flex-1 truncate text-sm font-medium text-neutral-900 group-hover/row:text-primary-700">${n.name}</span>
${tierBadge(n.tier)}
${metricCellsRaw(nodePeriod(n.id,fy), nodeMonthly(n.id,fy), grand)}
${tail}
</div>
</div>`; }).join("");
document.getElementById("ac-accordion").innerHTML = `<div class="overflow-hidden rounded-lg border border-neutral-200">${html}</div>`;
}
/* custom comparison list (selected items, with remove) */
function renderSelList(rows, grand, isCC){
const html=rows.map(r=>{ const n=r.it; const fy=state.fy;
const total=isCC?ccPeriod(n.id,fy):nodePeriod(n.id,fy), series=isCC?ccMonthly(n.id,fy):nodeMonthly(n.id,fy);
const label=isCC? n.name : `${n.code} · ${n.name}`;
return `<div class="flex items-center gap-3 border-b border-neutral-100 bg-white px-4 py-3 last:border-0">
<span class="flex-1 truncate text-sm font-medium text-neutral-900">${label}</span>
${isCC?`<span class="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600">${n.type}</span>`:tierBadge(n.tier)}
${metricCellsRaw(total, series, grand)}
<button onclick="toggleSel('${n.id}')" title="Remove" class="shrink-0 text-neutral-400 hover:text-red-600"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>`; }).join("");
document.getElementById("ac-accordion").innerHTML = `<div class="overflow-hidden rounded-lg border border-neutral-200">${html}</div>`;
}
/* ── Cost-centre flat table ──────────────────────────────────── */
function renderCcTable(rows, grand, fy){
document.getElementById("index-rows").innerHTML=rows.map(r=>{
const pct=grand?(r.total/grand*100):0;
const poCount=4+Math.round(r.total/9);
return `<tr onclick="openItem('${r.it.id}')" class="cursor-pointer hover:bg-primary-50/40">
<td class="px-5 py-3"><div class="flex items-center gap-3">${selBox(r.it.id)}<div><div class="font-medium text-neutral-900">${r.it.name}</div><div class="text-xs text-neutral-400">${r.it.type}</div></div></div></td>
<td class="px-5 py-3"><canvas class="spark" data-series="${r.series.join(',')}" width="90" height="28"></canvas></td>
<td class="px-5 py-3 text-right font-medium tabular-nums">${fmt(r.total)}</td>
<td class="px-5 py-3 text-right"><div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-neutral-100"><div class="h-full rounded-full bg-primary-600" style="width:${Math.min(pct,100)}%"></div></div>
<span class="tabular-nums text-neutral-500 w-10 text-right">${pct.toFixed(0)}%</span></div></td>
<td class="px-5 py-3 text-right tabular-nums text-neutral-500">${poCount}</td>
<td class="px-5 py-3 text-right"><svg class="inline h-4 w-4 text-neutral-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></td>
</tr>`;
}).join("");
}
/* ── Accounting-code accordion (mirrors the admin tree) ──────────
• Click a row / its title → open that account's report graph
• Click the ▸ caret beside the title → expand its sub-accounts inline
─────────────────────────────────────────────────────────────────*/
function byTotal(fy){ return (a,b)=>nodePeriod(b.id,fy)-nodePeriod(a.id,fy); }
function caretEl(node){
if(isLeaf(node.id)) return `<span class="w-4 shrink-0"></span>`;
const open=state.expanded.has(node.id);
return `<button onclick="event.stopPropagation();toggleNode('${node.id}')" aria-label="expand"
class="w-5 shrink-0 text-center text-neutral-500 hover:text-neutral-900">${open?'▾':'▸'}</button>`;
}
function metricCells(node, fy, denom){
const total=nodePeriod(node.id,fy), series=nodeMonthly(node.id,fy);
const pct=denom? total/denom*100 : 0;
return `<canvas class="spark shrink-0 hidden sm:block" data-series="${series.join(',')}" width="80" height="24"></canvas>
<span class="w-24 text-right font-medium tabular-nums text-sm text-neutral-900">${fmt(total)}</span>
<div class="hidden md:flex items-center gap-2 w-28 justify-end">
<div class="h-1.5 w-14 overflow-hidden rounded-full bg-neutral-200/70"><div class="h-full rounded-full bg-primary-600" style="width:${Math.min(pct,100)}%"></div></div>
<span class="w-9 text-right tabular-nums text-xs text-neutral-500">${pct.toFixed(0)}%</span>
</div>`;
}
function titleSpan(node, cls){
return `<span class="${cls} cursor-pointer group-hover/row:text-primary-700 group-hover/row:underline">${node.name}</span>`;
}
function renderAcAccordion(fy){
const grand=childrenOf(null).reduce((s,h)=>s+nodePeriod(h.id,fy),0)||1;
document.getElementById("ac-accordion").innerHTML =
childrenOf(null).slice().sort(byTotal(fy)).map(h=>acHeading(h,fy,grand)).join("");
}
function acHeading(node, fy, grand){
const open=state.expanded.has(node.id);
const kids=childrenOf(node.id).slice().sort(byTotal(fy));
const parentTotal=nodePeriod(node.id,fy);
return `<div class="mb-2 overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div onclick="openItem('${node.id}')" class="group/row flex items-center gap-3 bg-neutral-100 px-4 py-3 cursor-pointer hover:bg-neutral-200/60">
${caretEl(node)}
<span class="font-mono text-xs font-semibold text-neutral-600 w-14 shrink-0">${node.code}</span>
${titleSpan(node,'flex-1 text-sm font-semibold uppercase tracking-wide text-neutral-900')}
<span class="hidden lg:inline text-xs text-neutral-400">${kids.length} sub-headings</span>
${metricCells(node,fy,grand)}
</div>
${open? `<div>${kids.map(s=>acSub(s,fy,parentTotal)).join("")}</div>`:""}
</div>`;
}
function acSub(node, fy, parentTotal){
const open=state.expanded.has(node.id);
const kids=childrenOf(node.id).slice().sort(byTotal(fy));
const subTotal=nodePeriod(node.id,fy);
return `<div class="border-t border-neutral-100">
<div onclick="openItem('${node.id}')" class="group/row flex items-center gap-3 bg-neutral-50 px-4 py-2.5 pl-9 cursor-pointer hover:bg-neutral-100/70">
${caretEl(node)}
<span class="font-mono text-xs text-neutral-500 w-14 shrink-0">${node.code}</span>
${titleSpan(node,'flex-1 text-sm font-medium text-neutral-700')}
<span class="hidden lg:inline text-xs text-neutral-400">${kids.length} codes</span>
${metricCells(node,fy,parentTotal)}
</div>
${open? `<div>${kids.map(l=>acLeaf(l,fy,subTotal)).join("")}</div>`:""}
</div>`;
}
function acLeaf(node, fy, parentTotal){
return `<div onclick="openItem('${node.id}')" class="group/row flex items-center gap-3 border-t border-neutral-100 px-4 py-2.5 pl-16 cursor-pointer hover:bg-neutral-50">
<span class="w-4 shrink-0"></span>
<span class="font-mono text-xs text-neutral-400 w-14 shrink-0">${node.code}</span>
${titleSpan(node,'flex-1 text-sm text-neutral-800')}
${metricCells(node,fy,parentTotal)}
</div>`;
}
function toggleNode(id){ state.expanded.has(id)? state.expanded.delete(id) : state.expanded.add(id); render(); }
/* ── DETAIL ────────────────────────────────────────────────── */
function renderDetail(isCC){
document.getElementById("view-index").classList.add("hidden");
document.getElementById("view-detail").classList.remove("hidden");
const fy=state.fy;
let node,name,badge,id;
if(isCC){ node=COST_CENTRES.find(x=>x.id===state.itemId); id=node.id; name=node.name; badge=node.type;
document.getElementById("back-label").textContent="Back to Cost Centres"; }
else { node=acc(state.itemId); id=node.id; name=`${node.code} · ${node.name}`; badge=node.tier;
document.getElementById("back-label").textContent="Back to Accounting Codes"; }
document.getElementById("crumb-detail-wrap").classList.remove("hidden");
document.getElementById("crumb-detail-wrap").classList.add("flex");
document.getElementById("crumb-detail").textContent= isCC? node.name : node.code;
document.getElementById("det-title").textContent=name;
const db=document.getElementById("det-badge");
db.className="rounded-full px-2.5 py-0.5 text-xs font-medium "+
(badge==="Heading"?"bg-primary-50 text-primary-700":badge==="Sub-heading"?"bg-violet-50 text-violet-700":badge==="Vessel"?"bg-primary-50 text-primary-700":badge==="Site"?"bg-emerald-50 text-emerald-700":"bg-neutral-100 text-neutral-600");
db.textContent=badge;
document.getElementById("det-sub").textContent= state.gran==="yearly"
? "Year-over-year spend across all financial years"
: (isCC? `Spend detail · ${periodLabel()}` : `Aggregates all spend under this ${badge.toLowerCase()} · ${periodLabel()}`);
// trend
let labels,series,gl;
if(state.gran==="yearly"){ const fys=scopedFYs(); const yr=isCC?ccYearly(id):nodeYearly(id); labels=fys.map(f=>FY_LABEL[f]); series=fys.map(f=>yr[FYS.indexOf(f)]); gl="Per financial year"; }
else if(state.gran==="weekly"){ labels=weekOfMonthLabels(); series=isCC?ccWeeksOfMonth(id,fy):nodeWeeksOfMonth(id,fy); gl=`Weekly · ${monthLabel(fy,state.month)}`; }
else { labels=MONTHS; series=isCC?ccMonthly(id,fy):nodeMonthly(id,fy); gl=`Monthly · ${FY_LABEL[fy]}`; }
const total=r1(series.reduce((a,b)=>a+b,0)), avg=total/series.length, peak=series.indexOf(Math.max(...series));
const yr=isCC?ccYearly(id):nodeYearly(id); const yoy=yr[1]?((yr[2]-yr[1])/yr[1]*100):0;
document.getElementById("det-kpis").innerHTML=[
kpi(state.gran==="yearly"?"Total (all yrs)":"Total spend", fmtShort(total), state.gran==="yearly"?"3 FYs":periodLabel()),
kpi("Avg / "+(state.gran==="yearly"?"year":state.gran==="weekly"?"week":"month"), fmtShort(avg),""),
kpi("Peak "+(state.gran==="weekly"?"week":state.gran==="yearly"?"year":"month"), labels[peak], fmtShort(series[peak])),
kpi("YoY change",(yoy>=0?"+":"")+yoy.toFixed(1)+"%","vs prior FY",yoy>=0),
].join("");
document.getElementById("trend-gran").textContent=gl;
destroy("trend");
const tctx=document.getElementById("chart-trend");
if(state.gran==="yearly"){
charts.trend=new Chart(tctx,{ type:"bar", data:{ labels, datasets:[{ data:series, backgroundColor:"#2563eb", borderRadius:4 }] }, options:lineBarOpts() });
} else {
charts.trend=new Chart(tctx,{ type:"line", data:{ labels, datasets:[{ data:series, borderColor:"#2563eb", backgroundColor:"rgba(37,99,235,0.08)", fill:true, tension:0.35, pointRadius:3, borderWidth:2 }] }, options:lineBarOpts() });
}
// ── breakdown + contextual controls ──
let breakdown, thLabel, title, controls;
if(isCC){
title="Top accounting codes"; thLabel=state.ccTier;
breakdown=topAccountsForCC(id,fy,state.ccTier);
controls = `<span class="text-xs text-neutral-400">Tier</span>`
+ seg("cctier",[{val:"Heading",label:"Heading"},{val:"Sub-heading",label:"Sub-heading"},{val:"Leaf",label:"Leaf"}], state.ccTier)
+ seg("topn",[{val:5,label:"Top 5"},{val:10,label:"Top 10"},{val:99,label:"All"}], state.topn);
} else {
const leaf=isLeaf(id);
if(leaf || state.breakMode==="cc"){ title="Top cost centres"; thLabel="Cost centre"; breakdown=topCCsForNode(id,fy); }
else { const kids=childrenOf(id); title="Composition by sub-account"; thLabel=kids[0].tier; breakdown=childBreakdown(id,fy); }
const modeSeg = leaf? "" :
`<span class="text-xs text-neutral-400">Break down by</span>`+seg("breakmode",[{val:"children",label:childrenOf(id)[0].tier+"s"},{val:"cc",label:"Cost centres"}], state.breakMode);
controls = modeSeg + seg("topn",[{val:5,label:"Top 5"},{val:10,label:"Top 10"},{val:99,label:"All"}], state.topn);
}
document.getElementById("topn-title").textContent=title;
document.getElementById("topn-th").textContent=thLabel;
const cc=document.getElementById("topn-controls"); cc.innerHTML=controls;
cc.querySelectorAll(".seg-btn").forEach(b=> b.onclick=()=>handleSeg(b.dataset.seg,b.dataset.val));
const totB=breakdown.reduce((s,b)=>s+b.value,0)||1;
const shown=breakdown.slice(0,state.topn);
destroy("topn");
charts.topn=new Chart(document.getElementById("chart-topn"),{ type:"bar",
data:{ labels:shown.map(s=>shorten(s.label,22)), datasets:[{ data:shown.map(s=>s.value), backgroundColor:shown.map((_,i)=>COLORS[i%COLORS.length]), borderRadius:4 }] },
options:barOpts(false,true) });
document.getElementById("topn-rows").innerHTML=shown.map((b,i)=>`
<tr><td class="py-2"><span class="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style="background:${COLORS[i%COLORS.length]}"></span>${b.label}</td>
<td class="py-2 text-right tabular-nums font-medium">${fmt(b.value)}</td>
<td class="py-2 text-right tabular-nums text-neutral-500">${(b.value/totB*100).toFixed(0)}%</td></tr>`).join("");
}
function handleSeg(name,val){
if(name==="topn") state.topn=+val;
else if(name==="cctier") state.ccTier=val;
else if(name==="breakmode") state.breakMode=val;
render();
}
/* ── chart options & helpers ───────────────────────────────── */
function barOpts(grouped,horizontal){
const money=v=>"₹"+v+"L";
const valueTicks={ font:{size:11}, color:"#737373", callback:money };
const catTicks={ font:{size:11}, color:"#737373", maxRotation:0, autoSkip:true };
return { indexAxis:horizontal?'y':'x', responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:!!grouped, position:'bottom', labels:{ usePointStyle:true, boxWidth:8, font:{size:11} } },
tooltip:{ callbacks:{ label:c=>` ${fmt(c.parsed[horizontal?'x':'y'])}` } } },
scales:{ x:{ grid:{ display:!!horizontal, color:"#f0f0f0" }, ticks:horizontal?valueTicks:catTicks },
y:{ grid:{ display:!horizontal, color:"#f0f0f0" }, ticks:horizontal?catTicks:valueTicks } } };
}
function lineBarOpts(){
return { responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label:c=>" "+fmt(c.parsed.y) } } },
scales:{ x:{ grid:{ display:false }, ticks:{ font:{size:10}, color:"#737373", maxRotation:0, autoSkip:true, maxTicksLimit:13 } },
y:{ grid:{ color:"#f0f0f0" }, ticks:{ font:{size:11}, color:"#737373", callback:v=>"₹"+v+"L" } } } };
}
// multi-line comparison (one line per cost centre / accounting code)
function lineMultiOpts(){
return { responsive:true, maintainAspectRatio:false, interaction:{ mode:'index', intersect:false },
plugins:{ legend:{ display:true, position:'bottom', labels:{ usePointStyle:true, boxWidth:8, font:{size:11}, padding:12 } },
tooltip:{ callbacks:{ label:c=>` ${c.dataset.label}: ${fmt(c.parsed.y)}` } } },
scales:{ x:{ grid:{ display:false }, ticks:{ font:{size:10}, color:"#737373", maxRotation:0, autoSkip:true, maxTicksLimit:13 } },
y:{ grid:{ color:"#f0f0f0" }, ticks:{ font:{size:11}, color:"#737373", callback:v=>"₹"+v+"L" } } } };
}
function kpi(label,value,sub,positive){
const c= positive===undefined?"text-neutral-400":(positive?"text-green-600":"text-red-600");
return `<div class="rounded-lg border border-neutral-200 bg-white p-4">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">${label}</p>
<p class="mt-1.5 text-xl font-semibold text-neutral-900">${value}</p>
<p class="mt-0.5 text-xs ${c}">${sub||"&nbsp;"}</p></div>`;
}
function drawSparks(){
document.querySelectorAll("canvas.spark").forEach(cv=>{
const data=cv.dataset.series.split(",").map(Number), ctx=cv.getContext("2d");
ctx.clearRect(0,0,cv.width,cv.height);
const max=Math.max(...data),min=Math.min(...data),w=cv.width,h=cv.height,pad=3;
ctx.beginPath(); ctx.strokeStyle="#2563eb"; ctx.lineWidth=1.5;
data.forEach((v,i)=>{ const x=pad+(i/(data.length-1))*(w-2*pad); const y=h-pad-((v-min)/((max-min)||1))*(h-2*pad); i?ctx.lineTo(x,y):ctx.moveTo(x,y); });
ctx.stroke();
const lx=w-pad, ly=h-pad-((data[data.length-1]-min)/((max-min)||1))*(h-2*pad);
ctx.beginPath(); ctx.fillStyle="#2563eb"; ctx.arc(lx,ly,2,0,7); ctx.fill();
});
}
render();
</script>
</body>
</html>