This commit is contained in:
2026-03-23 17:12:35 -05:00
parent 92328713f4
commit d59a0a563d
9 changed files with 346 additions and 40 deletions

View File

@@ -10,6 +10,7 @@ Inven is a single-container inventory management system for Unraid-style deploym
- Sales orders can be created and shipped
- Purchase orders can be created and received
- Sales orders and purchase orders are created from relational inventory line selections
- Shipping and receiving are posted from relational order-line quantity controls
- Sales orders support partial shipments
- Purchase orders support partial receipts
- Invoices are generated from shipped sales orders
@@ -75,6 +76,7 @@ Suggested Unraid mapping:
- Add parts and assemblies in the Parts module.
- Define assembly components in the Assemblies module.
- Create SOs and POs by selecting actual inventory items from the item master instead of typing free-form SKUs.
- Fulfill SOs and POs by entering quantities against existing order lines instead of typing SKU text.
- Use purchase orders to restock and receive inventory.
- Use build transactions to convert component stock into assembly stock.
- Use sales orders and ship them fully or partially to reduce stock and generate invoices plus journal entries.

View File

@@ -1,7 +1,12 @@
import { addKitComponent, buildAssembly } from "@/lib/actions";
import { getAssembliesWithComponents, getParts } from "@/lib/repository";
export default function AssembliesPage() {
export default async function AssembliesPage({
searchParams
}: {
searchParams?: Promise<{ error?: string; success?: string }>;
}) {
const params = (await searchParams) ?? {};
const parts = getParts();
const assemblies = parts.filter((part) => part.kind === "assembly");
const components = parts.filter((part) => part.kind === "part");
@@ -9,6 +14,8 @@ export default function AssembliesPage() {
return (
<div className="grid">
{params.error ? <section className="panel"><p className="muted" style={{ color: "var(--danger)" }}>{params.error}</p></section> : null}
{params.success ? <section className="panel"><p className="muted" style={{ color: "var(--success)" }}>{params.success}</p></section> : null}
<section className="two-up">
<article className="panel">
<h2 className="section-title">Bill of Materials</h2>
@@ -16,26 +23,26 @@ export default function AssembliesPage() {
<form action={addKitComponent} className="form-grid">
<div className="form-row">
<label htmlFor="assemblySku">Assembly SKU</label>
<select className="select" id="assemblySku" name="assemblySku">{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
<select className="select" id="assemblySku" name="assemblySku" disabled={assemblies.length === 0}>{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
</div>
<div className="form-row">
<label htmlFor="componentSku">Component SKU</label>
<select className="select" id="componentSku" name="componentSku">{components.map((component) => <option key={component.id} value={component.sku}>{component.sku} - {component.name}</option>)}</select>
<select className="select" id="componentSku" name="componentSku" disabled={components.length === 0}>{components.map((component) => <option key={component.id} value={component.sku}>{component.sku} - {component.name}</option>)}</select>
</div>
<div className="form-row"><label htmlFor="component-qty">Quantity Per Assembly</label><input className="input" id="component-qty" name="quantity" type="number" min="0.01" step="0.01" defaultValue="1" /></div>
<button className="button" type="submit">Save Component</button>
<button className="button" type="submit" disabled={assemblies.length === 0 || components.length === 0}>Save Component</button>
</form>
</article>
<article className="panel">
<h2 className="section-title">Build Assembly</h2>
<p className="section-copy">Consume component stock and create finished kit inventory in one transaction flow.</p>
<p className="section-copy">Consume component stock and create finished kit inventory in one transaction flow. This only works after the assembly has a BOM and enough component stock exists.</p>
<form action={buildAssembly} className="form-grid">
<div className="form-row">
<label htmlFor="build-assembly">Assembly SKU</label>
<select className="select" id="build-assembly" name="assemblySku">{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
<select className="select" id="build-assembly" name="assemblySku" disabled={assemblies.length === 0}>{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
</div>
<div className="form-row"><label htmlFor="build-qty">Build Quantity</label><input className="input" id="build-qty" name="quantity" type="number" min="1" step="1" defaultValue="1" /></div>
<button className="button secondary" type="submit">Build Now</button>
<button className="button secondary" type="submit" disabled={assemblies.length === 0}>Build Now</button>
</form>
</article>
</section>

View File

@@ -1,12 +1,13 @@
import { receivePurchaseOrder } from "@/lib/actions";
import { PurchaseOrderFulfillmentForm } from "@/components/purchase-order-fulfillment-form";
import { PurchaseOrderForm } from "@/components/purchase-order-form";
import { formatCurrency, formatDate } from "@/lib/format";
import { getLowStockParts, getOrderItemOptions, getPurchaseOrders, getVendors } from "@/lib/repository";
import { getLowStockParts, getOrderItemOptions, getPurchaseOrderLineDetails, getPurchaseOrders, getVendors } from "@/lib/repository";
export default function PurchaseOrdersPage() {
const vendors = getVendors();
const items = getOrderItemOptions();
const orders = getPurchaseOrders();
const orderLines = getPurchaseOrderLineDetails();
const lowStockParts = getLowStockParts();
return (
@@ -19,7 +20,7 @@ export default function PurchaseOrdersPage() {
</article>
<article className="panel">
<h2 className="section-title">Receiving Flow</h2>
<p className="section-copy">Leave line quantities blank to receive the remaining balance, or enter `SKU,quantity` rows for a partial receipt.</p>
<p className="section-copy">Receive relational order lines directly by entering quantities against the remaining balance on each line.</p>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Order</th><th>Vendor</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
@@ -39,11 +40,10 @@ export default function PurchaseOrdersPage() {
{order.status === "received" ? (
<span className="muted">Received</span>
) : (
<form action={receivePurchaseOrder} className="form-grid">
<input type="hidden" name="orderId" value={order.id} />
<textarea className="textarea" name="lines" placeholder={"PART-001,4\nPART-002,10"} />
<button className="button secondary" type="submit">Receive</button>
</form>
<PurchaseOrderFulfillmentForm
orderId={order.id}
lines={orderLines.filter((line) => line.purchaseOrderId === order.id && line.remainingQuantity > 0)}
/>
)}
</td>
</tr>

View File

@@ -1,12 +1,13 @@
import { shipSalesOrder } from "@/lib/actions";
import { SalesOrderFulfillmentForm } from "@/components/sales-order-fulfillment-form";
import { SalesOrderForm } from "@/components/sales-order-form";
import { formatCurrency, formatDate } from "@/lib/format";
import { getCustomers, getOrderItemOptions, getSalesOrders } from "@/lib/repository";
import { getCustomers, getOrderItemOptions, getSalesOrderLineDetails, getSalesOrders } from "@/lib/repository";
export default function SalesOrdersPage() {
const customers = getCustomers();
const items = getOrderItemOptions();
const orders = getSalesOrders();
const orderLines = getSalesOrderLineDetails();
return (
<div className="grid">
@@ -18,7 +19,7 @@ export default function SalesOrdersPage() {
</article>
<article className="panel">
<h2 className="section-title">Shipping Flow</h2>
<p className="section-copy">Leave line quantities blank to ship the remaining balance, or enter `SKU,quantity` rows for a partial shipment.</p>
<p className="section-copy">Ship relational order lines directly by choosing quantities from the remaining balance on each line.</p>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
@@ -38,11 +39,10 @@ export default function SalesOrdersPage() {
{order.status === "shipped" ? (
<span className="muted">Shipped</span>
) : (
<form action={shipSalesOrder} className="form-grid">
<input type="hidden" name="orderId" value={order.id} />
<textarea className="textarea" name="lines" placeholder={"PART-001,1\nKIT-100,2"} />
<button className="button secondary" type="submit">Ship</button>
</form>
<SalesOrderFulfillmentForm
orderId={order.id}
lines={orderLines.filter((line) => line.salesOrderId === order.id && line.remainingQuantity > 0)}
/>
)}
</td>
</tr>

View File

@@ -0,0 +1,74 @@
"use client";
import { useMemo, useState } from "react";
import { receivePurchaseOrder } from "@/lib/actions";
import type { PurchaseOrderLineDetailRow } from "@/lib/types";
type Props = {
orderId: number;
lines: PurchaseOrderLineDetailRow[];
};
export function PurchaseOrderFulfillmentForm({ orderId, lines }: Props) {
const [quantities, setQuantities] = useState<Record<number, string>>({});
const payload = useMemo(
() =>
JSON.stringify(
lines
.map((line) => ({
lineId: line.lineId,
quantity: Number(quantities[line.lineId] || 0)
}))
.filter((line) => line.quantity > 0)
),
[lines, quantities]
);
return (
<form action={receivePurchaseOrder} className="form-grid">
<input type="hidden" name="orderId" value={orderId} />
<input type="hidden" name="fulfillmentLines" value={payload} />
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>SKU</th>
<th>Item</th>
<th>Remaining</th>
<th>Receive Now</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.lineId}>
<td>{line.sku}</td>
<td>{line.partName}</td>
<td>{line.remainingQuantity} {line.unitOfMeasure}</td>
<td>
<input
className="input"
type="number"
min="0"
max={line.remainingQuantity}
step="0.01"
value={quantities[line.lineId] ?? ""}
onChange={(event) =>
setQuantities((current) => ({
...current,
[line.lineId]: event.target.value
}))
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button className="button secondary" type="submit">
Receive Selected Quantities
</button>
</form>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useMemo, useState } from "react";
import { shipSalesOrder } from "@/lib/actions";
import type { SalesOrderLineDetailRow } from "@/lib/types";
type Props = {
orderId: number;
lines: SalesOrderLineDetailRow[];
};
export function SalesOrderFulfillmentForm({ orderId, lines }: Props) {
const [quantities, setQuantities] = useState<Record<number, string>>({});
const payload = useMemo(
() =>
JSON.stringify(
lines
.map((line) => ({
lineId: line.lineId,
quantity: Number(quantities[line.lineId] || 0)
}))
.filter((line) => line.quantity > 0)
),
[lines, quantities]
);
return (
<form action={shipSalesOrder} className="form-grid">
<input type="hidden" name="orderId" value={orderId} />
<input type="hidden" name="fulfillmentLines" value={payload} />
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>SKU</th>
<th>Item</th>
<th>Remaining</th>
<th>On Hand</th>
<th>Ship Now</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.lineId}>
<td>{line.sku}</td>
<td>{line.partName}</td>
<td>{line.remainingQuantity} {line.unitOfMeasure}</td>
<td>{line.quantityOnHand} {line.unitOfMeasure}</td>
<td>
<input
className="input"
type="number"
min="0"
max={Math.min(line.remainingQuantity, line.quantityOnHand)}
step="0.01"
value={quantities[line.lineId] ?? ""}
onChange={(event) =>
setQuantities((current) => ({
...current,
[line.lineId]: event.target.value
}))
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button className="button secondary" type="submit">
Ship Selected Quantities
</button>
</form>
);
}

View File

@@ -22,6 +22,11 @@ type ParsedFulfillmentLine = {
quantity: number;
};
type RelationalFulfillmentLine = {
lineId: number;
quantity: number;
};
function db() {
return getDb();
}
@@ -121,6 +126,32 @@ function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
});
}
function parseRelationalFulfillmentLines(raw: string): RelationalFulfillmentLine[] {
try {
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error("No fulfillment lines selected.");
}
return parsed.map((line) => {
const lineId = Number(line.lineId);
const quantity = Number(line.quantity);
if (!Number.isInteger(lineId) || lineId <= 0) {
throw new Error("Invalid line selection.");
}
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error("Invalid fulfillment quantity.");
}
return { lineId, quantity };
});
} catch {
throw new Error("Invalid relational fulfillment payload.");
}
}
function getPartIdBySku(sku: string) {
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
if (!row) {
@@ -274,8 +305,12 @@ export async function buildAssembly(formData: FormData) {
const assemblySku = getText(formData, "assemblySku");
const buildQuantity = getNumber(formData, "quantity");
if (!assemblySku) {
redirect("/assemblies?error=Select an assembly before building.");
}
if (buildQuantity <= 0) {
throw new Error("Build quantity must be greater than zero.");
redirect("/assemblies?error=Build quantity must be greater than zero.");
}
const assemblyId = getPartIdBySku(assemblySku);
@@ -304,13 +339,13 @@ export async function buildAssembly(formData: FormData) {
}>;
if (components.length === 0) {
throw new Error("Assembly has no bill of materials defined.");
redirect("/assemblies?error=Assembly has no bill of materials defined.");
}
for (const component of components) {
const needed = component.componentQuantity * buildQuantity;
if (component.quantityOnHand < needed) {
throw new Error(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`);
redirect(`/assemblies?error=${encodeURIComponent(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`)}`);
}
}
@@ -354,6 +389,7 @@ export async function buildAssembly(formData: FormData) {
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/assemblies");
redirect(`/assemblies?success=${encodeURIComponent(`Built ${buildQuantity} of ${assemblySku}.`)}`);
}
export async function recordAdjustment(formData: FormData) {
@@ -548,18 +584,18 @@ export async function shipSalesOrder(formData: FormData) {
throw new Error("Sales order has no lines.");
}
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
const fulfilledLines = requestedLines.length
? requestedLines.map((request) => {
const matchingLine = orderLines.find((line) => line.sku === request.sku);
const relationalPayload = getText(formData, "fulfillmentLines");
const fulfilledLines = relationalPayload
? parseRelationalFulfillmentLines(relationalPayload).map((request) => {
const matchingLine = orderLines.find((line) => line.lineId === request.lineId);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this sales order.`);
throw new Error("Selected sales order line is invalid.");
}
const remaining = matchingLine.quantity - matchingLine.shippedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot ship ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
throw new Error(`Cannot ship ${request.quantity} of ${matchingLine.sku}; only ${remaining} remain.`);
}
if (matchingLine.quantityOnHand < request.quantity) {
@@ -568,7 +604,26 @@ export async function shipSalesOrder(formData: FormData) {
return { ...matchingLine, shipQuantity: request.quantity };
})
: orderLines
: parseFulfillmentLines(getText(formData, "lines")).length
? parseFulfillmentLines(getText(formData, "lines")).map((request) => {
const matchingLine = orderLines.find((line) => line.sku === request.sku);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this sales order.`);
}
const remaining = matchingLine.quantity - matchingLine.shippedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot ship ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
}
if (matchingLine.quantityOnHand < request.quantity) {
throw new Error(`Insufficient stock for ${matchingLine.sku}. Need ${request.quantity}, have ${matchingLine.quantityOnHand}.`);
}
return { ...matchingLine, shipQuantity: request.quantity };
})
: orderLines
.map((line) => {
const remaining = line.quantity - line.shippedQuantity;
return remaining > 0 ? { ...line, shipQuantity: remaining } : null;
@@ -718,23 +773,38 @@ export async function receivePurchaseOrder(formData: FormData) {
throw new Error("Purchase order has no lines.");
}
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
const fulfilledLines = requestedLines.length
? requestedLines.map((request) => {
const matchingLine = lines.find((line) => line.sku === request.sku);
const relationalPayload = getText(formData, "fulfillmentLines");
const fulfilledLines = relationalPayload
? parseRelationalFulfillmentLines(relationalPayload).map((request) => {
const matchingLine = lines.find((line) => line.lineId === request.lineId);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this purchase order.`);
throw new Error("Selected purchase order line is invalid.");
}
const remaining = matchingLine.quantity - matchingLine.receivedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot receive ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
throw new Error(`Cannot receive ${request.quantity} of ${matchingLine.sku}; only ${remaining} remain.`);
}
return { ...matchingLine, receiveQuantity: request.quantity };
})
: lines
: parseFulfillmentLines(getText(formData, "lines")).length
? parseFulfillmentLines(getText(formData, "lines")).map((request) => {
const matchingLine = lines.find((line) => line.sku === request.sku);
if (!matchingLine) {
throw new Error(`SKU ${request.sku} is not on this purchase order.`);
}
const remaining = matchingLine.quantity - matchingLine.receivedQuantity;
if (request.quantity > remaining) {
throw new Error(`Cannot receive ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
}
return { ...matchingLine, receiveQuantity: request.quantity };
})
: lines
.map((line) => {
const remaining = line.quantity - line.receivedQuantity;
return remaining > 0 ? { ...line, receiveQuantity: remaining } : null;

View File

@@ -11,7 +11,9 @@ import type {
OrderItemOption,
PartRow,
PurchaseOrderListRow,
PurchaseOrderLineDetailRow,
SalesOrderListRow,
SalesOrderLineDetailRow,
VendorBillRow
} from "@/lib/types";
@@ -190,6 +192,54 @@ export function getPurchaseOrders(): PurchaseOrderListRow[] {
.all() as PurchaseOrderListRow[];
}
export function getSalesOrderLineDetails(): SalesOrderLineDetailRow[] {
return db()
.prepare(
`
SELECT
sol.id AS lineId,
sol.sales_order_id AS salesOrderId,
sol.part_id AS partId,
p.sku,
p.name AS partName,
sol.quantity,
sol.shipped_quantity AS fulfilledQuantity,
sol.quantity - sol.shipped_quantity AS remainingQuantity,
sol.unit_price AS unitPrice,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand,
p.unit_of_measure AS unitOfMeasure
FROM sales_order_lines sol
INNER JOIN parts p ON p.id = sol.part_id
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
ORDER BY sol.sales_order_id DESC, sol.id ASC
`
)
.all() as SalesOrderLineDetailRow[];
}
export function getPurchaseOrderLineDetails(): PurchaseOrderLineDetailRow[] {
return db()
.prepare(
`
SELECT
pol.id AS lineId,
pol.purchase_order_id AS purchaseOrderId,
pol.part_id AS partId,
p.sku,
p.name AS partName,
pol.quantity,
pol.received_quantity AS fulfilledQuantity,
pol.quantity - pol.received_quantity AS remainingQuantity,
pol.unit_cost AS unitCost,
p.unit_of_measure AS unitOfMeasure
FROM purchase_order_lines pol
INNER JOIN parts p ON p.id = pol.part_id
ORDER BY pol.purchase_order_id DESC, pol.id ASC
`
)
.all() as PurchaseOrderLineDetailRow[];
}
export function getJournalEntries(): JournalEntryRow[] {
const entries = db()
.prepare(

View File

@@ -141,3 +141,30 @@ export type VendorBillRow = {
paidAmount: number;
balanceDue: number;
};
export type SalesOrderLineDetailRow = {
lineId: number;
salesOrderId: number;
partId: number;
sku: string;
partName: string;
quantity: number;
fulfilledQuantity: number;
remainingQuantity: number;
unitPrice: number;
quantityOnHand: number;
unitOfMeasure: string;
};
export type PurchaseOrderLineDetailRow = {
lineId: number;
purchaseOrderId: number;
partId: number;
sku: string;
partName: string;
quantity: number;
fulfilledQuantity: number;
remainingQuantity: number;
unitCost: number;
unitOfMeasure: string;
};