feat(receipt): allow partial receipt confirmation with per-item delivery tracking
Submitters can now mark individual item quantities as received when confirming delivery, rather than treating a PO as all-or-nothing. Schema (migration: 20260516103013_partial_receipt): - POStatus: new PARTIALLY_CLOSED value between PAID_DELIVERED and CLOSED - ActionType: new PARTIAL_RECEIPT_CONFIRMED value - POLineItem: new deliveredQuantity Decimal? field — accumulates delivered qty across multiple receipt events State machine: - PAID_DELIVERED → confirm_partial_receipt → PARTIALLY_CLOSED (new) - PARTIALLY_CLOSED → confirm_receipt → CLOSED (all delivered) - PARTIALLY_CLOSED → confirm_partial_receipt → PARTIALLY_CLOSED (more partial) Receipt page / form: - Loads line items with ordered qty, previously delivered qty, and remaining qty - Per-row numeric input for "receiving now" defaulting to all remaining - "Mark all remaining" shortcut - Dynamic button: "Confirm Partial Receipt" vs "Confirm Receipt & Close PO" - Info banner telling user if the PO will stay open or close Receipt action: - Accumulates deliveredQuantity per line item - If all lines fully delivered → CLOSED + fires notifications + updates inventory - If any line still outstanding → PARTIALLY_CLOSED (no notifications yet) - Inventory auto-update runs per-event for the delivered quantities only Dashboard & PO detail: - Open Orders count now includes PARTIALLY_CLOSED - "Confirm Receipt" CTA in po-detail handles PARTIALLY_CLOSED with distinct amber styling and "Confirm Remaining" label - Activity log shows PARTIAL_RECEIPT_CONFIRMED with appropriate label - PARTIALLY_CLOSED gets warning (amber) badge variant Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b2bfa63f61
commit
3b3a26eafe
9 changed files with 378 additions and 93 deletions
|
|
@ -31,7 +31,7 @@ export default async function DashboardPage() {
|
||||||
async function SubmitterDashboard({ userId }: { userId: string }) {
|
async function SubmitterDashboard({ userId }: { userId: string }) {
|
||||||
const [openCount, pendingCount, closedCount, recentPos] = await Promise.all([
|
const [openCount, pendingCount, closedCount, recentPos] = await Promise.all([
|
||||||
db.purchaseOrder.count({
|
db.purchaseOrder.count({
|
||||||
where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED"] } },
|
where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED"] } },
|
||||||
}),
|
}),
|
||||||
db.purchaseOrder.count({
|
db.purchaseOrder.count({
|
||||||
where: { submitterId: userId, status: "MGR_REVIEW" },
|
where: { submitterId: userId, status: "MGR_REVIEW" },
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,17 @@ import { revalidatePath } from "next/cache";
|
||||||
export async function confirmReceipt({
|
export async function confirmReceipt({
|
||||||
poId,
|
poId,
|
||||||
notes,
|
notes,
|
||||||
|
deliveries,
|
||||||
}: {
|
}: {
|
||||||
poId: string;
|
poId: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}): Promise<{ ok: true } | { error: string }> {
|
/**
|
||||||
|
* Per-line delivery quantities for this receipt event.
|
||||||
|
* Key = line item id, value = quantity delivered now (not cumulative).
|
||||||
|
* If omitted or empty, all items are treated as fully delivered.
|
||||||
|
*/
|
||||||
|
deliveries?: Record<string, number>;
|
||||||
|
}): Promise<{ ok: true; partial: boolean } | { error: string }> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) return { error: "Unauthorized" };
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
|
@ -25,44 +32,110 @@ export async function confirmReceipt({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!po) return { error: "PO not found" };
|
if (!po) return { error: "PO not found" };
|
||||||
if (!canPerformAction(po.status, "confirm_receipt", session.user.role)) {
|
|
||||||
return { error: "You cannot confirm receipt on this PO." };
|
const isAllowedStatus =
|
||||||
|
po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED";
|
||||||
|
if (!isAllowedStatus) {
|
||||||
|
return { error: "You cannot confirm receipt on this PO in its current state." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!canPerformAction(po.status, "confirm_receipt", session.user.role) &&
|
||||||
|
!canPerformAction(po.status, "confirm_partial_receipt", session.user.role)
|
||||||
|
) {
|
||||||
|
return { error: "You do not have permission to confirm receipt on this PO." };
|
||||||
|
}
|
||||||
|
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
|
||||||
|
return { error: "You can only confirm receipt on your own purchase orders." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute the updated deliveredQuantity for each line item
|
||||||
|
const lineUpdates = po.lineItems.map((li) => {
|
||||||
|
const prevDelivered = Number(li.deliveredQuantity ?? 0);
|
||||||
|
const nowDelivered = deliveries ? (deliveries[li.id] ?? 0) : Number(li.quantity);
|
||||||
|
const totalDelivered = prevDelivered + nowDelivered;
|
||||||
|
const ordered = Number(li.quantity);
|
||||||
|
return {
|
||||||
|
id: li.id,
|
||||||
|
productId: li.productId,
|
||||||
|
quantity: ordered,
|
||||||
|
deliveredQuantity: Math.min(totalDelivered, ordered),
|
||||||
|
nowDelivered: Math.min(nowDelivered, ordered - prevDelivered),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine if all items are now fully delivered
|
||||||
|
const allDelivered = lineUpdates.every((u) => u.deliveredQuantity >= u.quantity);
|
||||||
|
const newStatus = allDelivered ? "CLOSED" : "PARTIALLY_CLOSED";
|
||||||
|
const isPartial = !allDelivered;
|
||||||
|
|
||||||
|
// Persist delivery quantities
|
||||||
|
await Promise.all(
|
||||||
|
lineUpdates.map((u) =>
|
||||||
|
db.pOLineItem.update({
|
||||||
|
where: { id: u.id },
|
||||||
|
data: { deliveredQuantity: u.deliveredQuantity },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update PO status and log action
|
||||||
await db.purchaseOrder.update({
|
await db.purchaseOrder.update({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
data: {
|
data: {
|
||||||
status: "CLOSED",
|
status: newStatus,
|
||||||
closedAt: new Date(),
|
closedAt: allDelivered ? new Date() : undefined,
|
||||||
receipt: notes ? { create: { storageKey: "", fileName: "no-file", notes } } : undefined,
|
receipt: notes
|
||||||
|
? { create: { storageKey: "", fileName: "no-file", notes } }
|
||||||
|
: undefined,
|
||||||
actions: {
|
actions: {
|
||||||
create: { actionType: "RECEIPT_CONFIRMED", actorId: session.user.id, note: notes ?? null },
|
create: {
|
||||||
|
actionType: isPartial ? "PARTIAL_RECEIPT_CONFIRMED" : "RECEIPT_CONFIRMED",
|
||||||
|
actorId: session.user.id,
|
||||||
|
note: notes ?? null,
|
||||||
|
metadata: isPartial
|
||||||
|
? {
|
||||||
|
deliveries: lineUpdates.map((u) => ({
|
||||||
|
lineItemId: u.id,
|
||||||
|
deliveredNow: u.nowDelivered,
|
||||||
|
totalDelivered: u.deliveredQuantity,
|
||||||
|
ordered: u.quantity,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-update inventory: use PO siteId, fall back to vessel's home site
|
// Auto-update inventory for delivered quantities
|
||||||
const siteId = (po as typeof po & { siteId?: string | null }).siteId ?? po.vessel?.site?.id ?? null;
|
const siteId =
|
||||||
|
(po as typeof po & { siteId?: string | null }).siteId ??
|
||||||
|
po.vessel?.site?.id ??
|
||||||
|
null;
|
||||||
|
|
||||||
if (siteId) {
|
if (siteId) {
|
||||||
for (const li of po.lineItems) {
|
for (const u of lineUpdates) {
|
||||||
if (!li.productId) continue;
|
if (!u.productId || u.nowDelivered <= 0) continue;
|
||||||
const qty = Number(li.quantity);
|
|
||||||
await db.itemInventory.upsert({
|
await db.itemInventory.upsert({
|
||||||
where: { productId_siteId: { productId: li.productId, siteId } },
|
where: { productId_siteId: { productId: u.productId, siteId } },
|
||||||
update: { quantity: { increment: qty } },
|
update: { quantity: { increment: u.nowDelivered } },
|
||||||
create: { productId: li.productId, siteId, quantity: qty },
|
create: { productId: u.productId, siteId, quantity: u.nowDelivered },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
revalidatePath(`/admin/sites/${siteId}`);
|
revalidatePath(`/admin/sites/${siteId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [managers, accounts] = await Promise.all([
|
// Notify on full close only
|
||||||
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
|
if (allDelivered) {
|
||||||
db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }),
|
const [managers, accounts] = await Promise.all([
|
||||||
]);
|
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
|
||||||
await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] });
|
db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }),
|
||||||
|
]);
|
||||||
|
await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] });
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/po/${poId}`);
|
revalidatePath(`/po/${poId}`);
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { ok: true };
|
revalidatePath("/my-orders");
|
||||||
|
return { ok: true, partial: isPartial };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,24 +18,59 @@ export default async function ReceiptPage({ params }: Props) {
|
||||||
|
|
||||||
const po = await db.purchaseOrder.findUnique({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { id: true, poNumber: true, title: true, status: true, submitterId: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
poNumber: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
submitterId: true,
|
||||||
|
lineItems: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
quantity: true,
|
||||||
|
unit: true,
|
||||||
|
deliveredQuantity: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
if (po.status !== "PAID_DELIVERED") redirect(`/po/${id}`);
|
if (po.status !== "PAID_DELIVERED" && po.status !== "PARTIALLY_CLOSED") {
|
||||||
|
redirect(`/po/${id}`);
|
||||||
|
}
|
||||||
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
|
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
|
||||||
redirect(`/po/${id}`);
|
redirect(`/po/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lineItems = po.lineItems.map((li) => ({
|
||||||
|
id: li.id,
|
||||||
|
name: li.name,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
unit: li.unit,
|
||||||
|
deliveredQuantity: li.deliveredQuantity !== null ? Number(li.deliveredQuantity) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isPartiallyReceived = po.status === "PARTIALLY_CLOSED";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg">
|
<div className="max-w-2xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Confirm Receipt</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">
|
||||||
|
{isPartiallyReceived ? "Confirm Remaining Receipt" : "Confirm Receipt"}
|
||||||
|
</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-500">
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
{po.poNumber} — {po.title}
|
{po.poNumber} — {po.title}
|
||||||
</p>
|
</p>
|
||||||
|
{isPartiallyReceived && (
|
||||||
|
<p className="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
|
This PO has been partially received. Mark the quantities delivered in this batch.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ReceiptForm poId={po.id} />
|
<ReceiptForm poId={po.id} lineItems={lineItems} isPartiallyReceived={isPartiallyReceived} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,57 @@ import { confirmReceipt } from "./actions";
|
||||||
import { FileUploader } from "@/components/po/file-uploader";
|
import { FileUploader } from "@/components/po/file-uploader";
|
||||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||||
|
|
||||||
export function ReceiptForm({ poId }: { poId: string }) {
|
interface LineItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
deliveredQuantity: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
poId: string;
|
||||||
|
lineItems: LineItem[];
|
||||||
|
isPartiallyReceived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReceiptForm({ poId, lineItems, isPartiallyReceived }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Per-item: how many are being delivered in this batch
|
||||||
|
const [deliveries, setDeliveries] = useState<Record<string, number>>(() => {
|
||||||
|
const init: Record<string, number> = {};
|
||||||
|
for (const li of lineItems) {
|
||||||
|
const remaining = li.quantity - (li.deliveredQuantity ?? 0);
|
||||||
|
init[li.id] = remaining; // default: all remaining
|
||||||
|
}
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setDelivery(id: string, value: number) {
|
||||||
|
const li = lineItems.find((l) => l.id === id)!;
|
||||||
|
const remaining = li.quantity - (li.deliveredQuantity ?? 0);
|
||||||
|
setDeliveries((prev) => ({ ...prev, [id]: Math.max(0, Math.min(value, remaining)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllRemaining() {
|
||||||
|
const all: Record<string, number> = {};
|
||||||
|
for (const li of lineItems) {
|
||||||
|
all[li.id] = li.quantity - (li.deliveredQuantity ?? 0);
|
||||||
|
}
|
||||||
|
setDeliveries(all);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError("");
|
setError("");
|
||||||
const result = await confirmReceipt({ poId, notes });
|
|
||||||
|
const result = await confirmReceipt({ poId, notes, deliveries });
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -35,55 +74,146 @@ export function ReceiptForm({ poId }: { poId: string }) {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allFullyDelivered = lineItems.every(
|
||||||
|
(li) => (li.deliveredQuantity ?? 0) + (deliveries[li.id] ?? 0) >= li.quantity
|
||||||
|
);
|
||||||
|
const nothingDelivered = Object.values(deliveries).every((v) => v === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
<div className="space-y-5">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
{/* Line items delivery tracker */}
|
||||||
<p className="text-sm text-neutral-600">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
Confirming receipt will close this purchase order. Please verify that all
|
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
|
||||||
items have been received before proceeding.
|
<h3 className="text-sm font-semibold text-neutral-800">Items to Receive</h3>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
|
||||||
Delivery notes (optional)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
rows={4}
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 resize-none"
|
|
||||||
placeholder="Any notes about the delivery…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
|
||||||
Attachments (optional)
|
|
||||||
</label>
|
|
||||||
<FileUploader files={files} onChange={setFiles} disabled={submitting} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.back()}
|
onClick={markAllRemaining}
|
||||||
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
className="text-xs text-primary-600 hover:text-primary-700 font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Mark all remaining
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
|
|
||||||
>
|
|
||||||
{submitting ? "Confirming…" : "Confirm Receipt & Close PO"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-neutral-50 border-b border-neutral-100">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium text-neutral-600">Item</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium text-neutral-600">Ordered</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium text-neutral-600">Previously Received</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium text-neutral-600">Remaining</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium text-neutral-600">Receiving Now</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
{lineItems.map((li) => {
|
||||||
|
const prevDelivered = li.deliveredQuantity ?? 0;
|
||||||
|
const remaining = li.quantity - prevDelivered;
|
||||||
|
const nowDelivering = deliveries[li.id] ?? 0;
|
||||||
|
const isFullyReceived = remaining <= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={li.id} className={isFullyReceived ? "bg-neutral-50" : ""}>
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">
|
||||||
|
{li.name}
|
||||||
|
{isFullyReceived && (
|
||||||
|
<span className="ml-2 text-xs text-success-600 font-normal">✓ fully received</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs text-neutral-600">
|
||||||
|
{li.quantity} {li.unit}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs text-neutral-500">
|
||||||
|
{isPartiallyReceived || prevDelivered > 0 ? `${prevDelivered} ${li.unit}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs">
|
||||||
|
<span className={remaining > 0 ? "text-amber-700 font-medium" : "text-neutral-400"}>
|
||||||
|
{remaining > 0 ? `${remaining} ${li.unit}` : "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{isFullyReceived ? (
|
||||||
|
<span className="text-xs text-neutral-400">—</span>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={remaining}
|
||||||
|
step="any"
|
||||||
|
value={nowDelivering}
|
||||||
|
onChange={(e) => setDelivery(li.id, parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-24 rounded border border-neutral-300 px-2 py-1 text-right text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{allFullyDelivered ? (
|
||||||
|
<p className="text-sm text-success-700 bg-success-50 border border-success-100 rounded-lg px-3 py-2">
|
||||||
|
All items will be marked as received. Confirming will fully close this PO.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
|
Not all items are being received. The PO will remain open as “Partially Received” until
|
||||||
|
all items are delivered.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Delivery notes (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 resize-none"
|
||||||
|
placeholder="Any notes about this delivery batch…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Attachments (optional)
|
||||||
|
</label>
|
||||||
|
<FileUploader files={files} onChange={setFiles} disabled={submitting} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || nothingDelivered}
|
||||||
|
className={`rounded-lg px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60 transition-opacity ${
|
||||||
|
allFullyDelivered
|
||||||
|
? "bg-success hover:opacity-90"
|
||||||
|
: "bg-primary-600 hover:bg-primary-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? "Confirming…"
|
||||||
|
: allFullyDelivered
|
||||||
|
? "Confirm Receipt & Close PO"
|
||||||
|
: "Confirm Partial Receipt"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||||
VENDOR_ID_PROVIDED: "Vendor ID provided",
|
VENDOR_ID_PROVIDED: "Vendor ID provided",
|
||||||
PAYMENT_SENT: "Payment confirmed",
|
PAYMENT_SENT: "Payment confirmed",
|
||||||
RECEIPT_CONFIRMED: "Receipt confirmed",
|
RECEIPT_CONFIRMED: "Receipt confirmed",
|
||||||
|
PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed",
|
||||||
CLOSED: "Closed",
|
CLOSED: "Closed",
|
||||||
MANAGER_LINE_EDIT: "Manager amended line items",
|
MANAGER_LINE_EDIT: "Manager amended line items",
|
||||||
PRODUCT_PRICE_UPDATED: "Product prices updated",
|
PRODUCT_PRICE_UPDATED: "Product prices updated",
|
||||||
|
|
@ -140,7 +141,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
);
|
);
|
||||||
|
|
||||||
const canConfirmReceipt =
|
const canConfirmReceipt =
|
||||||
po.status === "PAID_DELIVERED" &&
|
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED") &&
|
||||||
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
|
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
|
||||||
!readOnly;
|
!readOnly;
|
||||||
|
|
||||||
|
|
@ -388,16 +389,28 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
|
|
||||||
{/* Confirm receipt CTA */}
|
{/* Confirm receipt CTA */}
|
||||||
{canConfirmReceipt && (
|
{canConfirmReceipt && (
|
||||||
<div className="rounded-lg border border-success-100 bg-success-50 p-5 flex items-center justify-between">
|
<div className={`rounded-lg border p-5 flex items-center justify-between ${
|
||||||
|
po.status === "PARTIALLY_CLOSED"
|
||||||
|
? "border-warning-100 bg-warning-50"
|
||||||
|
: "border-success-100 bg-success-50"
|
||||||
|
}`}>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-success-700">Payment confirmed</p>
|
<p className={`font-medium ${po.status === "PARTIALLY_CLOSED" ? "text-warning-700" : "text-success-700"}`}>
|
||||||
<p className="text-sm text-success-700 mt-0.5">Please confirm that you have received all items.</p>
|
{po.status === "PARTIALLY_CLOSED" ? "Partially received" : "Payment confirmed"}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm mt-0.5 ${po.status === "PARTIALLY_CLOSED" ? "text-warning-700" : "text-success-700"}`}>
|
||||||
|
{po.status === "PARTIALLY_CLOSED"
|
||||||
|
? "Some items are still outstanding. Confirm remaining deliveries."
|
||||||
|
: "Please confirm that you have received all items."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={`/po/${po.id}/receipt`}
|
href={`/po/${po.id}/receipt`}
|
||||||
className="rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90"
|
className={`rounded-lg px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 ${
|
||||||
|
po.status === "PARTIALLY_CLOSED" ? "bg-warning-600" : "bg-success"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Confirm Receipt
|
{po.status === "PARTIALLY_CLOSED" ? "Confirm Remaining" : "Confirm Receipt"}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ export type POAction =
|
||||||
| "provide_vendor_id"
|
| "provide_vendor_id"
|
||||||
| "process_payment"
|
| "process_payment"
|
||||||
| "mark_paid"
|
| "mark_paid"
|
||||||
| "confirm_receipt";
|
| "confirm_receipt"
|
||||||
|
| "confirm_partial_receipt";
|
||||||
|
|
||||||
export type SideEffect =
|
export type SideEffect =
|
||||||
| "EMAIL_MANAGER"
|
| "EMAIL_MANAGER"
|
||||||
|
|
@ -110,6 +111,26 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
|
||||||
requiresNote: false,
|
requiresNote: false,
|
||||||
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
|
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
|
||||||
},
|
},
|
||||||
|
confirm_partial_receipt: {
|
||||||
|
to: "PARTIALLY_CLOSED",
|
||||||
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||||
|
requiresNote: false,
|
||||||
|
sideEffects: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PARTIALLY_CLOSED: {
|
||||||
|
confirm_receipt: {
|
||||||
|
to: "CLOSED",
|
||||||
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||||
|
requiresNote: false,
|
||||||
|
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
|
||||||
|
},
|
||||||
|
confirm_partial_receipt: {
|
||||||
|
to: "PARTIALLY_CLOSED",
|
||||||
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||||
|
requiresNote: false,
|
||||||
|
sideEffects: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
||||||
MGR_APPROVED: "Approved",
|
MGR_APPROVED: "Approved",
|
||||||
SENT_FOR_PAYMENT: "Sent for Payment",
|
SENT_FOR_PAYMENT: "Sent for Payment",
|
||||||
PAID_DELIVERED: "Paid",
|
PAID_DELIVERED: "Paid",
|
||||||
|
PARTIALLY_CLOSED: "Partially Received",
|
||||||
CLOSED: "Closed",
|
CLOSED: "Closed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -69,5 +70,6 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
|
||||||
MGR_APPROVED: "success",
|
MGR_APPROVED: "success",
|
||||||
SENT_FOR_PAYMENT: "default",
|
SENT_FOR_PAYMENT: "default",
|
||||||
PAID_DELIVERED: "success",
|
PAID_DELIVERED: "success",
|
||||||
|
PARTIALLY_CLOSED: "warning",
|
||||||
CLOSED: "secondary",
|
CLOSED: "secondary",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ActionType" ADD VALUE 'PARTIAL_RECEIPT_CONFIRMED';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "POStatus" ADD VALUE 'PARTIALLY_CLOSED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "POLineItem" ADD COLUMN "deliveredQuantity" DECIMAL(10,3);
|
||||||
|
|
@ -27,6 +27,7 @@ enum POStatus {
|
||||||
MGR_APPROVED
|
MGR_APPROVED
|
||||||
SENT_FOR_PAYMENT
|
SENT_FOR_PAYMENT
|
||||||
PAID_DELIVERED
|
PAID_DELIVERED
|
||||||
|
PARTIALLY_CLOSED
|
||||||
CLOSED
|
CLOSED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ enum ActionType {
|
||||||
VENDOR_ID_PROVIDED
|
VENDOR_ID_PROVIDED
|
||||||
PAYMENT_SENT
|
PAYMENT_SENT
|
||||||
RECEIPT_CONFIRMED
|
RECEIPT_CONFIRMED
|
||||||
|
PARTIAL_RECEIPT_CONFIRMED
|
||||||
CLOSED
|
CLOSED
|
||||||
REASSIGNED
|
REASSIGNED
|
||||||
PRODUCT_PRICE_UPDATED
|
PRODUCT_PRICE_UPDATED
|
||||||
|
|
@ -244,20 +246,21 @@ model PurchaseOrder {
|
||||||
}
|
}
|
||||||
|
|
||||||
model POLineItem {
|
model POLineItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
quantity Decimal @db.Decimal(10, 3)
|
quantity Decimal @db.Decimal(10, 3)
|
||||||
unit String
|
unit String
|
||||||
unitPrice Decimal @db.Decimal(12, 2)
|
unitPrice Decimal @db.Decimal(12, 2)
|
||||||
totalPrice Decimal @db.Decimal(12, 2)
|
totalPrice Decimal @db.Decimal(12, 2)
|
||||||
gstRate Decimal @default(0.18) @db.Decimal(5, 4)
|
gstRate Decimal @default(0.18) @db.Decimal(5, 4)
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
size String?
|
size String?
|
||||||
productId String?
|
deliveredQuantity Decimal? @db.Decimal(10, 3)
|
||||||
product Product? @relation(fields: [productId], references: [id])
|
productId String?
|
||||||
accountId String?
|
product Product? @relation(fields: [productId], references: [id])
|
||||||
account Account? @relation(fields: [accountId], references: [id])
|
accountId String?
|
||||||
|
account Account? @relation(fields: [accountId], references: [id])
|
||||||
|
|
||||||
poId String
|
poId String
|
||||||
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue