initial release testing

This commit is contained in:
2026-03-23 16:16:45 -05:00
parent f079fdca62
commit 6659707890
37 changed files with 3374 additions and 37 deletions

865
lib/actions.ts Normal file
View File

@@ -0,0 +1,865 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { authenticateUser, createSession, destroySession } from "@/lib/auth";
import { getDb } from "@/lib/db";
type ParsedLine = {
sku: string;
quantity: number;
amount: number;
};
type ParsedFulfillmentLine = {
sku: string;
quantity: number;
};
function db() {
return getDb();
}
function getText(formData: FormData, key: string) {
return String(formData.get(key) ?? "").trim();
}
function getNumber(formData: FormData, key: string) {
const value = Number(getText(formData, key));
return Number.isFinite(value) ? value : 0;
}
function parseLines(raw: string): ParsedLine[] {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sku, quantity, amount] = line.split(",").map((piece) => piece.trim());
if (!sku || !quantity || !amount) {
throw new Error(`Invalid line format: "${line}". Use SKU,quantity,amount.`);
}
const parsedQuantity = Number(quantity);
const parsedAmount = Number(amount);
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0 || !Number.isFinite(parsedAmount) || parsedAmount < 0) {
throw new Error(`Invalid line values: "${line}".`);
}
return {
sku,
quantity: parsedQuantity,
amount: parsedAmount
};
});
}
function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [sku, quantity] = line.split(",").map((piece) => piece.trim());
if (!sku || !quantity) {
throw new Error(`Invalid fulfillment line format: "${line}". Use SKU,quantity.`);
}
const parsedQuantity = Number(quantity);
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0) {
throw new Error(`Invalid fulfillment quantity: "${line}".`);
}
return { sku, quantity: parsedQuantity };
});
}
function getPartIdBySku(sku: string) {
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
if (!row) {
throw new Error(`Part with SKU "${sku}" does not exist.`);
}
return row.id;
}
function getOrderNumber(prefix: string, table: "sales_orders" | "purchase_orders") {
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
}
function getDocumentNumber(prefix: string, table: "customer_invoices" | "vendor_bills") {
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
}
function createJournalEntry(
entryType: string,
referenceType: string,
referenceId: number | null,
description: string,
lines: Array<{ accountCode: string; accountName: string; debit: number; credit: number }>
) {
const tx = db().transaction(() => {
const result = db()
.prepare(
`
INSERT INTO journal_entries (entry_type, reference_type, reference_id, description)
VALUES (?, ?, ?, ?)
`
)
.run(entryType, referenceType, referenceId, description);
const journalEntryId = Number(result.lastInsertRowid);
const insertLine = db().prepare(
`
INSERT INTO journal_lines (journal_entry_id, account_code, account_name, debit, credit)
VALUES (?, ?, ?, ?, ?)
`
);
for (const line of lines) {
insertLine.run(journalEntryId, line.accountCode, line.accountName, line.debit, line.credit);
}
});
tx();
}
function resolveAccount(code: string) {
const row = db().prepare(`SELECT code, name FROM accounts WHERE code = ?`).get(code) as { code: string; name: string } | undefined;
if (!row) {
throw new Error(`Account "${code}" does not exist.`);
}
return row;
}
function parseJournalLines(raw: string) {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [accountCode, debit, credit] = line.split(",").map((piece) => piece.trim());
if (!accountCode || debit === undefined || credit === undefined) {
throw new Error(`Invalid journal line format: "${line}". Use account code,debit,credit.`);
}
const parsedDebit = Number(debit);
const parsedCredit = Number(credit);
if (!Number.isFinite(parsedDebit) || !Number.isFinite(parsedCredit) || parsedDebit < 0 || parsedCredit < 0) {
throw new Error(`Invalid journal amounts: "${line}".`);
}
if ((parsedDebit === 0 && parsedCredit === 0) || (parsedDebit > 0 && parsedCredit > 0)) {
throw new Error(`Journal lines must have either a debit or a credit: "${line}".`);
}
const account = resolveAccount(accountCode);
return {
accountCode: account.code,
accountName: account.name,
debit: parsedDebit,
credit: parsedCredit
};
});
}
export async function createPart(formData: FormData) {
db()
.prepare(
`
INSERT INTO parts (sku, name, description, kind, unit_cost, sale_price, reorder_point, unit_of_measure)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
)
.run(
getText(formData, "sku"),
getText(formData, "name"),
getText(formData, "description"),
getText(formData, "kind"),
getNumber(formData, "unitCost"),
getNumber(formData, "salePrice"),
getNumber(formData, "reorderPoint"),
getText(formData, "unitOfMeasure") || "ea"
);
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/assemblies");
}
export async function addKitComponent(formData: FormData) {
const assemblyId = getPartIdBySku(getText(formData, "assemblySku"));
const componentId = getPartIdBySku(getText(formData, "componentSku"));
db()
.prepare(
`
INSERT INTO kit_components (assembly_part_id, component_part_id, quantity)
VALUES (?, ?, ?)
ON CONFLICT (assembly_part_id, component_part_id)
DO UPDATE SET quantity = excluded.quantity
`
)
.run(assemblyId, componentId, getNumber(formData, "quantity"));
revalidatePath("/assemblies");
}
export async function buildAssembly(formData: FormData) {
const assemblySku = getText(formData, "assemblySku");
const buildQuantity = getNumber(formData, "quantity");
if (buildQuantity <= 0) {
throw new Error("Build quantity must be greater than zero.");
}
const assemblyId = getPartIdBySku(assemblySku);
const components = db()
.prepare(
`
SELECT
kc.component_part_id AS componentId,
kc.quantity AS componentQuantity,
p.sku,
p.unit_cost AS unitCost,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
FROM kit_components kc
INNER JOIN parts p ON p.id = kc.component_part_id
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
WHERE kc.assembly_part_id = ?
`
)
.all(assemblyId) as Array<{
componentId: number;
componentQuantity: number;
sku: string;
unitCost: number;
quantityOnHand: number;
}>;
if (components.length === 0) {
throw new 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}.`);
}
}
const buildCost = components.reduce((sum, component) => sum + component.unitCost * component.componentQuantity, 0);
const tx = db().transaction(() => {
const insertInventory = db().prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
);
const updateAssemblyCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
for (const component of components) {
insertInventory.run(
component.componentId,
component.componentQuantity * buildQuantity * -1,
component.unitCost,
"assembly_consume",
"assembly_build",
assemblyId,
`Consumed for ${assemblySku}`
);
}
insertInventory.run(
assemblyId,
buildQuantity,
buildCost,
"assembly_build",
"assembly_build",
assemblyId,
`Built ${buildQuantity} of ${assemblySku}`
);
updateAssemblyCost.run(buildCost, assemblyId);
});
tx();
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/assemblies");
}
export async function recordAdjustment(formData: FormData) {
const partId = getPartIdBySku(getText(formData, "sku"));
const quantityDelta = getNumber(formData, "quantityDelta");
const unitCost = getNumber(formData, "unitCost");
const notes = getText(formData, "notes");
const inventoryImpact = Math.abs(quantityDelta * unitCost);
db()
.prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, notes)
VALUES (?, ?, ?, 'adjustment', 'manual_adjustment', ?)
`
)
.run(partId, quantityDelta, unitCost, notes);
if (inventoryImpact > 0) {
createJournalEntry("adjustment", "manual_adjustment", partId, notes || "Inventory adjustment posted", [
{
accountCode: quantityDelta >= 0 ? "1200" : "6100",
accountName: quantityDelta >= 0 ? "Inventory" : "Inventory Adjustments",
debit: quantityDelta >= 0 ? inventoryImpact : 0,
credit: quantityDelta >= 0 ? 0 : inventoryImpact
},
{
accountCode: quantityDelta >= 0 ? "3000" : "1200",
accountName: quantityDelta >= 0 ? "Owner Equity" : "Inventory",
debit: quantityDelta >= 0 ? 0 : inventoryImpact,
credit: quantityDelta >= 0 ? inventoryImpact : 0
}
]);
}
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/accounting");
}
export async function createCustomer(formData: FormData) {
db()
.prepare(
`
INSERT INTO customers (code, name, email, phone, billing_address, shipping_address)
VALUES (?, ?, ?, ?, ?, ?)
`
)
.run(
getText(formData, "code"),
getText(formData, "name"),
getText(formData, "email"),
getText(formData, "phone"),
getText(formData, "billingAddress"),
getText(formData, "shippingAddress")
);
revalidatePath("/customers");
revalidatePath("/");
}
export async function createAccount(formData: FormData) {
const category = getText(formData, "category");
if (!["asset", "liability", "equity", "revenue", "expense"].includes(category)) {
throw new Error("Invalid account category.");
}
db()
.prepare(
`
INSERT INTO accounts (code, name, category, is_system)
VALUES (?, ?, ?, 0)
`
)
.run(getText(formData, "code"), getText(formData, "name"), category);
revalidatePath("/accounting");
}
export async function createVendor(formData: FormData) {
db()
.prepare(
`
INSERT INTO vendors (code, name, email, phone, address)
VALUES (?, ?, ?, ?, ?)
`
)
.run(
getText(formData, "code"),
getText(formData, "name"),
getText(formData, "email"),
getText(formData, "phone"),
getText(formData, "address")
);
revalidatePath("/vendors");
revalidatePath("/");
}
export async function createSalesOrder(formData: FormData) {
const customerCode = getText(formData, "customerCode");
const lines = parseLines(getText(formData, "lines"));
const customerRow = db().prepare(`SELECT id FROM customers WHERE code = ?`).get(customerCode) as { id: number } | undefined;
if (!customerRow) {
throw new Error(`Customer "${customerCode}" does not exist.`);
}
const tx = db().transaction(() => {
const result = db()
.prepare(
`
INSERT INTO sales_orders (order_number, customer_id, status, notes)
VALUES (?, ?, 'open', ?)
`
)
.run(getOrderNumber("SO", "sales_orders"), customerRow.id, getText(formData, "notes"));
const orderId = Number(result.lastInsertRowid);
const insertLine = db().prepare(
`
INSERT INTO sales_order_lines (sales_order_id, part_id, quantity, unit_price)
VALUES (?, ?, ?, ?)
`
);
for (const line of lines) {
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
}
});
tx();
revalidatePath("/");
revalidatePath("/sales-orders");
}
export async function shipSalesOrder(formData: FormData) {
const orderId = Number(getText(formData, "orderId"));
const order = db()
.prepare(`SELECT customer_id AS customerId, status FROM sales_orders WHERE id = ?`)
.get(orderId) as { customerId: number; status: string } | undefined;
if (!order) {
throw new Error("Sales order not found.");
}
if (order.status === "shipped") {
throw new Error("Sales order has already been shipped.");
}
const orderLines = db()
.prepare(
`
SELECT
sol.id AS lineId,
so.order_number AS orderNumber,
p.id AS partId,
p.sku,
p.unit_cost AS unitCost,
sol.quantity,
sol.shipped_quantity AS shippedQuantity,
sol.unit_price AS unitPrice,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
FROM sales_order_lines sol
INNER JOIN sales_orders so ON so.id = sol.sales_order_id
INNER JOIN parts p ON p.id = sol.part_id
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
WHERE sol.sales_order_id = ?
`
)
.all(orderId) as Array<{
lineId: number;
orderNumber: string;
partId: number;
sku: string;
unitCost: number;
quantity: number;
shippedQuantity: number;
unitPrice: number;
quantityOnHand: number;
}>;
if (orderLines.length === 0) {
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);
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;
})
.filter((line): line is NonNullable<typeof line> => line !== null);
if (fulfilledLines.length === 0) {
throw new Error("No shippable quantities were provided.");
}
const revenue = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitPrice, 0);
const cogs = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitCost, 0);
const orderNumber = orderLines[0].orderNumber;
const tx = db().transaction(() => {
const insertInventory = db().prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
VALUES (?, ?, ?, 'sales_shipment', 'sales_order', ?, ?)
`
);
const updateLine = db().prepare(`UPDATE sales_order_lines SET shipped_quantity = shipped_quantity + ? WHERE id = ?`);
for (const line of fulfilledLines) {
insertInventory.run(line.partId, line.shipQuantity * -1, line.unitCost, orderId, `Shipment for ${orderNumber}`);
updateLine.run(line.shipQuantity, line.lineId);
}
const remainingCount = db()
.prepare(`SELECT COUNT(*) AS count FROM sales_order_lines WHERE sales_order_id = ? AND shipped_quantity < quantity`)
.get(orderId) as { count: number };
const nextStatus = remainingCount.count > 0 ? "partial" : "shipped";
db().prepare(`UPDATE sales_orders SET status = ?, shipped_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
db()
.prepare(
`
INSERT INTO customer_invoices (invoice_number, sales_order_id, customer_id, status, due_date, total_amount, paid_amount)
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
`
)
.run(getDocumentNumber("INV", "customer_invoices"), orderId, order.customerId, revenue);
});
tx();
createJournalEntry("shipment", "sales_order", orderId, `Shipment posted for ${orderNumber}`, [
{ accountCode: "1100", accountName: "Accounts Receivable", debit: revenue, credit: 0 },
{ accountCode: "4000", accountName: "Sales Revenue", debit: 0, credit: revenue },
{ accountCode: "5000", accountName: "Cost of Goods Sold", debit: cogs, credit: 0 },
{ accountCode: "1200", accountName: "Inventory", debit: 0, credit: cogs }
]);
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/sales-orders");
revalidatePath("/accounting");
revalidatePath("/invoices");
}
export async function createPurchaseOrder(formData: FormData) {
const vendorCode = getText(formData, "vendorCode");
const lines = parseLines(getText(formData, "lines"));
const vendorRow = db().prepare(`SELECT id FROM vendors WHERE code = ?`).get(vendorCode) as { id: number } | undefined;
if (!vendorRow) {
throw new Error(`Vendor "${vendorCode}" does not exist.`);
}
const tx = db().transaction(() => {
const result = db()
.prepare(
`
INSERT INTO purchase_orders (order_number, vendor_id, status, notes)
VALUES (?, ?, 'ordered', ?)
`
)
.run(getOrderNumber("PO", "purchase_orders"), vendorRow.id, getText(formData, "notes"));
const orderId = Number(result.lastInsertRowid);
const insertLine = db().prepare(
`
INSERT INTO purchase_order_lines (purchase_order_id, part_id, quantity, unit_cost)
VALUES (?, ?, ?, ?)
`
);
for (const line of lines) {
insertLine.run(orderId, getPartIdBySku(line.sku), line.quantity, line.amount);
}
});
tx();
revalidatePath("/");
revalidatePath("/purchase-orders");
}
export async function receivePurchaseOrder(formData: FormData) {
const orderId = Number(getText(formData, "orderId"));
const order = db()
.prepare(`SELECT vendor_id AS vendorId, status FROM purchase_orders WHERE id = ?`)
.get(orderId) as { vendorId: number; status: string } | undefined;
if (!order) {
throw new Error("Purchase order not found.");
}
if (order.status === "received") {
throw new Error("Purchase order has already been received.");
}
const lines = db()
.prepare(
`
SELECT
pol.id AS lineId,
po.order_number AS orderNumber,
pol.part_id AS partId,
pol.quantity,
pol.received_quantity AS receivedQuantity,
p.sku,
pol.unit_cost AS unitCost
FROM purchase_order_lines pol
INNER JOIN purchase_orders po ON po.id = pol.purchase_order_id
INNER JOIN parts p ON p.id = pol.part_id
WHERE pol.purchase_order_id = ?
`
)
.all(orderId) as Array<{
lineId: number;
orderNumber: string;
partId: number;
quantity: number;
receivedQuantity: number;
sku: string;
unitCost: number;
}>;
if (lines.length === 0) {
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);
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;
})
.filter((line): line is NonNullable<typeof line> => line !== null);
if (fulfilledLines.length === 0) {
throw new Error("No receivable quantities were provided.");
}
const receiptValue = fulfilledLines.reduce((sum, line) => sum + line.receiveQuantity * line.unitCost, 0);
const orderNumber = lines[0].orderNumber;
const tx = db().transaction(() => {
const insertInventory = db().prepare(
`
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
VALUES (?, ?, ?, 'purchase_receipt', 'purchase_order', ?, ?)
`
);
const updatePartCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
const updateLine = db().prepare(`UPDATE purchase_order_lines SET received_quantity = received_quantity + ? WHERE id = ?`);
for (const line of fulfilledLines) {
insertInventory.run(line.partId, line.receiveQuantity, line.unitCost, orderId, `Receipt for ${orderNumber}`);
updatePartCost.run(line.unitCost, line.partId);
updateLine.run(line.receiveQuantity, line.lineId);
}
const remainingCount = db()
.prepare(`SELECT COUNT(*) AS count FROM purchase_order_lines WHERE purchase_order_id = ? AND received_quantity < quantity`)
.get(orderId) as { count: number };
const nextStatus = remainingCount.count > 0 ? "partial" : "received";
db().prepare(`UPDATE purchase_orders SET status = ?, received_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
db()
.prepare(
`
INSERT INTO vendor_bills (bill_number, purchase_order_id, vendor_id, status, due_date, total_amount, paid_amount)
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
`
)
.run(getDocumentNumber("BILL", "vendor_bills"), orderId, order.vendorId, receiptValue);
});
tx();
createJournalEntry("receipt", "purchase_order", orderId, `Receipt posted for ${orderNumber}`, [
{ accountCode: "1200", accountName: "Inventory", debit: receiptValue, credit: 0 },
{ accountCode: "2000", accountName: "Accounts Payable", debit: 0, credit: receiptValue }
]);
revalidatePath("/");
revalidatePath("/parts");
revalidatePath("/purchase-orders");
revalidatePath("/accounting");
revalidatePath("/vendor-bills");
}
export async function createManualJournalEntry(formData: FormData) {
const description = getText(formData, "description");
const lines = parseJournalLines(getText(formData, "lines"));
const debitTotal = lines.reduce((sum, line) => sum + line.debit, 0);
const creditTotal = lines.reduce((sum, line) => sum + line.credit, 0);
if (Math.abs(debitTotal - creditTotal) > 0.005) {
throw new Error("Manual journal entry is not balanced.");
}
createJournalEntry("manual", "manual_journal", null, description || "Manual journal entry", lines);
revalidatePath("/accounting");
}
export async function loginAction(formData: FormData) {
const email = getText(formData, "email");
const password = getText(formData, "password");
const user = authenticateUser(db(), email, password);
if (!user) {
throw new Error("Invalid email or password.");
}
await createSession(user);
redirect("/");
}
export async function logoutAction() {
await destroySession();
redirect("/login");
}
export async function receiveCustomerPayment(formData: FormData) {
const invoiceId = Number(getText(formData, "invoiceId"));
const amount = getNumber(formData, "amount");
const notes = getText(formData, "notes");
if (amount <= 0) {
throw new Error("Payment amount must be greater than zero.");
}
const invoice = db()
.prepare(
`
SELECT id, invoice_number AS invoiceNumber, total_amount AS totalAmount, paid_amount AS paidAmount
FROM customer_invoices
WHERE id = ?
`
)
.get(invoiceId) as { id: number; invoiceNumber: string; totalAmount: number; paidAmount: number } | undefined;
if (!invoice) {
throw new Error("Invoice not found.");
}
const balanceDue = invoice.totalAmount - invoice.paidAmount;
if (amount > balanceDue) {
throw new Error("Payment cannot exceed invoice balance.");
}
const newPaidAmount = invoice.paidAmount + amount;
const newStatus = Math.abs(newPaidAmount - invoice.totalAmount) <= 0.005 ? "paid" : "partial";
const tx = db().transaction(() => {
db()
.prepare(`INSERT INTO customer_payments (invoice_id, amount, notes) VALUES (?, ?, ?)`)
.run(invoiceId, amount, notes);
db()
.prepare(`UPDATE customer_invoices SET paid_amount = ?, status = ? WHERE id = ?`)
.run(newPaidAmount, newStatus, invoiceId);
});
tx();
createJournalEntry("customer_payment", "customer_invoice", invoiceId, `Payment received for ${invoice.invoiceNumber}`, [
{ accountCode: "1000", accountName: "Cash", debit: amount, credit: 0 },
{ accountCode: "1100", accountName: "Accounts Receivable", debit: 0, credit: amount }
]);
revalidatePath("/");
revalidatePath("/accounting");
revalidatePath("/invoices");
}
export async function payVendorBill(formData: FormData) {
const vendorBillId = Number(getText(formData, "vendorBillId"));
const amount = getNumber(formData, "amount");
const notes = getText(formData, "notes");
if (amount <= 0) {
throw new Error("Payment amount must be greater than zero.");
}
const bill = db()
.prepare(
`
SELECT id, bill_number AS billNumber, total_amount AS totalAmount, paid_amount AS paidAmount
FROM vendor_bills
WHERE id = ?
`
)
.get(vendorBillId) as { id: number; billNumber: string; totalAmount: number; paidAmount: number } | undefined;
if (!bill) {
throw new Error("Vendor bill not found.");
}
const balanceDue = bill.totalAmount - bill.paidAmount;
if (amount > balanceDue) {
throw new Error("Payment cannot exceed vendor bill balance.");
}
const newPaidAmount = bill.paidAmount + amount;
const newStatus = Math.abs(newPaidAmount - bill.totalAmount) <= 0.005 ? "paid" : "partial";
const tx = db().transaction(() => {
db()
.prepare(`INSERT INTO vendor_payments (vendor_bill_id, amount, notes) VALUES (?, ?, ?)`)
.run(vendorBillId, amount, notes);
db()
.prepare(`UPDATE vendor_bills SET paid_amount = ?, status = ? WHERE id = ?`)
.run(newPaidAmount, newStatus, vendorBillId);
});
tx();
createJournalEntry("vendor_payment", "vendor_bill", vendorBillId, `Vendor payment posted for ${bill.billNumber}`, [
{ accountCode: "2000", accountName: "Accounts Payable", debit: amount, credit: 0 },
{ accountCode: "1000", accountName: "Cash", debit: 0, credit: amount }
]);
revalidatePath("/");
revalidatePath("/accounting");
revalidatePath("/vendor-bills");
}

145
lib/auth.ts Normal file
View File

@@ -0,0 +1,145 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const SESSION_COOKIE = "inven_session";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 14;
type SessionPayload = {
userId: number;
email: string;
role: string;
expiresAt: number;
};
function getAuthSecret() {
return process.env.AUTH_SECRET || "dev-insecure-auth-secret";
}
function hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(password: string, storedHash: string) {
const [salt, expectedHash] = storedHash.split(":");
if (!salt || !expectedHash) {
return false;
}
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(expectedHash, "hex"));
}
function sign(value: string) {
return crypto.createHmac("sha256", getAuthSecret()).update(value).digest("hex");
}
function encodeSession(payload: SessionPayload) {
const base = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${base}.${sign(base)}`;
}
function decodeSession(value: string | undefined): SessionPayload | null {
if (!value) {
return null;
}
const [base, signature] = value.split(".");
if (!base || !signature || sign(base) !== signature) {
return null;
}
try {
const payload = JSON.parse(Buffer.from(base, "base64url").toString("utf8")) as SessionPayload;
if (payload.expiresAt < Date.now()) {
return null;
}
return payload;
} catch {
return null;
}
}
export function bootstrapAdminUser(db: {
prepare: (sql: string) => {
get: (...args: unknown[]) => unknown;
run: (...args: unknown[]) => unknown;
};
}) {
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM users`).get() as { count: number };
if ((countRow.count ?? 0) > 0) {
return;
}
const email = (process.env.ADMIN_EMAIL || "").trim();
const password = process.env.ADMIN_PASSWORD || "";
if (!email || !password) {
return;
}
db.prepare(`INSERT INTO users (email, password_hash, role) VALUES (?, ?, 'admin')`).run(email, hashPassword(password));
}
export async function getSession() {
const cookieStore = await cookies();
return decodeSession(cookieStore.get(SESSION_COOKIE)?.value);
}
export async function requireSession() {
const session = await getSession();
if (!session) {
redirect("/login");
}
return session;
}
export async function createSession(user: { id: number; email: string; role: string }) {
const cookieStore = await cookies();
const payload: SessionPayload = {
userId: user.id,
email: user.email,
role: user.role,
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000
};
cookieStore.set(SESSION_COOKIE, encodeSession(payload), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_TTL_SECONDS
});
}
export async function destroySession() {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export function authenticateUser(
db: {
prepare: (sql: string) => {
get: (...args: unknown[]) => unknown;
};
},
email: string,
password: string
) {
const user = db
.prepare(`SELECT id, email, password_hash AS passwordHash, role FROM users WHERE email = ?`)
.get(email) as { id: number; email: string; passwordHash: string; role: string } | undefined;
if (!user || !verifyPassword(password, user.passwordHash)) {
return null;
}
return {
id: user.id,
email: user.email,
role: user.role
};
}

44
lib/db.ts Normal file
View File

@@ -0,0 +1,44 @@
import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import { bootstrapAdminUser } from "@/lib/auth";
import { bootstrapSql } from "@/lib/schema";
declare global {
var __invenDb: Database.Database | undefined;
}
function resolveDatabasePath() {
const configured = process.env.DATABASE_PATH ?? path.join(process.cwd(), "data", "inven.sqlite");
fs.mkdirSync(path.dirname(configured), { recursive: true });
return configured;
}
function createDatabase() {
const db = new Database(resolveDatabasePath());
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.exec(bootstrapSql);
const salesLineColumns = db.prepare(`PRAGMA table_info(sales_order_lines)`).all() as Array<{ name: string }>;
const purchaseLineColumns = db.prepare(`PRAGMA table_info(purchase_order_lines)`).all() as Array<{ name: string }>;
if (!salesLineColumns.some((column) => column.name === "shipped_quantity")) {
db.exec(`ALTER TABLE sales_order_lines ADD COLUMN shipped_quantity REAL NOT NULL DEFAULT 0`);
}
if (!purchaseLineColumns.some((column) => column.name === "received_quantity")) {
db.exec(`ALTER TABLE purchase_order_lines ADD COLUMN received_quantity REAL NOT NULL DEFAULT 0`);
}
bootstrapAdminUser(db);
return db;
}
export function getDb() {
if (!global.__invenDb) {
global.__invenDb = createDatabase();
}
return global.__invenDb;
}

13
lib/format.ts Normal file
View File

@@ -0,0 +1,13 @@
export function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(value ?? 0);
}
export function formatDate(value: string) {
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value));
}

319
lib/repository.ts Normal file
View File

@@ -0,0 +1,319 @@
import { getDb } from "@/lib/db";
import type {
AccountBalanceRow,
AccountRow,
ContactRow,
DashboardStats,
InvoiceRow,
JournalEntryRow,
KitRow,
LowStockRow,
PartRow,
PurchaseOrderListRow,
SalesOrderListRow,
VendorBillRow
} from "@/lib/types";
function db() {
return getDb();
}
export function getDashboardStats(): DashboardStats {
const row = db()
.prepare(
`
SELECT
(SELECT COUNT(*) FROM parts WHERE kind = 'part') AS total_parts,
(SELECT COUNT(*) FROM parts WHERE kind = 'assembly') AS total_assemblies,
(SELECT COUNT(*) FROM customers) AS active_customers,
(SELECT COUNT(*) FROM vendors) AS active_vendors,
(SELECT COUNT(*) FROM sales_orders WHERE status IN ('draft', 'open', 'partial')) AS open_sales_orders,
(SELECT COUNT(*) FROM purchase_orders WHERE status IN ('draft', 'ordered', 'partial')) AS open_purchase_orders,
(SELECT COUNT(*) FROM customer_invoices WHERE status IN ('open', 'partial')) AS open_invoices,
(SELECT COUNT(*) FROM vendor_bills WHERE status IN ('open', 'partial')) AS open_vendor_bills,
(
SELECT COUNT(*)
FROM parts p
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
WHERE ib.quantity_on_hand <= p.reorder_point
) AS low_stock_count,
(
SELECT COALESCE(SUM(ib.quantity_on_hand * p.unit_cost), 0)
FROM parts p
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
) AS inventory_value,
(
SELECT COALESCE(SUM(total_amount - paid_amount), 0)
FROM customer_invoices
WHERE status IN ('open', 'partial')
) AS accounts_receivable,
(
SELECT COALESCE(SUM(total_amount - paid_amount), 0)
FROM vendor_bills
WHERE status IN ('open', 'partial')
) AS accounts_payable
`
)
.get() as Record<string, number>;
return {
totalParts: row.total_parts ?? 0,
totalAssemblies: row.total_assemblies ?? 0,
activeCustomers: row.active_customers ?? 0,
activeVendors: row.active_vendors ?? 0,
openSalesOrders: row.open_sales_orders ?? 0,
openPurchaseOrders: row.open_purchase_orders ?? 0,
openInvoices: row.open_invoices ?? 0,
openVendorBills: row.open_vendor_bills ?? 0,
lowStockCount: row.low_stock_count ?? 0,
inventoryValue: row.inventory_value ?? 0,
accountsReceivable: row.accounts_receivable ?? 0,
accountsPayable: row.accounts_payable ?? 0
};
}
export function getParts(): PartRow[] {
return db()
.prepare(
`
SELECT
p.id,
p.sku,
p.name,
p.kind,
p.unit_cost AS unitCost,
p.sale_price AS salePrice,
p.reorder_point AS reorderPoint,
p.unit_of_measure AS unitOfMeasure,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
FROM parts p
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
ORDER BY p.kind DESC, p.sku ASC
`
)
.all() as PartRow[];
}
export function getAssembliesWithComponents(): KitRow[] {
return db()
.prepare(
`
SELECT
a.sku AS assemblySku,
a.name AS assemblyName,
c.sku AS componentSku,
c.name AS componentName,
kc.quantity
FROM kit_components kc
INNER JOIN parts a ON a.id = kc.assembly_part_id
INNER JOIN parts c ON c.id = kc.component_part_id
ORDER BY a.sku, c.sku
`
)
.all() as KitRow[];
}
export function getCustomers(): ContactRow[] {
return db().prepare(`SELECT id, code, name, email, phone FROM customers ORDER BY code`).all() as ContactRow[];
}
export function getVendors(): ContactRow[] {
return db().prepare(`SELECT id, code, name, email, phone FROM vendors ORDER BY code`).all() as ContactRow[];
}
export function getSalesOrders(): SalesOrderListRow[] {
return db()
.prepare(
`
SELECT
so.id,
so.order_number AS orderNumber,
c.name AS customerName,
so.status,
so.created_at AS createdAt,
COALESCE(SUM(sol.quantity * sol.unit_price), 0) AS totalAmount,
COALESCE(SUM(sol.quantity), 0) AS orderedQuantity,
COALESCE(SUM(sol.shipped_quantity), 0) AS fulfilledQuantity
FROM sales_orders so
INNER JOIN customers c ON c.id = so.customer_id
LEFT JOIN sales_order_lines sol ON sol.sales_order_id = so.id
GROUP BY so.id, so.order_number, c.name, so.status, so.created_at
ORDER BY so.created_at DESC
`
)
.all() as SalesOrderListRow[];
}
export function getPurchaseOrders(): PurchaseOrderListRow[] {
return db()
.prepare(
`
SELECT
po.id,
po.order_number AS orderNumber,
v.name AS vendorName,
po.status,
po.created_at AS createdAt,
COALESCE(SUM(pol.quantity * pol.unit_cost), 0) AS totalAmount,
COALESCE(SUM(pol.quantity), 0) AS orderedQuantity,
COALESCE(SUM(pol.received_quantity), 0) AS fulfilledQuantity
FROM purchase_orders po
INNER JOIN vendors v ON v.id = po.vendor_id
LEFT JOIN purchase_order_lines pol ON pol.purchase_order_id = po.id
GROUP BY po.id, po.order_number, v.name, po.status, po.created_at
ORDER BY po.created_at DESC
`
)
.all() as PurchaseOrderListRow[];
}
export function getJournalEntries(): JournalEntryRow[] {
const entries = db()
.prepare(
`
SELECT
id,
entry_type AS entryType,
reference_type AS referenceType,
reference_id AS referenceId,
description,
created_at AS createdAt
FROM journal_entries
ORDER BY created_at DESC, id DESC
`
)
.all() as Array<Omit<JournalEntryRow, "lines">>;
const lineStatement = db().prepare(
`
SELECT
account_code AS accountCode,
account_name AS accountName,
debit,
credit
FROM journal_lines
WHERE journal_entry_id = ?
ORDER BY id
`
);
return entries.map((entry) => ({
...entry,
lines: lineStatement.all(entry.id) as JournalEntryRow["lines"]
}));
}
export function getInvoices(): InvoiceRow[] {
return db()
.prepare(
`
SELECT
ci.id,
ci.invoice_number AS invoiceNumber,
ci.sales_order_id AS salesOrderId,
c.name AS customerName,
ci.status,
ci.invoice_date AS invoiceDate,
ci.due_date AS dueDate,
ci.total_amount AS totalAmount,
ci.paid_amount AS paidAmount,
ci.total_amount - ci.paid_amount AS balanceDue
FROM customer_invoices ci
INNER JOIN customers c ON c.id = ci.customer_id
ORDER BY ci.invoice_date DESC, ci.id DESC
`
)
.all() as InvoiceRow[];
}
export function getVendorBills(): VendorBillRow[] {
return db()
.prepare(
`
SELECT
vb.id,
vb.bill_number AS billNumber,
vb.purchase_order_id AS purchaseOrderId,
v.name AS vendorName,
vb.status,
vb.bill_date AS billDate,
vb.due_date AS dueDate,
vb.total_amount AS totalAmount,
vb.paid_amount AS paidAmount,
vb.total_amount - vb.paid_amount AS balanceDue
FROM vendor_bills vb
INNER JOIN vendors v ON v.id = vb.vendor_id
ORDER BY vb.bill_date DESC, vb.id DESC
`
)
.all() as VendorBillRow[];
}
export function getAccounts(): AccountRow[] {
return db()
.prepare(
`
SELECT
code,
name,
category,
is_system AS isSystem
FROM accounts
ORDER BY code
`
)
.all() as AccountRow[];
}
export function getAccountBalances(): AccountBalanceRow[] {
return db()
.prepare(
`
SELECT
a.code,
a.name,
a.category,
COALESCE(SUM(jl.debit), 0) AS debitTotal,
COALESCE(SUM(jl.credit), 0) AS creditTotal,
CASE
WHEN a.category IN ('asset', 'expense') THEN COALESCE(SUM(jl.debit), 0) - COALESCE(SUM(jl.credit), 0)
ELSE COALESCE(SUM(jl.credit), 0) - COALESCE(SUM(jl.debit), 0)
END AS balance
FROM accounts a
LEFT JOIN journal_lines jl ON jl.account_code = a.code
GROUP BY a.code, a.name, a.category
ORDER BY a.code
`
)
.all() as AccountBalanceRow[];
}
export function getLowStockParts(): LowStockRow[] {
return db()
.prepare(
`
SELECT
p.id,
p.sku,
p.name,
p.unit_of_measure AS unitOfMeasure,
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand,
p.reorder_point AS reorderPoint,
MAX(p.reorder_point - COALESCE(ib.quantity_on_hand, 0), 0) AS suggestedReorderQuantity,
(
SELECT v.name
FROM purchase_order_lines pol
INNER JOIN purchase_orders po ON po.id = pol.purchase_order_id
INNER JOIN vendors v ON v.id = po.vendor_id
WHERE pol.part_id = p.id
ORDER BY po.created_at DESC
LIMIT 1
) AS preferredVendorName
FROM parts p
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
WHERE p.kind = 'part' AND COALESCE(ib.quantity_on_hand, 0) <= p.reorder_point
ORDER BY suggestedReorderQuantity DESC, p.sku ASC
`
)
.all() as LowStockRow[];
}

214
lib/schema.ts Normal file
View File

@@ -0,0 +1,214 @@
export const bootstrapSql = `
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS accounts (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
billing_address TEXT,
shipping_address TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin', 'manager', 'viewer')),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS vendors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
address TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS parts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
kind TEXT NOT NULL CHECK (kind IN ('part', 'assembly')),
unit_cost REAL NOT NULL DEFAULT 0,
sale_price REAL NOT NULL DEFAULT 0,
reorder_point REAL NOT NULL DEFAULT 0,
unit_of_measure TEXT NOT NULL DEFAULT 'ea',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS kit_components (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assembly_part_id INTEGER NOT NULL,
component_part_id INTEGER NOT NULL,
quantity REAL NOT NULL CHECK (quantity > 0),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (assembly_part_id, component_part_id),
FOREIGN KEY (assembly_part_id) REFERENCES parts(id) ON DELETE CASCADE,
FOREIGN KEY (component_part_id) REFERENCES parts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sales_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number TEXT NOT NULL UNIQUE,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'open', 'partial', 'shipped', 'cancelled')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
shipped_at TEXT,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
CREATE TABLE IF NOT EXISTS sales_order_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sales_order_id INTEGER NOT NULL,
part_id INTEGER NOT NULL,
quantity REAL NOT NULL CHECK (quantity > 0),
shipped_quantity REAL NOT NULL DEFAULT 0,
unit_price REAL NOT NULL CHECK (unit_price >= 0),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sales_order_id) REFERENCES sales_orders(id) ON DELETE CASCADE,
FOREIGN KEY (part_id) REFERENCES parts(id)
);
CREATE TABLE IF NOT EXISTS purchase_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number TEXT NOT NULL UNIQUE,
vendor_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'ordered', 'partial', 'received', 'cancelled')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
received_at TEXT,
FOREIGN KEY (vendor_id) REFERENCES vendors(id)
);
CREATE TABLE IF NOT EXISTS purchase_order_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purchase_order_id INTEGER NOT NULL,
part_id INTEGER NOT NULL,
quantity REAL NOT NULL CHECK (quantity > 0),
received_quantity REAL NOT NULL DEFAULT 0,
unit_cost REAL NOT NULL CHECK (unit_cost >= 0),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (purchase_order_id) REFERENCES purchase_orders(id) ON DELETE CASCADE,
FOREIGN KEY (part_id) REFERENCES parts(id)
);
CREATE TABLE IF NOT EXISTS customer_invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_number TEXT NOT NULL UNIQUE,
sales_order_id INTEGER,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'partial', 'paid', 'void')),
invoice_date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
due_date TEXT,
total_amount REAL NOT NULL DEFAULT 0,
paid_amount REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (sales_order_id) REFERENCES sales_orders(id),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
CREATE TABLE IF NOT EXISTS customer_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL,
amount REAL NOT NULL CHECK (amount > 0),
payment_date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (invoice_id) REFERENCES customer_invoices(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS vendor_bills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_number TEXT NOT NULL UNIQUE,
purchase_order_id INTEGER,
vendor_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'partial', 'paid', 'void')),
bill_date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
due_date TEXT,
total_amount REAL NOT NULL DEFAULT 0,
paid_amount REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (purchase_order_id) REFERENCES purchase_orders(id),
FOREIGN KEY (vendor_id) REFERENCES vendors(id)
);
CREATE TABLE IF NOT EXISTS vendor_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vendor_bill_id INTEGER NOT NULL,
amount REAL NOT NULL CHECK (amount > 0),
payment_date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vendor_bill_id) REFERENCES vendor_bills(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS inventory_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER NOT NULL,
quantity_delta REAL NOT NULL,
unit_cost REAL NOT NULL DEFAULT 0,
transaction_type TEXT NOT NULL CHECK (
transaction_type IN ('purchase_receipt', 'sales_shipment', 'assembly_build', 'assembly_consume', 'adjustment')
),
reference_type TEXT NOT NULL,
reference_id INTEGER,
notes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (part_id) REFERENCES parts(id)
);
CREATE TABLE IF NOT EXISTS journal_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_type TEXT NOT NULL,
reference_type TEXT NOT NULL,
reference_id INTEGER,
description TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS journal_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journal_entry_id INTEGER NOT NULL,
account_code TEXT NOT NULL,
account_name TEXT NOT NULL,
debit REAL NOT NULL DEFAULT 0,
credit REAL NOT NULL DEFAULT 0,
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(id) ON DELETE CASCADE,
FOREIGN KEY (account_code) REFERENCES accounts(code)
);
INSERT OR IGNORE INTO accounts (code, name, category, is_system) VALUES
('1000', 'Cash', 'asset', 1),
('1100', 'Accounts Receivable', 'asset', 1),
('1200', 'Inventory', 'asset', 1),
('2000', 'Accounts Payable', 'liability', 1),
('3000', 'Owner Equity', 'equity', 1),
('4000', 'Sales Revenue', 'revenue', 1),
('5000', 'Cost of Goods Sold', 'expense', 1),
('6100', 'Inventory Adjustments', 'expense', 1);
CREATE VIEW IF NOT EXISTS inventory_balances AS
SELECT
p.id AS part_id,
COALESCE(SUM(it.quantity_delta), 0) AS quantity_on_hand
FROM parts p
LEFT JOIN inventory_transactions it ON it.part_id = p.id
GROUP BY p.id;
`;

132
lib/types.ts Normal file
View File

@@ -0,0 +1,132 @@
export type DashboardStats = {
totalParts: number;
totalAssemblies: number;
activeCustomers: number;
activeVendors: number;
openSalesOrders: number;
openPurchaseOrders: number;
openInvoices: number;
openVendorBills: number;
lowStockCount: number;
inventoryValue: number;
accountsReceivable: number;
accountsPayable: number;
};
export type PartRow = {
id: number;
sku: string;
name: string;
kind: "part" | "assembly";
unitCost: number;
salePrice: number;
reorderPoint: number;
unitOfMeasure: string;
quantityOnHand: number;
};
export type KitRow = {
assemblySku: string;
assemblyName: string;
componentSku: string;
componentName: string;
quantity: number;
};
export type ContactRow = {
id: number;
code: string;
name: string;
email: string | null;
phone: string | null;
};
export type SalesOrderListRow = {
id: number;
orderNumber: string;
customerName: string;
status: string;
createdAt: string;
totalAmount: number;
orderedQuantity: number;
fulfilledQuantity: number;
};
export type PurchaseOrderListRow = {
id: number;
orderNumber: string;
vendorName: string;
status: string;
createdAt: string;
totalAmount: number;
orderedQuantity: number;
fulfilledQuantity: number;
};
export type JournalEntryRow = {
id: number;
entryType: string;
referenceType: string;
referenceId: number | null;
description: string;
createdAt: string;
lines: Array<{
accountCode: string;
accountName: string;
debit: number;
credit: number;
}>;
};
export type AccountRow = {
code: string;
name: string;
category: "asset" | "liability" | "equity" | "revenue" | "expense";
isSystem: number;
};
export type AccountBalanceRow = {
code: string;
name: string;
category: "asset" | "liability" | "equity" | "revenue" | "expense";
debitTotal: number;
creditTotal: number;
balance: number;
};
export type LowStockRow = {
id: number;
sku: string;
name: string;
unitOfMeasure: string;
quantityOnHand: number;
reorderPoint: number;
suggestedReorderQuantity: number;
preferredVendorName: string | null;
};
export type InvoiceRow = {
id: number;
invoiceNumber: string;
salesOrderId: number | null;
customerName: string;
status: string;
invoiceDate: string;
dueDate: string | null;
totalAmount: number;
paidAmount: number;
balanceDue: number;
};
export type VendorBillRow = {
id: number;
billNumber: string;
purchaseOrderId: number | null;
vendorName: string;
status: string;
billDate: string;
dueDate: string | null;
totalAmount: number;
paidAmount: number;
balanceDue: number;
};