diff --git a/App/.env.example b/App/.env.example index 248cb9c..3f98d6d 100644 --- a/App/.env.example +++ b/App/.env.example @@ -62,6 +62,13 @@ FORGEJO_URL=https://git.pelagiamarine.com FORGEJO_REPO=shad0w/pelagia-portal FORGEJO_TOKEN= +# ── Feature flags (NEXT_PUBLIC_, available to client + server) ─ +# Inventory tracking (site stock / consumption). On unless explicitly "false". +# NEXT_PUBLIC_INVENTORY_ENABLED=false +# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History +# page (read-only). Opt-in — on only when exactly "true". +# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true + # ── Non-production banner ───────────────────────────────────── # When set, a fixed "internal dev / staging" banner is shown (EnvBanner). # Leave UNSET in production. Staging sets this automatically. diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 0e104df..72a6d13 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -112,6 +112,8 @@ Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25 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). +**Advance payment (issue #92):** the approving Manager sets how much of the PO is paid first via a 0–100% slider on the approval card (`approval-actions.tsx`, default 100%). The slider is convenience only — the resolved **absolute amount** is stored on `PurchaseOrder.suggestedAdvancePayment` (`Decimal(12,2)`, nullable; null = no explicit advance ⇒ full payment). `approvePo()` clamps it to `[0, totalAmount]` and records it on the `APPROVED` audit row; it is **set once at approval and never edited after**. Accounts sees it on the payment queue + PO detail, and it **prefills the first payment's amount** (`payment-actions.tsx`, only when nothing is paid yet and the advance is a true partial); the balance then runs through the normal `PARTIALLY_PAID` loop. It does **not** appear on the exported PO/invoice. + ### 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). @@ -238,6 +240,7 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004) NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag +NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only) NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default) NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod. ``` diff --git a/App/app/(portal)/admin/crew/admin-crew-manager.tsx b/App/app/(portal)/admin/crew/admin-crew-manager.tsx index 2fe7d7d..5dc725c 100644 --- a/App/app/(portal)/admin/crew/admin-crew-manager.tsx +++ b/App/app/(portal)/admin/crew/admin-crew-manager.tsx @@ -15,7 +15,6 @@ const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-m const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"]; const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"]; -const TYPES: CandidateType[] = ["NEW", "EX_HAND"]; const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase()); type Opt = { id: string; name: string }; @@ -132,7 +131,10 @@ function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt setF({ ...f, name: e.target.value })} required /> - + setF({ ...f, email: e.target.value })} /> diff --git a/App/app/(portal)/approvals/[id]/actions.ts b/App/app/(portal)/approvals/[id]/actions.ts index 6257eff..4c04098 100644 --- a/App/app/(portal)/approvals/[id]/actions.ts +++ b/App/app/(portal)/approvals/[id]/actions.ts @@ -3,6 +3,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { canPerformAction } from "@/lib/po-state-machine"; +import { approvePoSchema } from "@/lib/validations/po"; import { notify } from "@/lib/notifier"; import { revalidatePath } from "next/cache"; @@ -12,14 +13,21 @@ export async function approvePo({ poId, note, withNote = false, + suggestedAdvancePayment, }: { poId: string; note?: string; withNote?: boolean; + // Absolute advance the Manager wants paid first (issue #92). Whole amount, + // resolved from the approval slider client-side. Omitted ⇒ full payment. + suggestedAdvancePayment?: number; }): Promise { const session = await auth(); if (!session?.user) return { error: "Unauthorized" }; + const parsed = approvePoSchema.safeParse({ note, suggestedAdvancePayment }); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const po = await db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true, lineItems: true }, @@ -35,17 +43,28 @@ export async function approvePo({ return { error: "A vendor must be assigned before approving this PO." }; } + // Resolve the advance: clamp to [0, total]. Undefined ⇒ no explicit advance + // (full payment, current default behaviour). The slider always sends a value, + // but a malformed/over-total amount is clamped rather than rejected. + const total = Number(po.totalAmount); + const advance = + parsed.data.suggestedAdvancePayment === undefined + ? null + : Math.min(Math.max(parsed.data.suggestedAdvancePayment, 0), total); + await db.purchaseOrder.update({ where: { id: poId }, data: { status: "MGR_APPROVED", approvedAt: new Date(), managerNote: note ?? null, + suggestedAdvancePayment: advance, actions: { create: { actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED", note: note ?? null, actorId: session.user.id, + metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined, }, }, }, diff --git a/App/app/(portal)/approvals/[id]/approval-actions.tsx b/App/app/(portal)/approvals/[id]/approval-actions.tsx index ac83965..88e5026 100644 --- a/App/app/(portal)/approvals/[id]/approval-actions.tsx +++ b/App/app/(portal)/approvals/[id]/approval-actions.tsx @@ -3,21 +3,38 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions"; +import { formatCurrency } from "@/lib/utils"; import type { POStatus } from "@prisma/client"; +// Resolve the slider percent (whole number) into an absolute advance amount. +// 100% is the exact total (no rounding loss on paise); partial advances are +// rounded to whole rupees — the slider is convenience, the amount is the record. +function advanceAmount(total: number, percent: number): number { + if (percent >= 100) return total; + if (percent <= 0) return 0; + return Math.round((total * percent) / 100); +} + export function ApprovalActions({ poId, poStatus, + totalAmount = 0, + currency = "INR", }: { poId: string; poStatus: POStatus; + totalAmount?: number; + currency?: string; }) { const router = useRouter(); const [note, setNote] = useState(""); + const [advancePercent, setAdvancePercent] = useState(100); const [activeAction, setActiveAction] = useState(null); const [pending, setPending] = useState(null); const [error, setError] = useState(""); + const advance = advanceAmount(totalAmount, advancePercent); + async function dispatch(action: string, requireNote = false) { if (requireNote && !note.trim()) { setError("A note is required for this action."); @@ -26,8 +43,10 @@ export function ApprovalActions({ setPending(action); setError(""); let result: { ok: true } | { error: string } | undefined; - if (action === "approve") result = await approvePo({ poId, note }); - else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true }); + // Approvals carry the Manager's advance decision (resolved amount, not %). + if (action === "approve") result = await approvePo({ poId, note, suggestedAdvancePayment: advance }); + else if (action === "approve_note") + result = await approvePo({ poId, note, withNote: true, suggestedAdvancePayment: advance }); else if (action === "reject") result = await rejectPo({ poId, note }); else if (action === "request_edits") result = await requestEdits({ poId, note }); else if (action === "request_vendor_id") result = await requestVendorId({ poId }); @@ -45,6 +64,37 @@ export function ApprovalActions({

Decision

+ {/* Advance payment (issue #92) — Manager decides how much Accounts pays + first. 100% = full payment; lower values seed the first part-payment. */} +
+
+ + + {advancePercent}% · {formatCurrency(advance, currency)} + +
+ setAdvancePercent(Number(e.target.value))} + className="w-full accent-primary-600" + /> +

+ {advancePercent >= 100 + ? "Full payment — Accounts will be prompted to pay the whole PO value." + : `Accounts will be prompted to pay ${formatCurrency(advance, currency)} first; the balance of ${formatCurrency( + Math.max(totalAmount - advance, 0), + currency + )} follows the usual part-payment flow.`} +

+
+ {(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (