diff --git a/App/CLAUDE.md b/App/CLAUDE.md index af0739a..6037430 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -49,17 +49,20 @@ Internal purchase order management system for a maritime company. Full-stack Nex ``` DRAFT → SUBMITTED → MGR_REVIEW → MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED - ↓↑ - EDITS_REQUESTED / REJECTED / VENDOR_ID_PENDING + ↓↑ ↕ ↕ + EDITS_REQUESTED / REJECTED PARTIALLY_PAID PARTIALLY_CLOSED + / VENDOR_ID_PENDING ``` -Every status change is validated against the state machine and recorded as a `POAction` row (audit trail). +Partial payments (`PARTIALLY_PAID`) and partial receipts (`PARTIALLY_CLOSED`) loop until the full amount/quantity is settled. Imported POs are created directly in `CLOSED`. Every status change is validated against the state machine and recorded as a `POAction` row (audit trail). ### Role-Based Permissions -`lib/permissions.ts` defines `hasPermission(role, permission)` and `requirePermission(role, permission)`. Roles: `TECHNICAL`, `MANNING`, `ACCOUNTS`, `MANAGER`, `SUPERUSER`, `AUDITOR`, `ADMIN`. +`lib/permissions.ts` defines `hasPermission(role, permission)` and `requirePermission(role, permission)`. Roles: `TECHNICAL`, `MANNING`, `ACCOUNTS`, `MANAGER`, `SUPERUSER`, `AUDITOR`, `ADMIN`. Permissions include (non-exhaustive): `create_po`, `approve_po`, `process_payment`, `confirm_receipt`, `create_vendor`, `manage_vendors`, `manage_products`, `manage_sites`, `manage_vessels_accounts`, `manage_users`. `create_vendor` is held by submitters too; `manage_*` by Manager/Admin. -**Pattern:** Server Actions call `requirePermission()` at the top before any DB write. +**Pattern:** Server Actions call `requirePermission()` (or `hasPermission()`) at the top before any DB write. + +**Auth:** NextAuth v5 with a Microsoft Entra SSO provider **and** a credentials provider. SSO-only users have no `passwordHash` (it is nullable) — the profile page lets them optionally set one, and is reachable by every role. Only approvers (`approve_po`) can upload a signature. ### Key Directories @@ -74,15 +77,46 @@ Every status change is validated against the state machine and recorded as a `PO ### Cost Centre Model -A PO's "cost centre" is either a **Vessel** or a **Site**. `PurchaseOrder` has both `vesselId String?` (nullable) and `siteId String?` — exactly one is set. +A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId` is **required**. POs no longer reference a Site as a cost centre — that earlier dual Vessel-or-Site design was removed. -**Form encoding:** All PO creation/edit forms use a `costCentreRef` field with values `v:` (vessel) or `s:` (site). Server actions parse this to set the correct FK. +**Form field:** PO create/edit/import forms use a plain `vesselId` select (no more `costCentreRef` encoding). -**Display pattern:** `po.vessel?.name ?? po.site?.name ?? "—"` everywhere a cost centre name is shown. +**Display pattern:** `po.vessel?.name ?? "—"`. -**URL pre-select:** `/po/new?costCentreRef=v:` or `?costCentreRef=s:`. +**URL pre-select:** `/po/new?vesselId=`. -**Terminology:** Admin pages use the real entity names (Vessel Management, Sites). PO-facing pages use "Cost Centre" for the combined concept. Budget heads are labelled "Accounting Code" (not "Account"). +**Terminology:** "Vessel" is surfaced as **"Cost Centre"** everywhere in the UI, including the admin page (`/admin/vessels` → "Cost Centre Management"). `Site` still exists as a separate construct (used for vendor-distance and inventory), but is not a PO cost centre. Budget heads are labelled "Accounting Code" (not "Account"). + +### Accounting Code Hierarchy + +`Account` is a self-referential 3-level tree via `parentId` (`AccountHierarchy` relation): **Top Category (6-digit, e.g. `100000`) → Sub-Category (`100100`) → Leaf Item (`100101`)**. Codes are 6-digit numeric strings. Seed data lives in `prisma/accounting-codes-data.ts`. + +- **Only leaf items** (accounts with no children) are selectable on a PO. +- PO forms group leaf codes by their sub-category in a searchable dropdown (`components/ui/searchable-select.tsx`, a portal-rendered combobox used in the line-items editor and the main accounting-code field). + +### Companies (multi-company invoicing) + +`Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked). + +### PO Numbering (`lib/po-number.ts`) + +Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import. + +### Payments + +When Accounts records a payment, a **compulsory payment date** is captured (`PurchaseOrder.paymentDate`) — the input defaults to today and rejects future dates (validated in `processPaymentSchema` and `markPaid`). There is also an editable **`poDate`** field; the exported PO "Date" shows `poDate ?? approvedAt ?? createdAt` (i.e. the approval date once approved, not creation). + +### Vendors + +`Vendor` carries `isVerified`, `gstin`, `pincode` + `latitude`/`longitude` (geocoded for vendor-distance sorting from a Site), and a `VendorContact[]` list. **Submitters can create vendors** (permission `create_vendor`) but they are created **unverified**; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs `verifyVendor`. Only `manage_vendors` holders may assign a `vendorId` (the formal verified code). + +### Inventory (feature-flagged) + +Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless. + +### Import → Closed + +`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices. ### GST Calculation diff --git a/App/README.md b/App/README.md index 6baba01..669d6f6 100644 --- a/App/README.md +++ b/App/README.md @@ -133,6 +133,8 @@ FORGEJO_TOKEN= pnpm db:migrate:deploy # runs prisma migrate deploy (safe for production) ``` +> **Always run migrations before the new build serves traffic.** `pnpm build` only runs `prisma generate` (which updates the TypeScript client) — it does **not** apply migrations. Deploying new code whose client expects a column the DB doesn't have yet produces `P2022 … column does not exist` errors at runtime. The release workflow (`.forgejo/workflows/deploy.yml`) runs `migrate deploy` as part of the deploy; for manual deploys, run it (and restart) before/with the swap. + ### 3. Build and start ```bash @@ -172,7 +174,8 @@ and `staging-tunnel.cmd` (Windows tunnel launcher). | `pnpm db:migrate` | Create and run a new migration (dev only) | | `pnpm db:migrate:deploy` | Apply pending migrations without prompting (CI/production) | | `pnpm db:push` | Push schema changes without a migration file (prototyping only) | -| `pnpm db:seed` | Seed sample data | +| `pnpm db:seed` | Seed sample/demo data (dev) | +| `pnpm db:seed:prod` | Seed real production reference data — users, companies, cost centres, sites, and the full accounting-code hierarchy (idempotent) | | `pnpm db:studio` | Open Prisma Studio GUI | | `pnpm db:reset` | Drop and recreate the database, then re-seed (dev only) | @@ -235,12 +238,21 @@ pelagia-portal/ | Role | Description | |---|---| -| Technical | Deck/engine crew — create and submit POs, confirm receipt | +| Technical | Deck/engine crew — create and submit POs, confirm receipt, add (unverified) vendors | | Manning | Crew-management staff — same as Technical | -| Manager | Review, approve, reject, request edits | -| Accounts | Process payment for approved POs | +| Manager | Review, approve, reject, request edits; manage cost centres, items, vendors | +| Accounts | Process payment for approved POs (records payment reference + date); manage vendors | | SuperUser | Combined Technical + Manning + Manager authority | | Auditor | Read-only access to all records and reports | -| Admin | Manage users, vessels, accounts, and vendors | +| Admin | Manage users, companies, accounting codes, cost centres, sites, items, and vendors | -User accounts are provisioned by an Admin; there is no self-registration. +User accounts are provisioned by an Admin (or via Microsoft Entra SSO); there is no self-registration. SSO-only users have no password and may optionally set one from their profile. + +## Domain Concepts + +- **Cost Centre** — a PO is raised against a **Vessel** (surfaced as "Cost Centre" in the UI). Required on every PO. +- **Company** — the sister company a PO is billed under (e.g. PMS, HNR, DEI). Its GST/address details appear on the exported PO. +- **Accounting Code** — a 3-level hierarchy of 6-digit codes (Top Category → Sub-Category → Leaf). Only leaf codes are selectable on a PO. +- **PO Number** — auto-formatted `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); imported POs keep their original number. +- **Vendors** — submitters can add vendors; they stay *unverified* until a PO closes with them or a Manager/Accounts/Admin verifies them. +- **Import PO** (Manager/SuperUser) — uploads a Pelagia-format Excel PO straight into `CLOSED`, auto-creating the vendor and any new items. diff --git a/App/app/(portal)/dashboard/page.tsx b/App/app/(portal)/dashboard/page.tsx index 3da8778..b4ca973 100644 --- a/App/app/(portal)/dashboard/page.tsx +++ b/App/app/(portal)/dashboard/page.tsx @@ -3,7 +3,7 @@ import { db } from "@/lib/db"; import { StatCard } from "@/components/dashboard/stat-card"; import { SpendCharts } from "@/components/dashboard/spend-charts"; import { PoStatusBadge } from "@/components/po/po-status-badge"; -import { formatCurrency, formatDate } from "@/lib/utils"; +import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils"; import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react"; import Link from "next/link"; import type { Metadata } from "next"; @@ -110,11 +110,14 @@ async function ManagerDashboard() { const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1); - const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "CLOSED"] as const; + const approvedStatuses = POST_APPROVAL_STATUSES; const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([ db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }), - db.purchaseOrder.count({ where: { status: "MGR_APPROVED", approvedAt: { gte: startOfMonth } } }), + // POs approved this month — including those that have since moved past + // MGR_APPROVED into payment/delivery/closure. `approvedAt` is set once at + // approval and persists, so filter on it across all post-approval statuses. + db.purchaseOrder.count({ where: { status: { in: [...approvedStatuses] }, approvedAt: { gte: startOfMonth } } }), db.purchaseOrder.aggregate({ _sum: { totalAmount: true }, where: { status: { in: [...approvedStatuses] } }, @@ -144,6 +147,10 @@ async function ManagerDashboard() { const totalSpend = Number(totalSpendResult._sum.totalAmount ?? 0); + // Local YYYY-MM-DD for the first of this month, used to deep-link the + // "Approved This Month" card into the history page filtered by approval date. + const startOfMonthParam = `${startOfMonth.getFullYear()}-${String(startOfMonth.getMonth() + 1).padStart(2, "0")}-01`; + // Build monthly series for last 12 months const monthlyMap: Record = {}; for (let i = 11; i >= 0; i--) { @@ -174,7 +181,7 @@ async function ManagerDashboard() {

Dashboard

- +
diff --git a/App/app/(portal)/history/history-filters.tsx b/App/app/(portal)/history/history-filters.tsx index 3a63901..931b4ea 100644 --- a/App/app/(portal)/history/history-filters.tsx +++ b/App/app/(portal)/history/history-filters.tsx @@ -26,6 +26,8 @@ export function HistoryFilters({ vessels }: Props) { const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? ""); const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? ""); + const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? ""); + const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? ""); const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? ""); const [statuses, setStatuses] = useState(sp.getAll("status")); const [statusOpen, setStatusOpen] = useState(false); @@ -51,17 +53,19 @@ export function HistoryFilters({ vessels }: Props) { const params = new URLSearchParams(); if (dateFrom) params.set("dateFrom", dateFrom); if (dateTo) params.set("dateTo", dateTo); + if (approvedFrom) params.set("approvedFrom", approvedFrom); + if (approvedTo) params.set("approvedTo", approvedTo); if (vesselId) params.set("vesselId", vesselId); for (const s of statuses) params.append("status", s); router.push(`/history?${params.toString()}`); } function clear() { - setDateFrom(""); setDateTo(""); setVesselId(""); setStatuses([]); + setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]); router.push("/history"); } - const hasFilters = dateFrom || dateTo || vesselId || statuses.length > 0; + const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0; const statusLabel = statuses.length === 0 @@ -83,6 +87,16 @@ export function HistoryFilters({ vessels }: Props) { setDateTo(e.target.value)} 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" /> +
+ + setApprovedFrom(e.target.value)} + 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" /> +
+
+ + setApprovedTo(e.target.value)} + 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" /> +