Merge branch 'master' into ci/pr-checks

This commit is contained in:
shad0w 2026-06-19 07:36:28 +00:00
commit 8406397602
12 changed files with 335 additions and 32 deletions

View file

@ -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:<vesselId>` (vessel) or `s:<siteId>` (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:<id>` or `?costCentreRef=s:<id>`.
**URL pre-select:** `/po/new?vesselId=<id>`.
**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 (AprMar) 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

View file

@ -133,6 +133,8 @@ FORGEJO_TOKEN=<forgejo access 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.

View file

@ -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<string, number> = {};
for (let i = 11; i >= 0; i--) {
@ -174,7 +181,7 @@ async function ManagerDashboard() {
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" />
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
</div>

View file

@ -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<string[]>(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) {
<input type="date" value={dateTo} onChange={(e) => 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved From</label>
<input type="date" value={approvedFrom} onChange={(e) => 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved To</label>
<input type="date" value={approvedTo} onChange={(e) => 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}

View file

@ -16,6 +16,8 @@ interface Props {
searchParams: Promise<{
dateFrom?: string;
dateTo?: string;
approvedFrom?: string;
approvedTo?: string;
vesselId?: string;
status?: string | string[];
}>;
@ -27,7 +29,7 @@ export default async function HistoryPage({ searchParams }: Props) {
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
const { dateFrom, dateTo, vesselId, status } = await searchParams;
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
if (dateFrom || dateTo) {
@ -40,6 +42,16 @@ export default async function HistoryPage({ searchParams }: Props) {
}
where.createdAt = createdAt;
}
if (approvedFrom || approvedTo) {
const approvedAt: { gte?: Date; lt?: Date } = {};
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
if (approvedTo) {
const end = new Date(approvedTo);
end.setDate(end.getDate() + 1);
approvedAt.lt = end;
}
where.approvedAt = approvedAt;
}
if (vesselId) where.vesselId = vesselId;
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
@ -57,6 +69,8 @@ export default async function HistoryPage({ searchParams }: Props) {
const exportParams = new URLSearchParams({ format: "csv" });
if (dateFrom) exportParams.set("dateFrom", dateFrom);
if (dateTo) exportParams.set("dateTo", dateTo);
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
if (approvedTo) exportParams.set("approvedTo", approvedTo);
if (vesselId) exportParams.set("vesselId", vesselId);
for (const s of statuses) exportParams.append("status", s);

View file

@ -24,6 +24,8 @@ export async function GET(request: NextRequest) {
const format = sp.get("format") ?? "csv";
const dateFrom = sp.get("dateFrom");
const dateTo = sp.get("dateTo");
const approvedFrom = sp.get("approvedFrom");
const approvedTo = sp.get("approvedTo");
const vesselId = sp.get("vesselId");
const statuses = sp.getAll("status").filter(Boolean);
@ -38,6 +40,16 @@ export async function GET(request: NextRequest) {
}
where.createdAt = createdAt;
}
if (approvedFrom || approvedTo) {
const approvedAt: { gte?: Date; lt?: Date } = {};
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
if (approvedTo) {
const end = new Date(approvedTo);
end.setDate(end.getDate() + 1);
approvedAt.lt = end;
}
where.approvedAt = approvedAt;
}
if (vesselId) where.vesselId = vesselId;
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };

View file

@ -53,6 +53,18 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
CLOSED: "Closed",
};
// Statuses a PO can be in once it has received manager approval. A PO keeps its
// `approvedAt` timestamp as it moves through these states, so "approved this month"
// aggregations must match against all of them — not just MGR_APPROVED.
export const POST_APPROVAL_STATUSES = [
"MGR_APPROVED",
"SENT_FOR_PAYMENT",
"PARTIALLY_PAID",
"PAID_DELIVERED",
"PARTIALLY_CLOSED",
"CLOSED",
] as const satisfies readonly POStatus[];
export type BadgeVariant =
| "default"
| "secondary"

View file

@ -0,0 +1,105 @@
/**
* Integration test for the manager dashboard "Approved This Month" card.
*
* Regression: the card previously counted only POs *currently* in MGR_APPROVED,
* so POs approved this month that had moved on to payment/delivery/closure were
* dropped from the count. The card must count every PO approved this month
* regardless of its current (post-approval) status, and the same approval-date
* window must be reproducible on the /history page (where the card links to).
*/
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { db } from "@/lib/db";
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
import { deletePosByTitle } from "./helpers";
import type { POStatus } from "@prisma/client";
const PREFIX = "INTTEST_APPROVED_MONTH_";
let submitterId: string;
let vesselId: string;
let accountId: string;
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const midThisMonth = new Date(now.getFullYear(), now.getMonth(), 15, 12, 0, 0);
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 15, 12, 0, 0);
beforeAll(async () => {
// Resolve any existing cost-centre / account / user from the test DB rather
// than relying on dev-seed fixtures (the test DB is a production mirror).
const [user, vessel, account] = await Promise.all([
db.user.findFirstOrThrow({ where: { role: "MANAGER" } }),
db.vessel.findFirstOrThrow(),
db.account.findFirstOrThrow(),
]);
submitterId = user.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
});
let seq = 0;
async function makePo(opts: { title: string; status: POStatus; approvedAt: Date | null }) {
seq += 1;
return db.purchaseOrder.create({
data: {
poNumber: `${PREFIX}${Date.now()}_${seq}`,
title: opts.title,
status: opts.status,
totalAmount: 1000,
approvedAt: opts.approvedAt,
submitterId,
vesselId,
accountId,
},
});
}
/** Mirrors the dashboard "Approved This Month" query. */
function approvedThisMonthWhere() {
return {
title: { startsWith: PREFIX },
status: { in: [...POST_APPROVAL_STATUSES] },
approvedAt: { gte: startOfMonth },
};
}
describe("Approved This Month count", () => {
it("counts POs approved this month across every post-approval status", async () => {
await makePo({ title: `${PREFIX}approved`, status: "MGR_APPROVED", approvedAt: midThisMonth });
await makePo({ title: `${PREFIX}sent`, status: "SENT_FOR_PAYMENT", approvedAt: midThisMonth });
await makePo({ title: `${PREFIX}partpaid`, status: "PARTIALLY_PAID", approvedAt: midThisMonth });
await makePo({ title: `${PREFIX}paid`, status: "PAID_DELIVERED", approvedAt: midThisMonth });
await makePo({ title: `${PREFIX}partclosed`, status: "PARTIALLY_CLOSED", approvedAt: midThisMonth });
await makePo({ title: `${PREFIX}closed`, status: "CLOSED", approvedAt: midThisMonth });
const count = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
expect(count).toBe(6);
});
it("excludes POs approved in a previous month and POs never approved", async () => {
await makePo({ title: `${PREFIX}closed_lastmonth`, status: "CLOSED", approvedAt: lastMonth });
await makePo({ title: `${PREFIX}awaiting`, status: "MGR_REVIEW", approvedAt: null });
await makePo({ title: `${PREFIX}closed_thismonth`, status: "CLOSED", approvedAt: midThisMonth });
const count = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
expect(count).toBe(1);
});
it("would have missed moved-on POs under the old MGR_APPROVED-only filter", async () => {
// A PO approved this month that has since closed — the case from issue #32.
await makePo({ title: `${PREFIX}moved_on`, status: "CLOSED", approvedAt: midThisMonth });
const oldCount = await db.purchaseOrder.count({
where: { title: { startsWith: PREFIX }, status: "MGR_APPROVED", approvedAt: { gte: startOfMonth } },
});
const newCount = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
expect(oldCount).toBe(0);
expect(newCount).toBe(1);
});
});

View file

@ -4,7 +4,29 @@
### Added
- **Companies (multi-company invoicing)** — new `Company` model and `/admin/companies` CRUD. A PO is billed under a selected company (name, short `code`, GST number, address, phone/mobile, contact + invoice email, invoice address). The company's details populate the exported PO header / invoice block.
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
- **3-level accounting-code hierarchy**`Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.
- **Compulsory payment date**`PurchaseOrder.paymentDate` captured when Accounts records a payment; defaults to today, rejects future dates. Backfilled for existing POs from `paidAt` / the first payment action.
- **Editable PO date (`poDate`)** — the exported PO "Date" now shows `poDate ?? approvedAt ?? createdAt` (approval date once approved, not creation).
- **Submitter vendor creation**`create_vendor` permission lets Technical/Manning add vendors; they are created **unverified** and become verified when a PO closes/pays with them, on import, or via Manager/Accounts/Admin (`verifyVendor`).
- **Import PO → Closed**`/po/import` saves a parsed Excel PO directly as `CLOSED`, auto-detecting the company, matching the vessel by code, and auto-creating the vendor, products, and per-vendor prices.
- **Inventory feature flag** (`NEXT_PUBLIC_INVENTORY_ENABLED`) — site stock/consumption surfaces are gated; the vendor/item catalogue for PO creation stays available. Inventory is incremented at **PO approval** (not on close).
- **Dashboards** — Accounts gains a "Payments Completed This Month" card.
- **Automated issue-to-deploy pipeline** — end-to-end flow from a user-reported bug to a production fix without manual intervention on the developer's part:
- **Report Issue button** (`App/components/layout/report-issue-button.tsx`) — any signed-in user can file a bug from the portal header; the server action (`report-issue-actions.ts`) calls the Forgejo API and attaches `portal` + `claude-queue` labels.
- **Claude issue watcher** (`automation/claude-issue-watcher.ps1`) — a Windows Scheduled Task (`PelagiaClaudeIssueWatcher`) polls Forgejo every 10 minutes, picks up `claude-queue` issues, and runs Claude Code headlessly to implement and verify a fix. On success the watcher pushes a `claude/issue-N` branch and opens a PR; on failure it posts a comment and labels the issue `claude-failed`.
- **Tag-triggered deploy workflow** (`.forgejo/workflows/deploy.yml`) — pushing a `v*` semver tag triggers the `host` Forgejo runner on pms1, which checks out the tag, runs `pnpm install`, builds the app, applies Prisma migrations, and restarts the pm2 process `ppms`.
### Changed
- **Cost centre is now a Vessel only.** The earlier Vessel-or-Site cost-centre model was removed: `PurchaseOrder.vesselId` is required, the `costCentreRef` encoding is gone, and `Vessel` no longer links to a `Site`. Vessels are surfaced as **"Cost Centre"** throughout the UI (`/admin/vessels` → "Cost Centre Management").
- **Closed Purchase Orders** list: submitters see only their own `CLOSED` POs; Managers/SuperUsers see all `CLOSED` POs.
- **Sidebar** reorganised into **Purchasing** and **Administration** sections (role-aware); "Inventory" renamed to "Purchasing".
- **Items**: `/admin/products` is the editable catalogue; `/inventory/items` is read-only; both link to a shared item detail page.
- **Profile** page is reachable by every role (incl. SSO-only / no-password users, with an email fallback lookup); only approvers can upload an approval signature.
- **Manager dashboard** "Approved This Month" now counts by `approvedAt` (no longer undercounts once a PO progresses past `MGR_APPROVED`).
### Fixed
- Production `P2022 … column does not exist` after deploy — caused by shipping code whose Prisma client expected a column before `migrate deploy` had run. Migrations must be applied before the new build serves traffic (now documented in the README).

View file

@ -1,5 +1,18 @@
# Pelagia Portal — Design Document
> **Note — this is the original design spec.** The shipped product has evolved; where they
> differ, the code, `App/CLAUDE.md`, `Docs/02-architecture.md`, and the `CHANGELOG.md` are
> authoritative. Key amendments since this document was written:
> - **Cost centre is a Vessel only** (Vessels are labelled "Cost Centre" in the UI). "Account
> Management / cost centres" below refers to budget **Accounting Codes**, now a 3-level
> hierarchy of 6-digit codes — not cost centres.
> - **Companies** were added (a PO is billed under a sister company; details appear on the
> exported PO).
> - **Submitters can create vendors** (created unverified until approved or proven by a closed PO).
> - **Accounts capture a compulsory payment date**; PO numbers are auto-formatted
> `COMPANY/VESSEL/ID/FY`; POs can be **imported** straight to `CLOSED`.
> - Auth supports **Microsoft Entra SSO** (passwordless users); partial payment/receipt states exist.
## 1. Overview
Pelagia Portal is an internal purchase order (PO) management web application for a maritime / vessel-operations company. It digitises the entire PO lifecycle — from a crew member raising a requisition, through manager approval and vendor validation, to accounts payment processing and final receipt confirmation — replacing ad-hoc email chains and spreadsheets with a single, auditable system.

View file

@ -127,6 +127,24 @@ pelagia-portal/
## 4. Data Model
> **Source of truth:** `App/prisma/schema.prisma`. The excerpt below is an illustrative
> overview and may lag the schema. Notable evolutions since the original design:
> - **Cost centre is a Vessel only** — the PO `vesselId` is required; the earlier
> Vessel-or-Site cost-centre design was dropped. `Vessel` no longer links to a `Site`.
> In the UI a Vessel is labelled **"Cost Centre"**.
> - **`Account` is a 3-level hierarchy** (self-relation via `parentId`): Top Category →
> Sub-Category → Leaf item, with 6-digit numeric codes; only leaf codes are PO-selectable.
> - **`Company`** added — the sister company a PO is billed under (`PurchaseOrder.companyId`).
> - **`PurchaseOrder`** gained `companyId`, `siteId?`, `paidAmount`, `paymentDate`, `poDate`,
> and the quotation / requisition / terms-and-conditions fields; `currency` defaults to `INR`.
> - **`Vendor`** gained `gstin`, `address`, `pincode`, `latitude`/`longitude` (geocoded for
> distance) and a `VendorContact[]` list. Submitters can create vendors (created
> *unverified*); a vendor is verified when a PO closes/pays with it, on import, or by a
> Manager/Accounts/Admin.
> - Inventory/catalogue models added: `Site`, `ItemInventory`, `ItemConsumption`,
> `ProductVendorPrice`. Auth: `User.passwordHash` is **nullable** (SSO users) and `User`
> has a `signatureKey`. New statuses `PARTIALLY_PAID` / `PARTIALLY_CLOSED`.
### 4.1 Entity Relationship (Prisma Schema)
```prisma
@ -151,7 +169,9 @@ enum POStatus {
REJECTED
MGR_APPROVED
SENT_FOR_PAYMENT
PARTIALLY_PAID
PAID_DELIVERED
PARTIALLY_CLOSED
CLOSED
}
@ -187,24 +207,46 @@ model User {
notifications Notification[]
}
// Cost centre. Surfaced as "Cost Centre" in the UI.
model Vessel {
id String @id @default(cuid())
name String
code String @unique
isActive Boolean @default(true)
siteId String?
site Site? @relation(fields: [siteId], references: [id])
purchaseOrders PurchaseOrder[]
}
// 3-level hierarchy via self-relation: Top Category → Sub-Category → Leaf item.
// 6-digit numeric codes; only leaf accounts (no children) are selectable on a PO.
model Account {
id String @id @default(cuid())
code String @unique
code String @unique // e.g. 100000 / 100100 / 100101
name String
description String?
isActive Boolean @default(true)
parentId String?
parent Account? @relation("AccountHierarchy", fields: [parentId], references: [id])
children Account[] @relation("AccountHierarchy")
purchaseOrders PurchaseOrder[]
}
// Sister company a PO is billed under; its details populate the exported PO header.
model Company {
id String @id @default(cuid())
name String
code String? @unique // short code used in PO numbers, e.g. PMS
gstNumber String?
address String?
telephone String?
mobile String?
email String?
invoiceEmail String?
invoiceAddress String?
isActive Boolean @default(true)
purchaseOrders PurchaseOrder[]
}
@ -239,15 +281,19 @@ model Product {
model PurchaseOrder {
id String @id @default(cuid())
poNumber String @unique @default(cuid()) // formatted in app layer
poNumber String @unique // COMPANY/VESSEL/ID/FY, formatted in lib/po-number.ts
title String
status POStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
currency String @default("USD")
paidAmount Decimal? @db.Decimal(12, 2) // accumulates across partial payments
currency String @default("INR")
poDate DateTime? // editable PO date (export "Date" = poDate ?? approvedAt ?? createdAt)
dateRequired DateTime?
projectCode String?
managerNote String?
paymentRef String?
paymentDate DateTime? // compulsory when Accounts records a payment; no future dates
// + piQuotationNo/Date, requisitionNo/Date, placeOfDelivery, tc* (terms & conditions) fields
submittedAt DateTime?
approvedAt DateTime?
paidAt DateTime?
@ -257,10 +303,14 @@ model PurchaseOrder {
submitterId String
submitter User @relation("Submitter", fields: [submitterId], references: [id])
vesselId String
vesselId String // cost centre (required)
vessel Vessel @relation(fields: [vesselId], references: [id])
siteId String? // optional delivery site (drives inventory)
site Site? @relation(fields: [siteId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
companyId String?
company Company? @relation(fields: [companyId], references: [id])
vendorId String?
vendor Vendor? @relation(fields: [vendorId], references: [id])

View file

@ -3,6 +3,14 @@
Internal purchase-order management system for a maritime company.
This document describes every feature, page, workflow, and user story to guide UI/UX design.
> **Note — original UI/UX spec; the shipped product has evolved.** For current behaviour see
> `App/CLAUDE.md`, `Docs/02-architecture.md`, and `CHANGELOG.md`. Notably: a PO's cost centre
> is a **Vessel** (labelled "Cost Centre"); a PO is billed under a **Company** (shown on the
> exported PO); **Accounting Codes** are a 3-level 6-digit hierarchy; PO numbers are
> `COMPANY/VESSEL/ID/FY`; submitters can add (unverified) vendors; Accounts capture a
> compulsory **payment date**; POs can be **imported** directly to `CLOSED`; and login
> supports **Microsoft Entra SSO**.
---
## 1. Purpose