2026-03-15 14:00:12 -05:00
|
|
|
import type {
|
|
|
|
|
InventoryItemDetailDto,
|
|
|
|
|
InventoryReservationInput,
|
|
|
|
|
InventoryTransactionInput,
|
|
|
|
|
InventoryTransferInput,
|
|
|
|
|
WarehouseLocationOptionDto,
|
|
|
|
|
} from "@mrp/shared/dist/inventory/types.js";
|
2026-03-14 21:10:35 -05:00
|
|
|
import { permissions } from "@mrp/shared";
|
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { Link, useParams } from "react-router-dom";
|
|
|
|
|
|
|
|
|
|
import { useAuth } from "../../auth/AuthProvider";
|
|
|
|
|
import { api, ApiError } from "../../lib/api";
|
2026-03-14 22:37:09 -05:00
|
|
|
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
|
2026-03-14 23:03:17 -05:00
|
|
|
import { InventoryAttachmentsPanel } from "./InventoryAttachmentsPanel";
|
2026-03-14 21:10:35 -05:00
|
|
|
import { InventoryStatusBadge } from "./InventoryStatusBadge";
|
2026-03-14 22:37:09 -05:00
|
|
|
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
|
2026-03-14 21:10:35 -05:00
|
|
|
import { InventoryTypeBadge } from "./InventoryTypeBadge";
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
const emptyTransferInput: InventoryTransferInput = {
|
|
|
|
|
quantity: 1,
|
|
|
|
|
fromWarehouseId: "",
|
|
|
|
|
fromLocationId: "",
|
|
|
|
|
toWarehouseId: "",
|
|
|
|
|
toLocationId: "",
|
|
|
|
|
notes: "",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const emptyReservationInput: InventoryReservationInput = {
|
|
|
|
|
quantity: 1,
|
|
|
|
|
warehouseId: null,
|
|
|
|
|
locationId: null,
|
|
|
|
|
notes: "",
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-14 21:10:35 -05:00
|
|
|
export function InventoryDetailPage() {
|
|
|
|
|
const { token, user } = useAuth();
|
|
|
|
|
const { itemId } = useParams();
|
|
|
|
|
const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
|
2026-03-14 22:37:09 -05:00
|
|
|
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
|
|
|
|
|
const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput);
|
2026-03-15 14:00:12 -05:00
|
|
|
const [transferForm, setTransferForm] = useState<InventoryTransferInput>(emptyTransferInput);
|
|
|
|
|
const [reservationForm, setReservationForm] = useState<InventoryReservationInput>(emptyReservationInput);
|
2026-03-14 22:37:09 -05:00
|
|
|
const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
|
2026-03-15 14:00:12 -05:00
|
|
|
const [transferStatus, setTransferStatus] = useState("Move physical stock between warehouses or locations without manual paired entries.");
|
|
|
|
|
const [reservationStatus, setReservationStatus] = useState("Reserve stock manually while active work orders reserve component demand automatically.");
|
2026-03-14 22:37:09 -05:00
|
|
|
const [isSavingTransaction, setIsSavingTransaction] = useState(false);
|
2026-03-15 14:00:12 -05:00
|
|
|
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
|
|
|
|
|
const [isSavingReservation, setIsSavingReservation] = useState(false);
|
2026-03-14 21:10:35 -05:00
|
|
|
const [status, setStatus] = useState("Loading inventory item...");
|
|
|
|
|
|
|
|
|
|
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!token || !itemId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api
|
|
|
|
|
.getInventoryItem(token, itemId)
|
|
|
|
|
.then((nextItem) => {
|
|
|
|
|
setItem(nextItem);
|
|
|
|
|
setStatus("Inventory item loaded.");
|
|
|
|
|
})
|
|
|
|
|
.catch((error: unknown) => {
|
|
|
|
|
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
|
|
|
|
|
setStatus(message);
|
|
|
|
|
});
|
2026-03-14 22:37:09 -05:00
|
|
|
|
|
|
|
|
api
|
|
|
|
|
.getWarehouseLocationOptions(token)
|
|
|
|
|
.then((options) => {
|
|
|
|
|
setLocationOptions(options);
|
2026-03-15 14:00:12 -05:00
|
|
|
const firstOption = options[0];
|
|
|
|
|
if (!firstOption) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-14 22:37:09 -05:00
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
setTransactionForm((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
warehouseId: current.warehouseId || firstOption.warehouseId,
|
|
|
|
|
locationId: current.locationId || firstOption.locationId,
|
|
|
|
|
}));
|
|
|
|
|
setTransferForm((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
fromWarehouseId: current.fromWarehouseId || firstOption.warehouseId,
|
|
|
|
|
fromLocationId: current.fromLocationId || firstOption.locationId,
|
|
|
|
|
toWarehouseId: current.toWarehouseId || firstOption.warehouseId,
|
|
|
|
|
toLocationId: current.toLocationId || firstOption.locationId,
|
|
|
|
|
}));
|
2026-03-14 22:37:09 -05:00
|
|
|
})
|
|
|
|
|
.catch(() => setLocationOptions([]));
|
2026-03-14 21:10:35 -05:00
|
|
|
}, [itemId, token]);
|
|
|
|
|
|
2026-03-14 22:37:09 -05:00
|
|
|
function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) {
|
|
|
|
|
setTransactionForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
function updateTransferField<Key extends keyof InventoryTransferInput>(key: Key, value: InventoryTransferInput[Key]) {
|
|
|
|
|
setTransferForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 22:37:09 -05:00
|
|
|
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (!token || !itemId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSavingTransaction(true);
|
|
|
|
|
setTransactionStatus("Saving stock transaction...");
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
|
|
|
|
|
setItem(nextItem);
|
|
|
|
|
setTransactionStatus("Stock transaction recorded.");
|
|
|
|
|
setTransactionForm((current) => ({
|
|
|
|
|
...emptyInventoryTransactionInput,
|
|
|
|
|
transactionType: current.transactionType,
|
|
|
|
|
warehouseId: current.warehouseId,
|
|
|
|
|
locationId: current.locationId,
|
|
|
|
|
}));
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const message = error instanceof ApiError ? error.message : "Unable to save stock transaction.";
|
|
|
|
|
setTransactionStatus(message);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSavingTransaction(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:00:12 -05:00
|
|
|
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (!token || !itemId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSavingTransfer(true);
|
|
|
|
|
setTransferStatus("Saving transfer...");
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
|
|
|
|
|
setItem(nextItem);
|
|
|
|
|
setTransferStatus("Transfer recorded.");
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
|
|
|
|
|
setTransferStatus(message);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSavingTransfer(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (!token || !itemId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSavingReservation(true);
|
|
|
|
|
setReservationStatus("Saving reservation...");
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
|
|
|
|
|
setItem(nextItem);
|
|
|
|
|
setReservationStatus("Reservation recorded.");
|
|
|
|
|
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
|
|
|
|
|
setReservationStatus(message);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSavingReservation(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:10:35 -05:00
|
|
|
if (!item) {
|
2026-03-15 14:00:12 -05:00
|
|
|
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
2026-03-14 21:10:35 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="space-y-4">
|
2026-03-14 22:21:31 -05:00
|
|
|
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
|
|
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
2026-03-14 21:10:35 -05:00
|
|
|
<div>
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Detail</p>
|
2026-03-14 22:21:31 -05:00
|
|
|
<h3 className="mt-2 text-xl font-bold text-text">{item.sku}</h3>
|
|
|
|
|
<p className="mt-1 text-sm text-text">{item.name}</p>
|
2026-03-14 21:10:35 -05:00
|
|
|
<div className="mt-4 flex flex-wrap gap-3">
|
|
|
|
|
<InventoryTypeBadge type={item.type} />
|
|
|
|
|
<InventoryStatusBadge status={item.status} />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap gap-3">
|
2026-03-15 14:00:12 -05:00
|
|
|
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
2026-03-14 21:10:35 -05:00
|
|
|
Back to items
|
|
|
|
|
</Link>
|
|
|
|
|
{canManage ? (
|
2026-03-14 22:03:51 -05:00
|
|
|
<Link to={`/inventory/items/${item.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
2026-03-14 21:10:35 -05:00
|
|
|
Edit item
|
|
|
|
|
</Link>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-15 14:00:12 -05:00
|
|
|
|
|
|
|
|
<section className="grid gap-3 xl:grid-cols-7">
|
2026-03-14 22:37:09 -05:00
|
|
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p>
|
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div>
|
|
|
|
|
</article>
|
2026-03-15 14:00:12 -05:00
|
|
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reserved</p>
|
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</div>
|
|
|
|
|
</article>
|
|
|
|
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Available</p>
|
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</div>
|
|
|
|
|
</article>
|
2026-03-14 22:37:09 -05:00
|
|
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Stock Locations</p>
|
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
|
|
|
|
|
</article>
|
|
|
|
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transactions</p>
|
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div>
|
|
|
|
|
</article>
|
|
|
|
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
2026-03-15 14:00:12 -05:00
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p>
|
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{item.transfers.length}</div>
|
2026-03-14 22:37:09 -05:00
|
|
|
</article>
|
2026-03-15 12:11:46 -05:00
|
|
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
2026-03-15 14:00:12 -05:00
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p>
|
|
|
|
|
<div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
|
2026-03-15 12:11:46 -05:00
|
|
|
</article>
|
2026-03-14 22:37:09 -05:00
|
|
|
</section>
|
2026-03-15 14:00:12 -05:00
|
|
|
|
2026-03-14 22:21:31 -05:00
|
|
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
|
|
|
|
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
2026-03-14 21:10:35 -05:00
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
|
2026-03-14 22:21:31 -05:00
|
|
|
<dl className="mt-5 grid gap-3 xl:grid-cols-2">
|
2026-03-14 21:10:35 -05:00
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Description</dt>
|
2026-03-14 22:21:31 -05:00
|
|
|
<dd className="mt-1 text-sm leading-6 text-text">{item.description || "No description provided."}</dd>
|
2026-03-14 21:10:35 -05:00
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unit of measure</dt>
|
|
|
|
|
<dd className="mt-2 text-sm text-text">{item.unitOfMeasure}</dd>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default cost</dt>
|
|
|
|
|
<dd className="mt-2 text-sm text-text">{item.defaultCost == null ? "Not set" : `$${item.defaultCost.toFixed(2)}`}</dd>
|
|
|
|
|
</div>
|
2026-03-14 23:23:43 -05:00
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt>
|
|
|
|
|
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
|
|
|
|
|
</div>
|
2026-03-14 21:10:35 -05:00
|
|
|
<div>
|
|
|
|
|
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
|
|
|
|
|
<dd className="mt-2 text-sm text-text">
|
|
|
|
|
{item.isSellable ? "Sellable" : "Not sellable"} / {item.isPurchasable ? "Purchasable" : "Not purchasable"}
|
|
|
|
|
</dd>
|
|
|
|
|
</div>
|
|
|
|
|
</dl>
|
|
|
|
|
</article>
|
2026-03-14 22:21:31 -05:00
|
|
|
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
2026-03-15 14:00:12 -05:00
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock By Location</p>
|
|
|
|
|
{item.stockBalances.length === 0 ? (
|
|
|
|
|
<p className="mt-4 text-sm text-muted">No stock or reservation balances have been posted for this item yet.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="mt-4 space-y-2">
|
|
|
|
|
{item.stockBalances.map((balance) => (
|
|
|
|
|
<div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="font-semibold text-text">
|
|
|
|
|
{balance.warehouseCode} / {balance.locationCode}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-muted">
|
|
|
|
|
{balance.warehouseName} / {balance.locationName}
|
2026-03-14 22:37:09 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-15 14:00:12 -05:00
|
|
|
<div className="text-right">
|
|
|
|
|
<div className="font-semibold text-text">{balance.quantityOnHand} on hand</div>
|
|
|
|
|
<div className="text-xs text-muted">{balance.quantityReserved} reserved / {balance.quantityAvailable} available</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
2026-03-15 12:11:46 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-15 14:00:12 -05:00
|
|
|
</article>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<section className="grid gap-3 xl:grid-cols-2">
|
2026-03-14 22:37:09 -05:00
|
|
|
{canManage ? (
|
2026-03-15 14:00:12 -05:00
|
|
|
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}>
|
2026-03-14 22:37:09 -05:00
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p>
|
2026-03-15 14:00:12 -05:00
|
|
|
<div className="mt-5 grid gap-3">
|
2026-03-14 22:37:09 -05:00
|
|
|
<div className="grid gap-3 xl:grid-cols-2">
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
|
2026-03-15 14:00:12 -05:00
|
|
|
<select value={transactionForm.transactionType} onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
2026-03-14 22:37:09 -05:00
|
|
|
{inventoryTransactionOptions.map((option) => (
|
|
|
|
|
<option key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
2026-03-15 14:00:12 -05:00
|
|
|
<input type="number" min={1} step={1} value={transactionForm.quantity} onChange={(event) => updateTransactionField("quantity", Number.parseInt(event.target.value, 10) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
2026-03-14 22:37:09 -05:00
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
|
2026-03-15 14:00:12 -05:00
|
|
|
<select value={transactionForm.locationId} onChange={(event) => {
|
|
|
|
|
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
|
|
|
|
|
updateTransactionField("locationId", event.target.value);
|
|
|
|
|
if (nextLocation) {
|
|
|
|
|
updateTransactionField("warehouseId", nextLocation.warehouseId);
|
|
|
|
|
}
|
|
|
|
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
2026-03-14 22:37:09 -05:00
|
|
|
{locationOptions.map((option) => (
|
|
|
|
|
<option key={option.locationId} value={option.locationId}>
|
|
|
|
|
{option.warehouseCode} / {option.locationCode}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Reference</span>
|
2026-03-15 14:00:12 -05:00
|
|
|
<input value={transactionForm.reference} onChange={(event) => updateTransactionField("reference", event.target.value)} placeholder="PO, WO, adjustment note, etc." className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
2026-03-14 22:37:09 -05:00
|
|
|
</label>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
2026-03-15 14:00:12 -05:00
|
|
|
<textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("notes", event.target.value)} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
2026-03-14 22:37:09 -05:00
|
|
|
</label>
|
2026-03-15 14:00:12 -05:00
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
|
|
|
|
<span className="text-sm text-muted">{transactionStatus}</span>
|
|
|
|
|
<button type="submit" disabled={isSavingTransaction} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
2026-03-14 22:37:09 -05:00
|
|
|
{isSavingTransaction ? "Posting..." : "Post transaction"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-03-15 14:00:12 -05:00
|
|
|
</div>
|
|
|
|
|
</form>
|
2026-03-14 22:37:09 -05:00
|
|
|
) : null}
|
|
|
|
|
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
2026-03-15 14:00:12 -05:00
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Movements</p>
|
2026-03-14 22:37:09 -05:00
|
|
|
{item.recentTransactions.length === 0 ? (
|
|
|
|
|
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
|
|
|
|
No stock transactions have been recorded for this item yet.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="mt-6 space-y-3">
|
|
|
|
|
{item.recentTransactions.map((transaction) => (
|
|
|
|
|
<article key={transaction.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
|
|
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<InventoryTransactionTypeBadge type={transaction.transactionType} />
|
|
|
|
|
<span className={`text-sm font-semibold ${transaction.signedQuantity >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
|
|
|
|
|
{transaction.signedQuantity >= 0 ? "+" : ""}
|
|
|
|
|
{transaction.signedQuantity}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-sm font-semibold text-text">
|
|
|
|
|
{transaction.warehouseCode} / {transaction.locationCode}
|
|
|
|
|
</div>
|
|
|
|
|
{transaction.reference ? <div className="mt-2 text-xs text-muted">Ref: {transaction.reference}</div> : null}
|
|
|
|
|
{transaction.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-sm text-muted lg:text-right">
|
|
|
|
|
<div>{new Date(transaction.createdAt).toLocaleString()}</div>
|
|
|
|
|
<div className="mt-1">{transaction.createdByName}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</article>
|
|
|
|
|
</section>
|
2026-03-15 14:00:12 -05:00
|
|
|
|
|
|
|
|
{canManage ? (
|
|
|
|
|
<section className="grid gap-3 xl:grid-cols-2">
|
|
|
|
|
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}>
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p>
|
|
|
|
|
<div className="mt-5 grid gap-3">
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
|
|
|
|
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
|
|
|
|
</label>
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">From</span>
|
|
|
|
|
<select value={transferForm.fromLocationId} onChange={(event) => {
|
|
|
|
|
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
|
|
|
|
updateTransferField("fromLocationId", event.target.value);
|
|
|
|
|
if (option) {
|
|
|
|
|
updateTransferField("fromWarehouseId", option.warehouseId);
|
|
|
|
|
}
|
|
|
|
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
|
|
|
|
{locationOptions.map((option) => (
|
|
|
|
|
<option key={`from-${option.locationId}`} value={option.locationId}>
|
|
|
|
|
{option.warehouseCode} / {option.locationCode}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">To</span>
|
|
|
|
|
<select value={transferForm.toLocationId} onChange={(event) => {
|
|
|
|
|
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
|
|
|
|
updateTransferField("toLocationId", event.target.value);
|
|
|
|
|
if (option) {
|
|
|
|
|
updateTransferField("toWarehouseId", option.warehouseId);
|
|
|
|
|
}
|
|
|
|
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
|
|
|
|
{locationOptions.map((option) => (
|
|
|
|
|
<option key={`to-${option.locationId}`} value={option.locationId}>
|
|
|
|
|
{option.warehouseCode} / {option.locationCode}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
|
|
|
|
<textarea value={transferForm.notes} onChange={(event) => updateTransferField("notes", event.target.value)} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
|
|
|
|
</label>
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
|
|
|
|
<span className="text-sm text-muted">{transferStatus}</span>
|
|
|
|
|
<button type="submit" disabled={isSavingTransfer} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
|
|
|
|
{isSavingTransfer ? "Posting transfer..." : "Post transfer"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}>
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p>
|
|
|
|
|
<div className="mt-5 grid gap-3">
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
|
|
|
|
|
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
|
|
|
|
|
<select value={reservationForm.locationId ?? ""} onChange={(event) => {
|
|
|
|
|
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
|
|
|
|
|
setReservationForm((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
locationId: event.target.value || null,
|
|
|
|
|
warehouseId: option ? option.warehouseId : null,
|
|
|
|
|
}));
|
|
|
|
|
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
|
|
|
|
<option value="">Global / not location-specific</option>
|
|
|
|
|
{locationOptions.map((option) => (
|
|
|
|
|
<option key={option.locationId} value={option.locationId}>
|
|
|
|
|
{option.warehouseCode} / {option.locationCode}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block">
|
|
|
|
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
|
|
|
|
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, notes: event.target.value }))} rows={3} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
|
|
|
|
</label>
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
|
|
|
|
|
<span className="text-sm text-muted">{reservationStatus}</span>
|
|
|
|
|
<button type="submit" disabled={isSavingReservation} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
|
|
|
|
|
{isSavingReservation ? "Saving reservation..." : "Create reservation"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<section className="grid gap-3 xl:grid-cols-2">
|
|
|
|
|
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Reservations</p>
|
|
|
|
|
{item.reservations.length === 0 ? (
|
|
|
|
|
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
|
|
|
|
No reservations have been recorded for this item.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="mt-5 space-y-3">
|
|
|
|
|
{item.reservations.map((reservation) => (
|
|
|
|
|
<article key={reservation.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="font-semibold text-text">{reservation.quantity} reserved</div>
|
|
|
|
|
<div className="mt-1 text-xs text-muted">{reservation.sourceLabel ?? reservation.sourceType}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-muted">{reservation.status}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-xs text-muted">
|
|
|
|
|
{reservation.warehouseCode && reservation.locationCode ? `${reservation.warehouseCode} / ${reservation.locationCode}` : "Not location-specific"}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-sm text-text">{reservation.notes || "No notes recorded."}</div>
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</article>
|
|
|
|
|
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Transfers</p>
|
|
|
|
|
{item.transfers.length === 0 ? (
|
|
|
|
|
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
|
|
|
|
No transfers have been recorded for this item.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="mt-5 space-y-3">
|
|
|
|
|
{item.transfers.map((transfer) => (
|
|
|
|
|
<article key={transfer.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<div className="font-semibold text-text">{transfer.quantity} moved</div>
|
|
|
|
|
<div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-xs text-muted">
|
|
|
|
|
{transfer.fromWarehouseCode} / {transfer.fromLocationCode} to {transfer.toWarehouseCode} / {transfer.toLocationCode}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-sm text-text">{transfer.notes || "No notes recorded."}</div>
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</article>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-03-14 23:03:17 -05:00
|
|
|
<InventoryAttachmentsPanel itemId={item.id} />
|
2026-03-14 21:10:35 -05:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|