initial release testing
This commit is contained in:
865
lib/actions.ts
Normal file
865
lib/actions.ts
Normal 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
145
lib/auth.ts
Normal 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
44
lib/db.ts
Normal 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
13
lib/format.ts
Normal 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
319
lib/repository.ts
Normal 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
214
lib/schema.ts
Normal 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
132
lib/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user