Renames the product-catalogue pages (items + vendors, incl. their [id] detail
pages) out of /inventory into /catalogue. /inventory/cart is unchanged. All
internal links, redirects, revalidatePath calls, sidebar nav, and tests are
updated; next.config redirects keep old /inventory/{items,vendors}[/...] URLs
working (permanent) so existing bookmarks don't 404.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
6.2 KiB
TypeScript
209 lines
6.2 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { canPerformAction } from "@/lib/po-state-machine";
|
|
import { approvePoSchema } from "@/lib/validations/po";
|
|
import { syncProductCatalog } from "@/lib/product-catalog";
|
|
import { notify } from "@/lib/notifier";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
type ActionResult = { ok: true } | { error: string };
|
|
|
|
export async function approvePo({
|
|
poId,
|
|
note,
|
|
withNote = false,
|
|
suggestedAdvancePayment,
|
|
}: {
|
|
poId: string;
|
|
note?: string;
|
|
withNote?: boolean;
|
|
// Absolute advance the Manager wants paid first (issue #92). Whole amount,
|
|
// resolved from the approval slider client-side. Omitted ⇒ full payment.
|
|
suggestedAdvancePayment?: number;
|
|
}): Promise<ActionResult> {
|
|
const session = await auth();
|
|
if (!session?.user) return { error: "Unauthorized" };
|
|
|
|
const parsed = approvePoSchema.safeParse({ note, suggestedAdvancePayment });
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: { submitter: true, lineItems: true },
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
|
|
const action = withNote ? "approve_with_note" : "approve";
|
|
if (!canPerformAction(po.status, action, session.user.role)) {
|
|
return { error: "You cannot approve this PO." };
|
|
}
|
|
|
|
if (!po.vendorId) {
|
|
return { error: "A vendor must be assigned before approving this PO." };
|
|
}
|
|
|
|
// Resolve the advance: clamp to [0, total]. Undefined ⇒ no explicit advance
|
|
// (full payment, current default behaviour). The slider always sends a value,
|
|
// but a malformed/over-total amount is clamped rather than rejected.
|
|
const total = Number(po.totalAmount);
|
|
const advance =
|
|
parsed.data.suggestedAdvancePayment === undefined
|
|
? null
|
|
: Math.min(Math.max(parsed.data.suggestedAdvancePayment, 0), total);
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
status: "MGR_APPROVED",
|
|
approvedAt: new Date(),
|
|
managerNote: note ?? null,
|
|
suggestedAdvancePayment: advance,
|
|
actions: {
|
|
create: {
|
|
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
|
|
note: note ?? null,
|
|
actorId: session.user.id,
|
|
metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Add line items to site inventory immediately on approval (not on closure)
|
|
const siteId = po.siteId ?? null;
|
|
if (siteId) {
|
|
for (const li of po.lineItems) {
|
|
if (!li.productId) continue;
|
|
await db.itemInventory.upsert({
|
|
where: { productId_siteId: { productId: li.productId, siteId } },
|
|
update: { quantity: { increment: Number(li.quantity) } },
|
|
create: { productId: li.productId, siteId, quantity: Number(li.quantity) },
|
|
});
|
|
}
|
|
revalidatePath(`/admin/sites/${siteId}`);
|
|
}
|
|
|
|
// Register the line items in the product catalogue (/catalogue/items) on
|
|
// approval, so an approved PO's items are immediately reusable in further POs.
|
|
// Idempotent; payment re-syncs to refresh prices on the final figures.
|
|
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
|
|
revalidatePath("/catalogue/items");
|
|
|
|
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
|
await notify({
|
|
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",
|
|
po,
|
|
recipients: [po.submitter, ...accounts],
|
|
note,
|
|
});
|
|
|
|
revalidatePath("/approvals");
|
|
revalidatePath(`/po/${poId}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function rejectPo({
|
|
poId,
|
|
note,
|
|
}: {
|
|
poId: string;
|
|
note: string;
|
|
}): Promise<ActionResult> {
|
|
const session = await auth();
|
|
if (!session?.user) return { error: "Unauthorized" };
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: { submitter: true },
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
if (!canPerformAction(po.status, "reject", session.user.role)) {
|
|
return { error: "You cannot reject this PO." };
|
|
}
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
status: "REJECTED",
|
|
managerNote: note,
|
|
actions: {
|
|
create: { actionType: "REJECTED", note, actorId: session.user.id },
|
|
},
|
|
},
|
|
});
|
|
|
|
await notify({ event: "PO_REJECTED", po, recipients: [po.submitter], note });
|
|
|
|
revalidatePath("/approvals");
|
|
revalidatePath(`/po/${poId}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function requestEdits({
|
|
poId,
|
|
note,
|
|
}: {
|
|
poId: string;
|
|
note: string;
|
|
}): Promise<ActionResult> {
|
|
const session = await auth();
|
|
if (!session?.user) return { error: "Unauthorized" };
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: { submitter: true },
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
if (!canPerformAction(po.status, "request_edits", session.user.role)) {
|
|
return { error: "You cannot request edits on this PO." };
|
|
}
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
status: "EDITS_REQUESTED",
|
|
managerNote: note,
|
|
actions: {
|
|
create: { actionType: "EDITS_REQUESTED", note, actorId: session.user.id },
|
|
},
|
|
},
|
|
});
|
|
|
|
await notify({ event: "EDITS_REQUESTED", po, recipients: [po.submitter], note });
|
|
|
|
revalidatePath("/approvals");
|
|
revalidatePath(`/po/${poId}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function requestVendorId({ poId }: { poId: string }): Promise<ActionResult> {
|
|
const session = await auth();
|
|
if (!session?.user) return { error: "Unauthorized" };
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: { submitter: true },
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
if (!canPerformAction(po.status, "request_vendor_id", session.user.role)) {
|
|
return { error: "You cannot request a vendor ID for this PO." };
|
|
}
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
status: "VENDOR_ID_PENDING",
|
|
actions: {
|
|
create: { actionType: "VENDOR_ID_REQUESTED", actorId: session.user.id },
|
|
},
|
|
},
|
|
});
|
|
|
|
await notify({ event: "VENDOR_ID_REQUESTED", po, recipients: [po.submitter] });
|
|
|
|
revalidatePath("/approvals");
|
|
revalidatePath(`/po/${poId}`);
|
|
return { ok: true };
|
|
}
|