Merge remote-tracking branch 'origin/master' into feat/email-po-to-vendor
# Conflicts: # App/app/api/po/[id]/export/route.ts
This commit is contained in:
commit
8206483f88
42 changed files with 1012 additions and 111 deletions
|
|
@ -72,6 +72,13 @@ FORGEJO_URL=https://git.pelagiamarine.com
|
||||||
FORGEJO_REPO=shad0w/pelagia-portal
|
FORGEJO_REPO=shad0w/pelagia-portal
|
||||||
FORGEJO_TOKEN=
|
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 ─────────────────────────────────────
|
# ── Non-production banner ─────────────────────────────────────
|
||||||
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
|
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
|
||||||
# Leave UNSET in production. Staging sets this automatically.
|
# Leave UNSET in production. Staging sets this automatically.
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,12 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId
|
||||||
|
|
||||||
`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).
|
`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).
|
||||||
|
|
||||||
|
### Delivery Locations (issue #19)
|
||||||
|
|
||||||
|
`DeliveryLocation` (a `Company` FK + free-text `address` + `isActive`) is an admin-managed list that backs the PO **Place of Delivery** dropdown. Managed at `/admin/delivery-locations`, gated by the **`manage_delivery_locations`** permission (Manager + SuperUser + Admin — explicitly **not** admin-only, per the issue). The CRUD mirrors `/admin/sites` (table + Add/Edit dialogs + activate/deactivate + delete).
|
||||||
|
|
||||||
|
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)` → `"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
|
||||||
|
|
||||||
### PO Numbering (`lib/po-number.ts`)
|
### 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.
|
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.
|
||||||
|
|
@ -106,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).
|
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
|
### 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).
|
`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).
|
||||||
|
|
@ -241,6 +249,7 @@ PDF_SERVICE_URL # PdfService microservice for PO→PDF render (defaults
|
||||||
PDF_SERVICE_TOKEN # Shared secret for PdfService ↔ export-route auth ("Email to vendor")
|
PDF_SERVICE_TOKEN # Shared secret for PdfService ↔ export-route auth ("Email to vendor")
|
||||||
APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to NEXTAUTH_URL)
|
APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to NEXTAUTH_URL)
|
||||||
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
|
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_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.
|
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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 STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
|
||||||
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
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());
|
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
|
||||||
type Opt = { id: string; name: string };
|
type Opt = { id: string; name: string };
|
||||||
|
|
@ -132,7 +131,10 @@ function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt
|
||||||
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
||||||
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||||
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||||
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value as CandidateType })}>{TYPES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
<label className="flex items-center gap-2 px-1 text-sm text-neutral-700">
|
||||||
|
<input type="checkbox" className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" checked={f.type === "EX_HAND"} onChange={(e) => setF({ ...f, type: e.target.checked ? "EX_HAND" : "NEW" })} />
|
||||||
|
Ex-hand (returning crew)
|
||||||
|
</label>
|
||||||
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
||||||
|
|
|
||||||
77
App/app/(portal)/admin/delivery-locations/actions.ts
Normal file
77
App/app/(portal)/admin/delivery-locations/actions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
companyId: z.string().min(1, "Company is required"),
|
||||||
|
address: z.string().trim().min(1, "Delivery address is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Result = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
async function guard(): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_delivery_locations")) {
|
||||||
|
return { error: "Forbidden" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDeliveryLocation(formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
// Guard against a dangling FK if the company was removed concurrently.
|
||||||
|
const company = await db.company.findUnique({ where: { id: parsed.data.companyId }, select: { id: true } });
|
||||||
|
if (!company) return { error: "Selected company no longer exists." };
|
||||||
|
|
||||||
|
await db.deliveryLocation.create({
|
||||||
|
data: { companyId: parsed.data.companyId, address: parsed.data.address },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeliveryLocation(id: string, formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
await db.deliveryLocation.update({
|
||||||
|
where: { id },
|
||||||
|
data: { companyId: parsed.data.companyId, address: parsed.data.address },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDeliveryLocationActive(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const loc = await db.deliveryLocation.findUnique({ where: { id }, select: { isActive: true } });
|
||||||
|
if (!loc) return { error: "Not found" };
|
||||||
|
await db.deliveryLocation.update({ where: { id }, data: { isActive: !loc.isActive } });
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDeliveryLocation(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
// Safe to delete: POs keep their place-of-delivery as a text snapshot, so no
|
||||||
|
// purchase order references this row.
|
||||||
|
await db.deliveryLocation.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { createDeliveryLocation, updateDeliveryLocation } from "./actions";
|
||||||
|
|
||||||
|
const INPUT = "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";
|
||||||
|
|
||||||
|
export type CompanyOption = { id: string; name: string };
|
||||||
|
export type DeliveryLocationRow = {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
companyName: string;
|
||||||
|
address: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Fields({ companies, location }: { companies: CompanyOption[]; location?: DeliveryLocationRow }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Company *</label>
|
||||||
|
<select name="companyId" defaultValue={location?.companyId ?? ""} required className={INPUT}>
|
||||||
|
<option value="" disabled>Select a company…</option>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Delivery address *</label>
|
||||||
|
<textarea name="address" defaultValue={location?.address ?? ""} rows={3} required className={INPUT} placeholder="e.g. Reti Bundar, Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddDeliveryLocationButton({ companies }: { companies: CompanyOption[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await createDeliveryLocation(new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||||
|
+ Add Delivery Location
|
||||||
|
</button>
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Delivery Location">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields companies={companies} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDeliveryLocationButton({
|
||||||
|
companies,
|
||||||
|
location,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
companies: CompanyOption[];
|
||||||
|
location: DeliveryLocationRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await updateDeliveryLocation(location.id, new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit Delivery Location">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields companies={companies} location={location} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import {
|
||||||
|
AddDeliveryLocationButton,
|
||||||
|
EditDeliveryLocationButton,
|
||||||
|
type CompanyOption,
|
||||||
|
type DeliveryLocationRow,
|
||||||
|
} from "./delivery-location-form";
|
||||||
|
import { deleteDeliveryLocation, toggleDeliveryLocationActive } from "./actions";
|
||||||
|
|
||||||
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function LocationActionsMenu({ companies, location }: { companies: CompanyOption[]; location: DeliveryLocationRow }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [toggleOpen, setToggleOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||||
|
{location.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditDeliveryLocationButton companies={companies} location={location} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={`${location.companyName} — ${location.address}`}
|
||||||
|
onConfirm={() => deleteDeliveryLocation(location.id)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toggleOpen}
|
||||||
|
onOpenChange={setToggleOpen}
|
||||||
|
title={location.isActive ? "Deactivate location?" : "Activate location?"}
|
||||||
|
description={
|
||||||
|
location.isActive
|
||||||
|
? "It will no longer appear in the Place of Delivery dropdown."
|
||||||
|
: "It will appear in the Place of Delivery dropdown again."
|
||||||
|
}
|
||||||
|
confirmLabel={location.isActive ? "Deactivate" : "Activate"}
|
||||||
|
onConfirm={() => toggleDeliveryLocationActive(location.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeliveryLocationsTable({
|
||||||
|
locations,
|
||||||
|
companies,
|
||||||
|
}: {
|
||||||
|
locations: DeliveryLocationRow[];
|
||||||
|
companies: CompanyOption[];
|
||||||
|
}) {
|
||||||
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
|
useTableControls<DeliveryLocationRow>({
|
||||||
|
rows: locations,
|
||||||
|
defaultSortKey: "companyName",
|
||||||
|
searchText: (l) => [l.companyName, l.address, l.isActive ? "active" : "inactive"].join(" "),
|
||||||
|
chipMatch: (l, chip) => {
|
||||||
|
if (chip.toLowerCase() === "active") return l.isActive;
|
||||||
|
if (chip.toLowerCase() === "inactive") return !l.isActive;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
sortValue: (l, key) => {
|
||||||
|
if (key === "isActive") return l.isActive ? "Active" : "Inactive";
|
||||||
|
const val = l[key as keyof DeliveryLocationRow];
|
||||||
|
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Delivery Locations</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">Destinations that populate the PO “Place of Delivery” dropdown</p>
|
||||||
|
</div>
|
||||||
|
<AddDeliveryLocationButton companies={companies} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableControls
|
||||||
|
search={search}
|
||||||
|
onSearch={setSearch}
|
||||||
|
searchPlaceholder="Search company or address…"
|
||||||
|
chips={CHIPS}
|
||||||
|
activeFilters={activeFilters}
|
||||||
|
onToggleFilter={toggleFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
|
<tr>
|
||||||
|
<SortableTh sortKey="companyName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Company</SortableTh>
|
||||||
|
<SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Address</SortableTh>
|
||||||
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Status</SortableTh>
|
||||||
|
<th className="px-4 py-3 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">
|
||||||
|
No delivery locations yet. Add one to populate the Place of Delivery dropdown.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filtered.map((location) => (
|
||||||
|
<tr key={location.id} className="hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{location.companyName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 max-w-md whitespace-pre-wrap">{location.address}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
location.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||||
|
}`}>
|
||||||
|
{location.isActive ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<LocationActionsMenu companies={companies} location={location} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
App/app/(portal)/admin/delivery-locations/page.tsx
Normal file
35
App/app/(portal)/admin/delivery-locations/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { DeliveryLocationsTable } from "./delivery-locations-table";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Delivery Locations" };
|
||||||
|
|
||||||
|
export default async function DeliveryLocationsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_delivery_locations")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [locations, companies] = await Promise.all([
|
||||||
|
db.deliveryLocation.findMany({
|
||||||
|
orderBy: [{ isActive: "desc" }, { createdAt: "desc" }],
|
||||||
|
include: { company: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeliveryLocationsTable
|
||||||
|
companies={companies}
|
||||||
|
locations={locations.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
companyId: l.companyId,
|
||||||
|
companyName: l.company.name,
|
||||||
|
address: l.address,
|
||||||
|
isActive: l.isActive,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canPerformAction } from "@/lib/po-state-machine";
|
import { canPerformAction } from "@/lib/po-state-machine";
|
||||||
|
import { approvePoSchema } from "@/lib/validations/po";
|
||||||
import { notify } from "@/lib/notifier";
|
import { notify } from "@/lib/notifier";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
|
@ -12,14 +13,21 @@ export async function approvePo({
|
||||||
poId,
|
poId,
|
||||||
note,
|
note,
|
||||||
withNote = false,
|
withNote = false,
|
||||||
|
suggestedAdvancePayment,
|
||||||
}: {
|
}: {
|
||||||
poId: string;
|
poId: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
withNote?: boolean;
|
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<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) return { error: "Unauthorized" };
|
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({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
include: { submitter: true, lineItems: true },
|
include: { submitter: true, lineItems: true },
|
||||||
|
|
@ -35,17 +43,28 @@ export async function approvePo({
|
||||||
return { error: "A vendor must be assigned before approving this PO." };
|
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({
|
await db.purchaseOrder.update({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
data: {
|
data: {
|
||||||
status: "MGR_APPROVED",
|
status: "MGR_APPROVED",
|
||||||
approvedAt: new Date(),
|
approvedAt: new Date(),
|
||||||
managerNote: note ?? null,
|
managerNote: note ?? null,
|
||||||
|
suggestedAdvancePayment: advance,
|
||||||
actions: {
|
actions: {
|
||||||
create: {
|
create: {
|
||||||
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
|
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
|
||||||
note: note ?? null,
|
note: note ?? null,
|
||||||
actorId: session.user.id,
|
actorId: session.user.id,
|
||||||
|
metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,38 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions";
|
import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
import type { POStatus } from "@prisma/client";
|
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({
|
export function ApprovalActions({
|
||||||
poId,
|
poId,
|
||||||
poStatus,
|
poStatus,
|
||||||
|
totalAmount = 0,
|
||||||
|
currency = "INR",
|
||||||
}: {
|
}: {
|
||||||
poId: string;
|
poId: string;
|
||||||
poStatus: POStatus;
|
poStatus: POStatus;
|
||||||
|
totalAmount?: number;
|
||||||
|
currency?: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
const [advancePercent, setAdvancePercent] = useState(100);
|
||||||
const [activeAction, setActiveAction] = useState<string | null>(null);
|
const [activeAction, setActiveAction] = useState<string | null>(null);
|
||||||
const [pending, setPending] = useState<string | null>(null);
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const advance = advanceAmount(totalAmount, advancePercent);
|
||||||
|
|
||||||
async function dispatch(action: string, requireNote = false) {
|
async function dispatch(action: string, requireNote = false) {
|
||||||
if (requireNote && !note.trim()) {
|
if (requireNote && !note.trim()) {
|
||||||
setError("A note is required for this action.");
|
setError("A note is required for this action.");
|
||||||
|
|
@ -26,8 +43,10 @@ export function ApprovalActions({
|
||||||
setPending(action);
|
setPending(action);
|
||||||
setError("");
|
setError("");
|
||||||
let result: { ok: true } | { error: string } | undefined;
|
let result: { ok: true } | { error: string } | undefined;
|
||||||
if (action === "approve") result = await approvePo({ poId, note });
|
// Approvals carry the Manager's advance decision (resolved amount, not %).
|
||||||
else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true });
|
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 === "reject") result = await rejectPo({ poId, note });
|
||||||
else if (action === "request_edits") result = await requestEdits({ poId, note });
|
else if (action === "request_edits") result = await requestEdits({ poId, note });
|
||||||
else if (action === "request_vendor_id") result = await requestVendorId({ poId });
|
else if (action === "request_vendor_id") result = await requestVendorId({ poId });
|
||||||
|
|
@ -45,6 +64,37 @@ export function ApprovalActions({
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
|
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
|
||||||
<h3 className="text-base font-semibold text-neutral-900 mb-4">Decision</h3>
|
<h3 className="text-base font-semibold text-neutral-900 mb-4">Decision</h3>
|
||||||
|
|
||||||
|
{/* Advance payment (issue #92) — Manager decides how much Accounts pays
|
||||||
|
first. 100% = full payment; lower values seed the first part-payment. */}
|
||||||
|
<div className="mb-5 rounded-lg border border-neutral-200 bg-neutral-50 p-3.5">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label htmlFor="advance-slider" className="text-sm font-medium text-neutral-700">
|
||||||
|
Advance payment
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
|
||||||
|
{advancePercent}% · {formatCurrency(advance, currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="advance-slider"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={advancePercent}
|
||||||
|
onChange={(e) => setAdvancePercent(Number(e.target.value))}
|
||||||
|
className="w-full accent-primary-600"
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
|
{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.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
|
{(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
|
|
||||||
type SerializedLineItem = {
|
type SerializedLineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +40,7 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
|
|
@ -51,7 +53,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
|
||||||
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }: Props) {
|
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -230,7 +232,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
||||||
<label className={LABEL}>Place of Delivery</label>
|
<label className={LABEL}>Place of Delivery</label>
|
||||||
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
|
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Vendor */}
|
{/* Vendor */}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ApprovalActions } from "./approval-actions";
|
||||||
import { PoDetail } from "@/components/po/po-detail";
|
import { PoDetail } from "@/components/po/po-detail";
|
||||||
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
|
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||||
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
const hasSignature = !!(currentUser?.signatureKey);
|
const hasSignature = !!(currentUser?.signatureKey);
|
||||||
|
|
||||||
const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
|
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
||||||
db.purchaseOrder.findUnique({
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -52,12 +53,14 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
|
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -98,12 +101,18 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 md:mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
{hasSignature ? (
|
{hasSignature ? (
|
||||||
<ApprovalActions poId={po.id} poStatus={po.status} />
|
<ApprovalActions
|
||||||
|
poId={po.id}
|
||||||
|
poStatus={po.status}
|
||||||
|
totalAmount={Number(po.totalAmount)}
|
||||||
|
currency={po.currency}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3">
|
<div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3">
|
||||||
<span className="text-warning-500 text-xl leading-none mt-0.5">✎</span>
|
<span className="text-warning-500 text-xl leading-none mt-0.5">✎</span>
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
||||||
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
||||||
{c.source === "EX_HAND" && (
|
{c.type === "EX_HAND" && (
|
||||||
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
|
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{c.source === "EX_HAND" && (
|
{c.type === "EX_HAND" && (
|
||||||
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
|
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
|
||||||
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
|
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
|
||||||
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,6 @@ function parse(formData: FormData) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// An EX_HAND source means a returning crew member; everyone else is NEW. The
|
|
||||||
// CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE.
|
|
||||||
function derive(source: CandidateSource) {
|
|
||||||
const isExHand = source === "EX_HAND";
|
|
||||||
return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store an optional CV upload and return its storage key (null if none).
|
// Store an optional CV upload and return its storage key (null if none).
|
||||||
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
||||||
const file = formData.get("cv");
|
const file = formData.get("cv");
|
||||||
|
|
@ -74,53 +67,53 @@ export async function addCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
const parsed = parse(formData);
|
const parsed = parse(formData);
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
const { type, status } = derive(d.source);
|
|
||||||
|
|
||||||
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
|
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
|
||||||
// candidate (not already tagged EX_HAND) is matched to their existing EX_HAND
|
// candidate is matched to their existing EX_HAND pool record by a stable key —
|
||||||
// pool record by a stable key — email when given, else an exact name match —
|
// email when given, else an exact name match — and the SAME row is reused (so
|
||||||
// and the SAME row is reused (so their tour history, documents and bank stay on
|
// their tour history, documents and bank stay on file) rather than creating a
|
||||||
// file) rather than creating a duplicate. (Heuristic: with no DOB on file a
|
// duplicate. (Ex-hand is set by the office on the admin crew record; the
|
||||||
|
// candidate form never tags it directly. Heuristic: with no DOB on file a
|
||||||
// name-only match can in theory collide; email is preferred when available.)
|
// name-only match can in theory collide; email is preferred when available.)
|
||||||
if (d.source !== "EX_HAND") {
|
const match = await db.crewMember.findFirst({
|
||||||
const match = await db.crewMember.findFirst({
|
where: {
|
||||||
where: {
|
status: "EX_HAND",
|
||||||
status: "EX_HAND",
|
...(d.email
|
||||||
...(d.email
|
? { email: { equals: d.email, mode: "insensitive" } }
|
||||||
? { email: { equals: d.email, mode: "insensitive" } }
|
: { name: { equals: d.name, mode: "insensitive" } }),
|
||||||
: { name: { equals: d.name, mode: "insensitive" } }),
|
},
|
||||||
|
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
const updated = await db.crewMember.update({
|
||||||
|
where: { id: match.id },
|
||||||
|
data: {
|
||||||
|
// Keep EX_HAND type/status; refresh the application's details, never
|
||||||
|
// discarding prior history (take the larger recorded experience).
|
||||||
|
appliedRankId: d.appliedRankId || match.appliedRankId,
|
||||||
|
currentRankId: d.currentRankId || match.currentRankId,
|
||||||
|
email: d.email || match.email,
|
||||||
|
phone: d.phone || match.phone,
|
||||||
|
notes: d.notes || match.notes,
|
||||||
|
experienceMonths: Math.max(d.experienceMonths, match.experienceMonths),
|
||||||
|
vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } },
|
||||||
},
|
},
|
||||||
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
|
|
||||||
});
|
});
|
||||||
if (match) {
|
const cvKey = await storeCv(formData, updated.id);
|
||||||
const updated = await db.crewMember.update({
|
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
|
||||||
where: { id: match.id },
|
revalidatePath(LIST_PATH);
|
||||||
data: {
|
return { ok: true, id: updated.id };
|
||||||
// Keep EX_HAND type/status; refresh the application's details, never
|
|
||||||
// discarding prior history (take the larger recorded experience).
|
|
||||||
appliedRankId: d.appliedRankId || match.appliedRankId,
|
|
||||||
currentRankId: d.currentRankId || match.currentRankId,
|
|
||||||
email: d.email || match.email,
|
|
||||||
phone: d.phone || match.phone,
|
|
||||||
notes: d.notes || match.notes,
|
|
||||||
experienceMonths: Math.max(d.experienceMonths, match.experienceMonths),
|
|
||||||
vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience,
|
|
||||||
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const cvKey = await storeCv(formData, updated.id);
|
|
||||||
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
|
|
||||||
revalidatePath(LIST_PATH);
|
|
||||||
return { ok: true, id: updated.id };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = await db.crewMember.create({
|
const candidate = await db.crewMember.create({
|
||||||
data: {
|
data: {
|
||||||
name: d.name,
|
name: d.name,
|
||||||
source: d.source,
|
source: d.source,
|
||||||
type,
|
// The candidate form always intakes a fresh NEW candidate. Ex-hand status
|
||||||
status,
|
// is an office/admin designation set on the crew record, not here.
|
||||||
|
type: "NEW",
|
||||||
|
status: "CANDIDATE",
|
||||||
appliedRankId: d.appliedRankId || null,
|
appliedRankId: d.appliedRankId || null,
|
||||||
currentRankId: d.currentRankId || null,
|
currentRankId: d.currentRankId || null,
|
||||||
experienceMonths: d.experienceMonths,
|
experienceMonths: d.experienceMonths,
|
||||||
|
|
@ -149,7 +142,6 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
|
||||||
const parsed = parse(formData);
|
const parsed = parse(formData);
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
const { type, status } = derive(d.source);
|
|
||||||
|
|
||||||
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
||||||
if (!existing) return { error: "Candidate not found" };
|
if (!existing) return { error: "Candidate not found" };
|
||||||
|
|
@ -161,9 +153,8 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
|
||||||
data: {
|
data: {
|
||||||
name: d.name,
|
name: d.name,
|
||||||
source: d.source,
|
source: d.source,
|
||||||
// Don't downgrade an onboarded employee back to a candidate via an edit.
|
// type/status are left untouched — ex-hand / employee designation is owned
|
||||||
type,
|
// by the office (admin crew record + sign-off), never by a candidate edit.
|
||||||
status: existing.status === "EMPLOYEE" ? existing.status : status,
|
|
||||||
appliedRankId: d.appliedRankId || null,
|
appliedRankId: d.appliedRankId || null,
|
||||||
currentRankId: d.currentRankId || null,
|
currentRankId: d.currentRankId || null,
|
||||||
experienceMonths: d.experienceMonths,
|
experienceMonths: d.experienceMonths,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import type { CandidateSource } from "@prisma/client";
|
import type { CandidateSource } from "@prisma/client";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { addCandidate, updateCandidate } from "./actions";
|
import { addCandidate, updateCandidate } from "./actions";
|
||||||
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
"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";
|
"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";
|
||||||
|
|
@ -46,7 +46,7 @@ function CandidateFields({
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
|
||||||
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
|
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
|
||||||
{SOURCE_OPTIONS.map((s) => (
|
{FORM_SOURCE_OPTIONS.map((s) => (
|
||||||
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -64,7 +64,7 @@ function CandidateFields({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held</label>
|
||||||
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{ranks.map((r) => (
|
{ranks.map((r) => (
|
||||||
|
|
@ -131,7 +131,9 @@ function emptyState(): FieldState {
|
||||||
function stateFrom(c: EditableCandidate): FieldState {
|
function stateFrom(c: EditableCandidate): FieldState {
|
||||||
return {
|
return {
|
||||||
name: c.name,
|
name: c.name,
|
||||||
source: c.source,
|
// Ex-hand is an admin-only designation; the candidate form only edits origin.
|
||||||
|
// Legacy rows may carry the EX_HAND source — show a sensible origin instead.
|
||||||
|
source: c.source === "EX_HAND" ? "CAREERS" : c.source,
|
||||||
appliedRankId: c.appliedRankId ?? "",
|
appliedRankId: c.appliedRankId ?? "",
|
||||||
currentRankId: c.currentRankId ?? "",
|
currentRankId: c.currentRankId ?? "",
|
||||||
experienceMonths: String(c.experienceMonths),
|
experienceMonths: String(c.experienceMonths),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ export const SOURCE_LABEL: Record<CandidateSource, string> = {
|
||||||
|
|
||||||
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
||||||
|
|
||||||
|
// Ex-hand is now its own checkbox (not a source) — the Add/Edit form offers only
|
||||||
|
// the real origins. EX_HAND stays in the enum/label for legacy rows created
|
||||||
|
// before the split.
|
||||||
|
export const FORM_SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "WALK_IN", "REFERRAL", "OTHER"];
|
||||||
|
|
||||||
export const STATUS_LABEL: Record<CrewStatus, string> = {
|
export const STATUS_LABEL: Record<CrewStatus, string> = {
|
||||||
PROSPECT: "Prospect",
|
PROSPECT: "Prospect",
|
||||||
CANDIDATE: "Candidate",
|
CANDIDATE: "Candidate",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { CandidateSource, CrewStatus } from "@prisma/client";
|
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
||||||
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
||||||
|
|
@ -12,6 +12,7 @@ type CandidateRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
source: CandidateSource;
|
source: CandidateSource;
|
||||||
|
type: CandidateType;
|
||||||
status: CrewStatus;
|
status: CrewStatus;
|
||||||
appliedRankId: string | null;
|
appliedRankId: string | null;
|
||||||
appliedRank: string | null;
|
appliedRank: string | null;
|
||||||
|
|
@ -54,13 +55,12 @@ function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
|
||||||
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
||||||
|
{c.type === "EX_HAND" && (
|
||||||
|
<span className="ml-2 rounded-full bg-purple-100 text-purple-700 px-2 py-0.5 text-[10px] font-medium align-middle">Ex-hand</span>
|
||||||
|
)}
|
||||||
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3 text-neutral-600 text-sm">{SOURCE_LABEL[c.source]}</td>
|
||||||
<span className={c.source === "EX_HAND" ? "text-purple-700 font-medium text-sm" : "text-neutral-600 text-sm"}>
|
|
||||||
{SOURCE_LABEL[c.source]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export default async function CandidatesPage() {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
source: c.source,
|
source: c.source,
|
||||||
|
type: c.type,
|
||||||
status: c.status,
|
status: c.status,
|
||||||
appliedRankId: c.appliedRankId,
|
appliedRankId: c.appliedRankId,
|
||||||
appliedRank: c.appliedRank?.name ?? null,
|
appliedRank: c.appliedRank?.name ?? null,
|
||||||
|
|
|
||||||
|
|
@ -304,10 +304,13 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
|
||||||
source: "internal",
|
source: "internal",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand.
|
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a
|
||||||
|
// returning hand. The ex-hand flag lives on type/status — their original
|
||||||
|
// source (how they were first recruited) is preserved. currentRank (rank
|
||||||
|
// held) is refreshed to the tour they just signed off from.
|
||||||
await tx.crewMember.update({
|
await tx.crewMember.update({
|
||||||
where: { id: assignment.crewMemberId },
|
where: { id: assignment.crewMemberId },
|
||||||
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId },
|
data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
|
||||||
});
|
});
|
||||||
await tx.crewAction.create({
|
await tx.crewAction.create({
|
||||||
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
|
|
@ -27,7 +27,14 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
// Report-export holders see History; submitters get read+export access when the
|
||||||
|
// submitter-view-all feature flag is on.
|
||||||
|
if (
|
||||||
|
!hasPermission(session.user.role, "export_reports") &&
|
||||||
|
!submitterCanViewAll(session.user.role)
|
||||||
|
) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,25 @@ export default async function PaymentsPage() {
|
||||||
Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)}
|
Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Manager's advance decision (issue #92) — shown until the first payment lands. */}
|
||||||
|
{po.status === "SENT_FOR_PAYMENT" &&
|
||||||
|
po.paidAmount == null &&
|
||||||
|
po.suggestedAdvancePayment != null &&
|
||||||
|
Number(po.suggestedAdvancePayment) < Number(po.totalAmount) && (
|
||||||
|
<span className="text-xs text-primary-700">
|
||||||
|
Advance requested: {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} of{" "}
|
||||||
|
{formatCurrency(Number(po.totalAmount), po.currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PaymentActions
|
<PaymentActions
|
||||||
poId={po.id}
|
poId={po.id}
|
||||||
poStatus={po.status}
|
poStatus={po.status}
|
||||||
totalAmount={Number(po.totalAmount)}
|
totalAmount={Number(po.totalAmount)}
|
||||||
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : 0}
|
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : 0}
|
||||||
|
suggestedAdvancePayment={
|
||||||
|
po.suggestedAdvancePayment != null ? Number(po.suggestedAdvancePayment) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ interface Props {
|
||||||
poStatus: POStatus;
|
poStatus: POStatus;
|
||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
paidAmount?: number;
|
paidAmount?: number;
|
||||||
|
// Manager's advance decision (issue #92) — absolute amount. Prefills the FIRST
|
||||||
|
// payment's amount field; ignored once any payment has been recorded.
|
||||||
|
suggestedAdvancePayment?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Today's date as a local yyyy-mm-dd string (for <input type="date"> default + max)
|
// Today's date as a local yyyy-mm-dd string (for <input type="date"> default + max)
|
||||||
|
|
@ -19,15 +22,33 @@ function todayLocal(): string {
|
||||||
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
|
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
|
export function PaymentActions({
|
||||||
|
poId,
|
||||||
|
poStatus,
|
||||||
|
totalAmount = 0,
|
||||||
|
paidAmount = 0,
|
||||||
|
suggestedAdvancePayment = null,
|
||||||
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const remaining = totalAmount - paidAmount;
|
||||||
|
|
||||||
|
// Prefill the first payment with the Manager's advance, when it's a genuine
|
||||||
|
// partial of the (untouched) total. Nothing paid yet ⇒ first payment; a full
|
||||||
|
// (>= total) advance leaves the field blank so "Confirm Full Payment" is used.
|
||||||
|
const advancePrefill =
|
||||||
|
paidAmount === 0 &&
|
||||||
|
suggestedAdvancePayment != null &&
|
||||||
|
suggestedAdvancePayment > 0 &&
|
||||||
|
suggestedAdvancePayment < remaining
|
||||||
|
? String(suggestedAdvancePayment)
|
||||||
|
: "";
|
||||||
|
|
||||||
const [ref, setRef] = useState("");
|
const [ref, setRef] = useState("");
|
||||||
const [amount, setAmount] = useState<string>("");
|
const [amount, setAmount] = useState<string>(advancePrefill);
|
||||||
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
|
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const remaining = totalAmount - paidAmount;
|
|
||||||
const today = todayLocal();
|
const today = todayLocal();
|
||||||
|
|
||||||
async function handleProcessPayment() {
|
async function handleProcessPayment() {
|
||||||
|
|
@ -120,6 +141,11 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
|
||||||
className="w-full sm:w-36 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 sm:w-36 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>
|
||||||
|
{advancePrefill && (
|
||||||
|
<span className="text-xs text-primary-700">
|
||||||
|
Manager set an advance of {Number(suggestedAdvancePayment).toFixed(2)} — prefilled below; adjust if needed.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{error && <span className="text-xs text-danger-700">{error}</span>}
|
{error && <span className="text-xs text-danger-700">{error}</span>}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
{isPartialPayment && (
|
{isPartialPayment && (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
||||||
|
|
||||||
|
|
@ -40,10 +41,11 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
managerNoteAuthor?: string | null;
|
managerNoteAuthor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditPoForm({ po, vessels, accounts, vendors, companies, managerNoteAuthor }: Props) {
|
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, managerNoteAuthor }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
po.lineItems.map((li) => ({
|
po.lineItems.map((li) => ({
|
||||||
|
|
@ -229,7 +231,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
<textarea name="placeOfDelivery" rows={2} className={INPUT_CLS} defaultValue={extPo.placeOfDelivery ?? ""} />
|
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT_CLS} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { db } from "@/lib/db";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { EditPoForm } from "./edit-po-form";
|
import { EditPoForm } from "./edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
|
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||||
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
if (!canEdit) redirect(`/po/${id}`);
|
if (!canEdit) redirect(`/po/${id}`);
|
||||||
|
|
||||||
const [vessels, leafAccounts, vendors, companies, noteAction] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
|
@ -38,6 +39,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
|
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||||
po.status === "EDITS_REQUESTED"
|
po.status === "EDITS_REQUESTED"
|
||||||
? db.pOAction.findFirst({
|
? db.pOAction.findFirst({
|
||||||
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
||||||
|
|
@ -48,6 +50,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -73,6 +76,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { PoDetail } from "@/components/po/po-detail";
|
import { PoDetail } from "@/components/po/po-detail";
|
||||||
|
import { canViewAllPos } from "@/lib/permissions";
|
||||||
import { VendorIdForm } from "./vendor-id-form";
|
import { VendorIdForm } from "./vendor-id-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -39,11 +40,11 @@ export default async function PoDetailPage({ params }: Props) {
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
|
|
||||||
// Submitters can only view their own POs (unless they have view_all_pos)
|
// Submitters can only view their own POs — unless they hold view_all_pos, or the
|
||||||
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(
|
// submitter-view-all feature flag grants them read access to every PO.
|
||||||
session.user.role
|
if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
|
||||||
);
|
redirect("/dashboard");
|
||||||
if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard");
|
}
|
||||||
|
|
||||||
const canProvideVendorId =
|
const canProvideVendorId =
|
||||||
po.status === "VENDOR_ID_PENDING" &&
|
po.status === "VENDOR_ID_PENDING" &&
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Vendor } from "@prisma/client";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { FileUploader } from "@/components/po/file-uploader";
|
import { FileUploader } from "@/components/po/file-uploader";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
||||||
|
|
@ -25,13 +26,14 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
initialLineItems?: LineItemInput[];
|
initialLineItems?: LineItemInput[];
|
||||||
initialVendorId?: string;
|
initialVendorId?: string;
|
||||||
initialVesselId?: string;
|
initialVesselId?: string;
|
||||||
initialCompanyId?: string;
|
initialCompanyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewPoForm({ vessels, accounts, vendors, companies, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||||
|
|
@ -194,12 +196,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
<textarea
|
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
|
||||||
name="placeOfDelivery"
|
{deliveryOptions.length === 0 && (
|
||||||
rows={2}
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
className={INPUT_CLS}
|
No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations.
|
||||||
defaultValue="Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614"
|
</p>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { NewPoForm } from "./new-po-form";
|
import { NewPoForm } from "./new-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
|
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { CartItem } from "@/lib/cart";
|
import type { CartItem } from "@/lib/cart";
|
||||||
|
|
@ -46,7 +47,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [vessels, leafAccounts, vendors, companies] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
|
@ -55,9 +56,11 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
|
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
|
|
@ -72,6 +75,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
initialLineItems={initialLineItems}
|
initialLineItems={initialLineItems}
|
||||||
initialVendorId={initialVendorId}
|
initialVendorId={initialVendorId}
|
||||||
initialVesselId={initialVesselId}
|
initialVesselId={initialVesselId}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { downloadBuffer } from "@/lib/storage";
|
||||||
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
||||||
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||||
import { signatoryLayout } from "@/lib/po-export-layout";
|
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||||
|
import { canViewAllPos } from "@/lib/permissions";
|
||||||
|
|
||||||
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
||||||
|
|
||||||
|
|
@ -73,8 +74,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
if (!isService) {
|
if (!isService) {
|
||||||
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session!.user.role);
|
// view_all_pos holders, or submitters when the view-all feature flag is on, may export
|
||||||
if (!canViewAll && po.submitterId !== session!.user.id) {
|
// any PO; everyone else only their own. (PdfService bypasses this — read-only, PDF only.)
|
||||||
|
if (!canViewAllPos(session!.user.role) && po.submitterId !== session!.user.id) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import type { POStatus } from "@prisma/client";
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -16,7 +16,10 @@ export async function GET(request: NextRequest) {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (!hasPermission(session.user.role, "export_reports")) {
|
if (
|
||||||
|
!hasPermission(session.user.role, "export_reports") &&
|
||||||
|
!submitterCanViewAll(session.user.role)
|
||||||
|
) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
|
import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
UserCog,
|
UserCog,
|
||||||
Gauge,
|
Gauge,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Truck,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
@ -45,6 +46,13 @@ interface NavItem {
|
||||||
roles?: Role[];
|
roles?: Role[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History is open to all-PO viewers; when the submitter-view-all flag is on, submitters
|
||||||
|
// (TECHNICAL / MANNING) get read+export access to it too.
|
||||||
|
const HISTORY_ROLES: Role[] = [
|
||||||
|
"MANAGER", "SUPERUSER", "AUDITOR", "ADMIN",
|
||||||
|
...(SUBMITTER_VIEW_ALL_ENABLED ? (["TECHNICAL", "MANNING"] as Role[]) : []),
|
||||||
|
];
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
|
||||||
|
|
@ -53,7 +61,7 @@ const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
|
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
|
||||||
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
||||||
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
|
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
|
||||||
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] },
|
{ href: "/history", label: "History", icon: History, roles: HISTORY_ROLES },
|
||||||
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -97,8 +105,9 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
||||||
// ── Administration section ────────────────────────────────────────────────────
|
// ── Administration section ────────────────────────────────────────────────────
|
||||||
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
||||||
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||||
|
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
||||||
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
||||||
...(CREWING_ENABLED
|
...(CREWING_ENABLED
|
||||||
? [
|
? [
|
||||||
|
|
|
||||||
36
App/components/po/delivery-location-field.tsx
Normal file
36
App/components/po/delivery-location-field.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place-of-Delivery dropdown (issue #19) — a native <select name="placeOfDelivery">
|
||||||
|
* sourced from the admin-managed delivery locations. Plain HTML so it works with
|
||||||
|
* the forms' native FormData submission (no client state needed).
|
||||||
|
*
|
||||||
|
* `options` are the formatted "Company — address" strings (also the stored value).
|
||||||
|
* `current` is the PO's existing place-of-delivery; if it isn't one of the active
|
||||||
|
* options (legacy / imported / a since-removed location) it is preserved as a
|
||||||
|
* leading "(current)" option so an edit never silently drops it.
|
||||||
|
*/
|
||||||
|
export function DeliveryLocationField({
|
||||||
|
options,
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
options: string[];
|
||||||
|
current?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const cur = (current ?? "").trim();
|
||||||
|
const currentMissing = cur.length > 0 && !options.includes(cur);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select name="placeOfDelivery" defaultValue={cur} className={className}>
|
||||||
|
<option value="">— Select a delivery location —</option>
|
||||||
|
{currentMissing && <option value={cur}>{cur} (current)</option>}
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ type PoWithRelations = {
|
||||||
paymentRef: string | null;
|
paymentRef: string | null;
|
||||||
paymentDate?: Date | null;
|
paymentDate?: Date | null;
|
||||||
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
||||||
|
suggestedAdvancePayment?: import("@prisma/client").Prisma.Decimal | null;
|
||||||
piQuotationNo?: string | null;
|
piQuotationNo?: string | null;
|
||||||
piQuotationDate?: Date | null;
|
piQuotationDate?: Date | null;
|
||||||
requisitionNo?: string | null;
|
requisitionNo?: string | null;
|
||||||
|
|
@ -298,6 +299,21 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Manager's advance-payment decision (issue #92) — a partial advance set
|
||||||
|
at approval. Shown to Accounts/Manager from approval through payment. */}
|
||||||
|
{po.suggestedAdvancePayment != null &&
|
||||||
|
Number(po.suggestedAdvancePayment) < Number(po.totalAmount) &&
|
||||||
|
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID"].includes(po.status) && (
|
||||||
|
<div className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-primary-700 mb-0.5">Advance payment requested</p>
|
||||||
|
<p className="text-sm text-primary-700">
|
||||||
|
Pay {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} first (of{" "}
|
||||||
|
{formatCurrency(Number(po.totalAmount), po.currency)}). The balance follows the usual
|
||||||
|
part-payment flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
|
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
|
||||||
{resubmitSnapshot &&
|
{resubmitSnapshot &&
|
||||||
po.status === "MGR_REVIEW" &&
|
po.status === "MGR_REVIEW" &&
|
||||||
|
|
|
||||||
9
App/lib/delivery-location.ts
Normal file
9
App/lib/delivery-location.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Delivery locations (issue #19) — admin-managed destinations used to populate
|
||||||
|
* the PO "Place of Delivery" dropdown. A location is a Company + a free-text
|
||||||
|
* address; the PO stores the resolved single string below as a point-in-time
|
||||||
|
* snapshot in `PurchaseOrder.placeOfDelivery`.
|
||||||
|
*/
|
||||||
|
export function formatDeliveryLocation(companyName: string, address: string): string {
|
||||||
|
return `${companyName} — ${address}`.trim();
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,12 @@
|
||||||
* NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption)
|
* NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption)
|
||||||
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
|
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
|
||||||
*
|
*
|
||||||
|
* NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true → lets submitters (TECHNICAL / MANNING)
|
||||||
|
* read every PO (not just their own), open the History page, and use the export buttons.
|
||||||
|
* Opt-in (off unless explicitly "true") because it widens read access. Submitters stay
|
||||||
|
* read-only — it grants no approval, payment, or edit rights. See lib/permissions.ts
|
||||||
|
* (canViewAllPos / submitterCanViewAll).
|
||||||
|
*
|
||||||
* NEXT_PUBLIC_CREWING_ENABLED=true → exposes the Crewing module (crew/ranks/requisitions
|
* NEXT_PUBLIC_CREWING_ENABLED=true → exposes the Crewing module (crew/ranks/requisitions
|
||||||
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
|
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
|
||||||
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
|
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
|
||||||
|
|
@ -14,5 +20,8 @@
|
||||||
export const INVENTORY_ENABLED =
|
export const INVENTORY_ENABLED =
|
||||||
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
|
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
|
||||||
|
|
||||||
|
export const SUBMITTER_VIEW_ALL_ENABLED =
|
||||||
|
process.env.NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED === "true";
|
||||||
|
|
||||||
export const CREWING_ENABLED =
|
export const CREWING_ENABLED =
|
||||||
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
|
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags";
|
||||||
|
|
||||||
export type Permission =
|
export type Permission =
|
||||||
| "create_po"
|
| "create_po"
|
||||||
|
|
@ -21,6 +22,7 @@ export type Permission =
|
||||||
| "manage_vessels_accounts"
|
| "manage_vessels_accounts"
|
||||||
| "manage_products"
|
| "manage_products"
|
||||||
| "manage_sites"
|
| "manage_sites"
|
||||||
|
| "manage_delivery_locations"
|
||||||
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
||||||
| "raise_requisition"
|
| "raise_requisition"
|
||||||
| "request_relief_cover"
|
| "request_relief_cover"
|
||||||
|
|
@ -80,6 +82,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_vessels_accounts",
|
"manage_vessels_accounts",
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
|
"manage_delivery_locations",
|
||||||
"confirm_receipt",
|
"confirm_receipt",
|
||||||
"process_payment"
|
"process_payment"
|
||||||
],
|
],
|
||||||
|
|
@ -99,6 +102,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"view_analytics",
|
"view_analytics",
|
||||||
"export_reports",
|
"export_reports",
|
||||||
"create_vendor",
|
"create_vendor",
|
||||||
|
"manage_delivery_locations",
|
||||||
],
|
],
|
||||||
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
||||||
ADMIN: [
|
ADMIN: [
|
||||||
|
|
@ -112,6 +116,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_vessels_accounts",
|
"manage_vessels_accounts",
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
|
"manage_delivery_locations",
|
||||||
],
|
],
|
||||||
SITE_STAFF: [],
|
SITE_STAFF: [],
|
||||||
};
|
};
|
||||||
|
|
@ -237,3 +242,31 @@ export function requirePermission(role: Role, permission: Permission): void {
|
||||||
export function getPermissions(role: Role): Permission[] {
|
export function getPermissions(role: Role): Permission[] {
|
||||||
return ROLE_PERMISSIONS[role] ?? [];
|
return ROLE_PERMISSIONS[role] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Submitter roles & feature-flagged view-all ────────────────────────────────
|
||||||
|
// Submitters raise and track their own POs. The two "submitter" roles below hold
|
||||||
|
// `view_own_pos` but not `view_all_pos`.
|
||||||
|
|
||||||
|
export const SUBMITTER_ROLES: Role[] = ["TECHNICAL", "MANNING"];
|
||||||
|
|
||||||
|
export function isSubmitterRole(role: Role): boolean {
|
||||||
|
return SUBMITTER_ROLES.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature-flagged: when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true, submitters may
|
||||||
|
* read & export every PO (not just their own) and reach the History page. This is a
|
||||||
|
* read-only widening — it does not grant approval, payment, or edit rights.
|
||||||
|
*/
|
||||||
|
export function submitterCanViewAll(role: Role): boolean {
|
||||||
|
return SUBMITTER_VIEW_ALL_ENABLED && isSubmitterRole(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a role may view/export any PO, not just the ones they submitted.
|
||||||
|
* True for `view_all_pos` holders (ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN) and,
|
||||||
|
* when the feature flag is on, for submitters too.
|
||||||
|
*/
|
||||||
|
export function canViewAllPos(role: Role): boolean {
|
||||||
|
return hasPermission(role, "view_all_pos") || submitterCanViewAll(role);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,13 @@ export const createPoSchema = z.object({
|
||||||
|
|
||||||
export const approvePoSchema = z.object({
|
export const approvePoSchema = z.object({
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
|
// Absolute advance amount the Manager wants paid first (issue #92). The UI
|
||||||
|
// slider works in whole percent of totalAmount; the resolved amount is what we
|
||||||
|
// persist. Validated against the PO total in the action. Omitted ⇒ full payment.
|
||||||
|
suggestedAdvancePayment: z.coerce
|
||||||
|
.number()
|
||||||
|
.nonnegative("Advance payment cannot be negative")
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rejectPoSchema = z.object({
|
export const rejectPoSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD COLUMN "suggestedAdvancePayment" DECIMAL(12,2);
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DeliveryLocation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"companyId" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "DeliveryLocation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "DeliveryLocation_companyId_idx" ON "DeliveryLocation"("companyId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DeliveryLocation" ADD CONSTRAINT "DeliveryLocation_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -389,7 +389,25 @@ model Company {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
purchaseOrders PurchaseOrder[]
|
||||||
|
deliveryLocations DeliveryLocation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-managed delivery destinations (issue #19). Each is a Company + a
|
||||||
|
// free-text address; the PO "Place of Delivery" field becomes a dropdown sourced
|
||||||
|
// from these. The PO stores the resolved text snapshot in
|
||||||
|
// PurchaseOrder.placeOfDelivery (point-in-time document), so deleting/editing a
|
||||||
|
// location never rewrites historical POs. Managed by manage_delivery_locations.
|
||||||
|
model DeliveryLocation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
companyId String
|
||||||
|
company Company @relation(fields: [companyId], references: [id])
|
||||||
|
address String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([companyId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
|
|
@ -512,6 +530,17 @@ model PurchaseOrder {
|
||||||
paymentRef String?
|
paymentRef String?
|
||||||
paymentDate DateTime?
|
paymentDate DateTime?
|
||||||
paidAmount Decimal? @db.Decimal(12, 2)
|
paidAmount Decimal? @db.Decimal(12, 2)
|
||||||
|
// Advance the approving Manager wants paid first (absolute amount, not %).
|
||||||
|
// The approval slider (0–100% of totalAmount) is convenience only — the
|
||||||
|
// resolved amount is stored here. Null on legacy/pre-feature POs ⇒ no explicit
|
||||||
|
// advance, so Accounts defaults to the full remaining balance. Set once at
|
||||||
|
// approval and not edited afterwards (issue #92).
|
||||||
|
//
|
||||||
|
// NOTE (issue #91): this IS the "exact sum due for payment" for an ADVANCE/PART
|
||||||
|
// request. When the structured payment-request lane (payment-term enum +
|
||||||
|
// separate approval) is built, reuse this column for the requested amount
|
||||||
|
// rather than adding a parallel "exact sum" field.
|
||||||
|
suggestedAdvancePayment Decimal? @db.Decimal(12, 2)
|
||||||
piQuotationNo String?
|
piQuotationNo String?
|
||||||
piQuotationDate DateTime?
|
piQuotationDate DateTime?
|
||||||
requisitionNo String?
|
requisitionNo String?
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,46 @@ describe("M-02 — approve PO", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── #92: Advance payment decided at approval ─────────────────────────────────
|
||||||
|
|
||||||
|
describe("issue #92 — advance payment on approval", () => {
|
||||||
|
it("persists the manager's advance amount and records it on the audit row", async () => {
|
||||||
|
const poId = await createSubmittedPo(`${PREFIX}Advance`);
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
const half = Math.round(Number(before.totalAmount) / 2);
|
||||||
|
|
||||||
|
const result = await approvePo({ poId, suggestedAdvancePayment: half });
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
expect(po.status).toBe("MGR_APPROVED");
|
||||||
|
expect(Number(po.suggestedAdvancePayment)).toBe(half);
|
||||||
|
|
||||||
|
const action = await db.pOAction.findFirst({ where: { poId, actionType: "APPROVED" } });
|
||||||
|
expect((action?.metadata as { suggestedAdvancePayment?: number } | null)?.suggestedAdvancePayment).toBe(half);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to null (full payment) when no advance is provided", async () => {
|
||||||
|
const poId = await createSubmittedPo(`${PREFIX}AdvanceNone`);
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
await approvePo({ poId });
|
||||||
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
expect(po.suggestedAdvancePayment).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an advance above the PO total down to the total", async () => {
|
||||||
|
const poId = await createSubmittedPo(`${PREFIX}AdvanceClamp`);
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
const total = Number(before.totalAmount);
|
||||||
|
|
||||||
|
await approvePo({ poId, suggestedAdvancePayment: total + 5000 });
|
||||||
|
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||||
|
expect(Number(po.suggestedAdvancePayment)).toBe(total);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── M-03: Reject ──────────────────────────────────────────────────────────────
|
// ── M-03: Reject ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("M-03 — reject PO", () => {
|
describe("M-03 — reject PO", () => {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,21 @@ const SS_EMAIL = "sitestaff@itcand.local";
|
||||||
const as = (userId: string, role: Role) =>
|
const as = (userId: string, role: Role) =>
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||||
|
|
||||||
|
// Ex-hand is an office/admin designation (set on the admin crew record, not the
|
||||||
|
// candidate form) — seed such rows directly for the recognition tests.
|
||||||
|
const seedExHand = (data: { name: string; email?: string; experienceMonths?: number }) =>
|
||||||
|
db.crewMember.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
type: "EX_HAND",
|
||||||
|
status: "EX_HAND",
|
||||||
|
source: "CAREERS",
|
||||||
|
email: data.email ?? null,
|
||||||
|
experienceMonths: data.experienceMonths ?? 0,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: managerId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
||||||
const ss = await db.user.upsert({
|
const ss = await db.user.upsert({
|
||||||
|
|
@ -71,12 +86,14 @@ describe("addCandidate", () => {
|
||||||
expect(c.actions[0].actorId).toBe(managerId);
|
expect(c.actions[0].actorId).toBe(managerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
|
it("candidate intake always creates a NEW candidate — ex-hand is admin-only", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
|
// Even if an ex-hand hint is smuggled into the form data, intake stays
|
||||||
|
// NEW/CANDIDATE; ex-hand is set only on the admin crew record.
|
||||||
|
await addCandidate(fd({ name: "Returning Ravi", source: "CAREERS", isExHand: "true" }));
|
||||||
const c = await db.crewMember.findFirstOrThrow();
|
const c = await db.crewMember.findFirstOrThrow();
|
||||||
expect(c.type).toBe("EX_HAND");
|
expect(c.type).toBe("NEW");
|
||||||
expect(c.status).toBe("EX_HAND");
|
expect(c.status).toBe("CANDIDATE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires a name", async () => {
|
it("requires a name", async () => {
|
||||||
|
|
@ -98,8 +115,7 @@ describe("addCandidate", () => {
|
||||||
describe("ex-hand recognition + ordering (B3)", () => {
|
describe("ex-hand recognition + ordering (B3)", () => {
|
||||||
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
|
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" }));
|
const exhand = await seedExHand({ name: "Ravi Old", email: "ravi@ex.com", experienceMonths: 120 });
|
||||||
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
|
|
||||||
|
|
||||||
// Re-applies as a fresh careers candidate with the same email → recognized.
|
// Re-applies as a fresh careers candidate with the same email → recognized.
|
||||||
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
|
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
|
||||||
|
|
@ -115,16 +131,15 @@ describe("ex-hand recognition + ordering (B3)", () => {
|
||||||
|
|
||||||
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
|
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
|
const exhand = await seedExHand({ name: "Returning Ravi" });
|
||||||
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
|
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
|
||||||
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
|
|
||||||
expect("ok" in res && res.id).toBe(exhand.id);
|
expect("ok" in res && res.id).toBe(exhand.id);
|
||||||
expect(await db.crewMember.count()).toBe(1);
|
expect(await db.crewMember.count()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not match a different person → creates a new candidate", async () => {
|
it("does not match a different person → creates a new candidate", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" }));
|
await seedExHand({ name: "Ex One", email: "one@ex.com" });
|
||||||
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
|
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
|
||||||
expect(await db.crewMember.count()).toBe(2);
|
expect(await db.crewMember.count()).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +147,7 @@ describe("ex-hand recognition + ordering (B3)", () => {
|
||||||
it("lists ex-hands above new candidates by default (AC2)", async () => {
|
it("lists ex-hands above new candidates by default (AC2)", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
|
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
|
||||||
await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" }));
|
await seedExHand({ name: "Ex Second" });
|
||||||
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
|
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
|
||||||
expect(el.props.candidates[0].status).toBe("EX_HAND");
|
expect(el.props.candidates[0].status).toBe("EX_HAND");
|
||||||
expect(el.props.candidates[0].name).toBe("Ex Second");
|
expect(el.props.candidates[0].name).toBe("Ex Second");
|
||||||
|
|
|
||||||
89
App/tests/integration/delivery-locations.test.ts
Normal file
89
App/tests/integration/delivery-locations.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for the Delivery Locations admin CRUD (issue #19).
|
||||||
|
* Covers create/update/toggle/delete + the manage_delivery_locations guard.
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
createDeliveryLocation,
|
||||||
|
updateDeliveryLocation,
|
||||||
|
toggleDeliveryLocationActive,
|
||||||
|
deleteDeliveryLocation,
|
||||||
|
} from "@/app/(portal)/admin/delivery-locations/actions";
|
||||||
|
import { makeSession, fd } from "./helpers";
|
||||||
|
|
||||||
|
const mockedAuth = vi.mocked(auth);
|
||||||
|
const PREFIX = "INTTEST_DELLOC_";
|
||||||
|
let companyId: string;
|
||||||
|
|
||||||
|
const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const company = await db.company.create({ data: { name: `${PREFIX}Co`, code: "ZZDELLOC" } });
|
||||||
|
companyId = company.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.deliveryLocation.deleteMany({ where: { companyId } });
|
||||||
|
await db.company.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDeliveryLocation", () => {
|
||||||
|
it("persists a location tied to its company", async () => {
|
||||||
|
asManager();
|
||||||
|
const result = await createDeliveryLocation(fd({ companyId, address: "Dock 4, Mumbai" }));
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const loc = await db.deliveryLocation.findFirstOrThrow({ where: { companyId, address: "Dock 4, Mumbai" } });
|
||||||
|
expect(loc.isActive).toBe(true);
|
||||||
|
expect(loc.companyId).toBe(companyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires both a company and an address", async () => {
|
||||||
|
asManager();
|
||||||
|
expect("error" in (await createDeliveryLocation(fd({ companyId, address: " " })))).toBe(true);
|
||||||
|
expect("error" in (await createDeliveryLocation(fd({ companyId: "", address: "x" })))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a company that no longer exists", async () => {
|
||||||
|
asManager();
|
||||||
|
const result = await createDeliveryLocation(fd({ companyId: "nonexistent", address: "x" }));
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses callers without manage_delivery_locations", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
expect(await createDeliveryLocation(fd({ companyId, address: "x" }))).toEqual({ error: "Forbidden" });
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
|
||||||
|
expect(await createDeliveryLocation(fd({ companyId, address: "x" }))).toEqual({ error: "Forbidden" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateDeliveryLocation / toggle / delete", () => {
|
||||||
|
it("edits, toggles active, then deletes a location", async () => {
|
||||||
|
asManager();
|
||||||
|
await createDeliveryLocation(fd({ companyId, address: "Old Address" }));
|
||||||
|
const loc = await db.deliveryLocation.findFirstOrThrow({ where: { companyId, address: "Old Address" } });
|
||||||
|
|
||||||
|
expect(await updateDeliveryLocation(loc.id, fd({ companyId, address: "New Address" }))).toEqual({ ok: true });
|
||||||
|
expect((await db.deliveryLocation.findUniqueOrThrow({ where: { id: loc.id } })).address).toBe("New Address");
|
||||||
|
|
||||||
|
expect(await toggleDeliveryLocationActive(loc.id)).toEqual({ ok: true });
|
||||||
|
expect((await db.deliveryLocation.findUniqueOrThrow({ where: { id: loc.id } })).isActive).toBe(false);
|
||||||
|
|
||||||
|
expect(await deleteDeliveryLocation(loc.id)).toEqual({ ok: true });
|
||||||
|
expect(await db.deliveryLocation.findUnique({ where: { id: loc.id } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards update/toggle/delete behind the permission", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
expect(await updateDeliveryLocation("x", fd({ companyId, address: "y" }))).toEqual({ error: "Forbidden" });
|
||||||
|
expect(await toggleDeliveryLocationActive("x")).toEqual({ error: "Forbidden" });
|
||||||
|
expect(await deleteDeliveryLocation("x")).toEqual({ error: "Forbidden" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { hasPermission, requirePermission } from "@/lib/permissions";
|
import {
|
||||||
|
hasPermission,
|
||||||
|
requirePermission,
|
||||||
|
isSubmitterRole,
|
||||||
|
submitterCanViewAll,
|
||||||
|
canViewAllPos,
|
||||||
|
} from "@/lib/permissions";
|
||||||
|
|
||||||
describe("Permissions", () => {
|
describe("Permissions", () => {
|
||||||
describe("hasPermission", () => {
|
describe("hasPermission", () => {
|
||||||
|
|
@ -99,6 +105,64 @@ describe("Permissions", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Submitter view-all (feature-flagged) ──────────────────────────────────
|
||||||
|
describe("isSubmitterRole", () => {
|
||||||
|
it("is true for the two submitter roles", () => {
|
||||||
|
expect(isSubmitterRole("TECHNICAL")).toBe(true);
|
||||||
|
expect(isSubmitterRole("MANNING")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for every other role", () => {
|
||||||
|
for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) {
|
||||||
|
expect(isSubmitterRole(role)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canViewAllPos / submitterCanViewAll — flag OFF (default)", () => {
|
||||||
|
it("submitters cannot view all POs", () => {
|
||||||
|
expect(canViewAllPos("TECHNICAL")).toBe(false);
|
||||||
|
expect(canViewAllPos("MANNING")).toBe(false);
|
||||||
|
expect(submitterCanViewAll("TECHNICAL")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("view_all_pos holders can still view all POs", () => {
|
||||||
|
for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) {
|
||||||
|
expect(canViewAllPos(role)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canViewAllPos / submitterCanViewAll — flag ON", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitters gain view-all when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true");
|
||||||
|
const perms = await import("@/lib/permissions");
|
||||||
|
|
||||||
|
expect(perms.submitterCanViewAll("TECHNICAL")).toBe(true);
|
||||||
|
expect(perms.submitterCanViewAll("MANNING")).toBe(true);
|
||||||
|
expect(perms.canViewAllPos("TECHNICAL")).toBe(true);
|
||||||
|
expect(perms.canViewAllPos("MANNING")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not widen non-submitter roles, and is read-only (no approve/edit)", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true");
|
||||||
|
const perms = await import("@/lib/permissions");
|
||||||
|
|
||||||
|
expect(perms.submitterCanViewAll("MANAGER")).toBe(false);
|
||||||
|
expect(perms.canViewAllPos("ACCOUNTS")).toBe(true); // unchanged
|
||||||
|
// The flag grants read access only — no approval or edit rights.
|
||||||
|
expect(perms.hasPermission("TECHNICAL", "approve_po")).toBe(false);
|
||||||
|
expect(perms.hasPermission("TECHNICAL", "view_all_pos")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("requirePermission", () => {
|
describe("requirePermission", () => {
|
||||||
it("does not throw when permission is granted", () => {
|
it("does not throw when permission is granted", () => {
|
||||||
expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow();
|
expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue