Merge branch 'master' into ci/pr-checks
This commit is contained in:
commit
8406397602
12 changed files with 335 additions and 32 deletions
|
|
@ -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 (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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] };
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
105
App/tests/integration/approved-this-month.test.ts
Normal file
105
App/tests/integration/approved-this-month.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,23 +207,45 @@ 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
|
||||
id String @id @default(cuid())
|
||||
code String @unique // e.g. 100000 / 100100 / 100101
|
||||
name String
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
parentId String?
|
||||
parent Account? @relation("AccountHierarchy", fields: [parentId], references: [id])
|
||||
children Account[] @relation("AccountHierarchy")
|
||||
|
||||
purchaseOrders PurchaseOrder[]
|
||||
}
|
||||
|
||||
// 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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue