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
|
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
|
### 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
|
### Key Directories
|
||||||
|
|
||||||
|
|
@ -74,15 +77,46 @@ Every status change is validated against the state machine and recorded as a `PO
|
||||||
|
|
||||||
### Cost Centre Model
|
### 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
|
### GST Calculation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,8 @@ FORGEJO_TOKEN=<forgejo access token>
|
||||||
pnpm db:migrate:deploy # runs prisma migrate deploy (safe for production)
|
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
|
### 3. Build and start
|
||||||
|
|
||||||
```bash
|
```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` | Create and run a new migration (dev only) |
|
||||||
| `pnpm db:migrate:deploy` | Apply pending migrations without prompting (CI/production) |
|
| `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: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:studio` | Open Prisma Studio GUI |
|
||||||
| `pnpm db:reset` | Drop and recreate the database, then re-seed (dev only) |
|
| `pnpm db:reset` | Drop and recreate the database, then re-seed (dev only) |
|
||||||
|
|
||||||
|
|
@ -235,12 +238,21 @@ pelagia-portal/
|
||||||
|
|
||||||
| Role | Description |
|
| 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 |
|
| Manning | Crew-management staff — same as Technical |
|
||||||
| Manager | Review, approve, reject, request edits |
|
| Manager | Review, approve, reject, request edits; manage cost centres, items, vendors |
|
||||||
| Accounts | Process payment for approved POs |
|
| Accounts | Process payment for approved POs (records payment reference + date); manage vendors |
|
||||||
| SuperUser | Combined Technical + Manning + Manager authority |
|
| SuperUser | Combined Technical + Manning + Manager authority |
|
||||||
| Auditor | Read-only access to all records and reports |
|
| 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 { StatCard } from "@/components/dashboard/stat-card";
|
||||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
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 { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
@ -110,11 +110,14 @@ async function ManagerDashboard() {
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 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([
|
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
|
||||||
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
|
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({
|
db.purchaseOrder.aggregate({
|
||||||
_sum: { totalAmount: true },
|
_sum: { totalAmount: true },
|
||||||
where: { status: { in: [...approvedStatuses] } },
|
where: { status: { in: [...approvedStatuses] } },
|
||||||
|
|
@ -144,6 +147,10 @@ async function ManagerDashboard() {
|
||||||
|
|
||||||
const totalSpend = Number(totalSpendResult._sum.totalAmount ?? 0);
|
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
|
// Build monthly series for last 12 months
|
||||||
const monthlyMap: Record<string, number> = {};
|
const monthlyMap: Record<string, number> = {};
|
||||||
for (let i = 11; i >= 0; i--) {
|
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>
|
<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">
|
<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="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" />
|
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
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 [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||||
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
|
|
@ -51,17 +53,19 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
for (const s of statuses) params.append("status", s);
|
for (const s of statuses) params.append("status", s);
|
||||||
router.push(`/history?${params.toString()}`);
|
router.push(`/history?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setVesselId(""); setStatuses([]);
|
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
||||||
router.push("/history");
|
router.push("/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || vesselId || statuses.length > 0;
|
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
statuses.length === 0
|
statuses.length === 0
|
||||||
|
|
@ -83,6 +87,16 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
<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" />
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
||||||
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ interface Props {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
approvedFrom?: string;
|
||||||
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
status?: string | string[];
|
status?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -27,7 +29,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
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"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -40,6 +42,16 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
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 (vesselId) where.vesselId = vesselId;
|
||||||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
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" });
|
const exportParams = new URLSearchParams({ format: "csv" });
|
||||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||||
for (const s of statuses) exportParams.append("status", s);
|
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 format = sp.get("format") ?? "csv";
|
||||||
const dateFrom = sp.get("dateFrom");
|
const dateFrom = sp.get("dateFrom");
|
||||||
const dateTo = sp.get("dateTo");
|
const dateTo = sp.get("dateTo");
|
||||||
|
const approvedFrom = sp.get("approvedFrom");
|
||||||
|
const approvedTo = sp.get("approvedTo");
|
||||||
const vesselId = sp.get("vesselId");
|
const vesselId = sp.get("vesselId");
|
||||||
const statuses = sp.getAll("status").filter(Boolean);
|
const statuses = sp.getAll("status").filter(Boolean);
|
||||||
|
|
||||||
|
|
@ -38,6 +40,16 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
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 (vesselId) where.vesselId = vesselId;
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,18 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
||||||
CLOSED: "Closed",
|
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 =
|
export type BadgeVariant =
|
||||||
| "default"
|
| "default"
|
||||||
| "secondary"
|
| "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
|
### 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:
|
- **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.
|
- **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`.
|
- **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`.
|
- **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
|
# 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
|
## 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.
|
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
|
## 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)
|
### 4.1 Entity Relationship (Prisma Schema)
|
||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
|
|
@ -151,7 +169,9 @@ enum POStatus {
|
||||||
REJECTED
|
REJECTED
|
||||||
MGR_APPROVED
|
MGR_APPROVED
|
||||||
SENT_FOR_PAYMENT
|
SENT_FOR_PAYMENT
|
||||||
|
PARTIALLY_PAID
|
||||||
PAID_DELIVERED
|
PAID_DELIVERED
|
||||||
|
PARTIALLY_CLOSED
|
||||||
CLOSED
|
CLOSED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,23 +207,45 @@ model User {
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cost centre. Surfaced as "Cost Centre" in the UI.
|
||||||
model Vessel {
|
model Vessel {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
code String @unique
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
siteId String?
|
|
||||||
site Site? @relation(fields: [siteId], references: [id])
|
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
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 {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String @unique
|
code String @unique // e.g. 100000 / 100100 / 100101
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
parentId String?
|
||||||
|
parent Account? @relation("AccountHierarchy", fields: [parentId], references: [id])
|
||||||
|
children Account[] @relation("AccountHierarchy")
|
||||||
|
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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[]
|
purchaseOrders PurchaseOrder[]
|
||||||
}
|
}
|
||||||
|
|
@ -239,15 +281,19 @@ model Product {
|
||||||
|
|
||||||
model PurchaseOrder {
|
model PurchaseOrder {
|
||||||
id String @id @default(cuid())
|
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
|
title String
|
||||||
status POStatus @default(DRAFT)
|
status POStatus @default(DRAFT)
|
||||||
totalAmount Decimal @db.Decimal(12, 2)
|
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?
|
dateRequired DateTime?
|
||||||
projectCode String?
|
projectCode String?
|
||||||
managerNote String?
|
managerNote String?
|
||||||
paymentRef 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?
|
submittedAt DateTime?
|
||||||
approvedAt DateTime?
|
approvedAt DateTime?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
|
|
@ -257,10 +303,14 @@ model PurchaseOrder {
|
||||||
|
|
||||||
submitterId String
|
submitterId String
|
||||||
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
||||||
vesselId String
|
vesselId String // cost centre (required)
|
||||||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
vessel Vessel @relation(fields: [vesselId], references: [id])
|
||||||
|
siteId String? // optional delivery site (drives inventory)
|
||||||
|
site Site? @relation(fields: [siteId], references: [id])
|
||||||
accountId String
|
accountId String
|
||||||
account Account @relation(fields: [accountId], references: [id])
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
|
companyId String?
|
||||||
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
vendorId String?
|
vendorId String?
|
||||||
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@
|
||||||
Internal purchase-order management system for a maritime company.
|
Internal purchase-order management system for a maritime company.
|
||||||
This document describes every feature, page, workflow, and user story to guide UI/UX design.
|
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
|
## 1. Purpose
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue