initial release testing
This commit is contained in:
92
app/accounting/page.tsx
Normal file
92
app/accounting/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createAccount, createManualJournalEntry } from "@/lib/actions";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getAccountBalances, getAccounts, getJournalEntries } from "@/lib/repository";
|
||||
|
||||
export default function AccountingPage() {
|
||||
const entries = getJournalEntries();
|
||||
const balances = getAccountBalances();
|
||||
const accounts = getAccounts();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Chart of Accounts</h2>
|
||||
<p className="section-copy">Seeded operational accounts are ready, and you can add your own accounts here.</p>
|
||||
<form action={createAccount} className="form-grid">
|
||||
<div className="form-row"><label htmlFor="account-code">Code</label><input className="input" id="account-code" name="code" required /></div>
|
||||
<div className="form-row"><label htmlFor="account-name">Name</label><input className="input" id="account-name" name="name" required /></div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="account-category">Category</label>
|
||||
<select className="select" id="account-category" name="category" defaultValue="expense">
|
||||
<option value="asset">Asset</option>
|
||||
<option value="liability">Liability</option>
|
||||
<option value="equity">Equity</option>
|
||||
<option value="revenue">Revenue</option>
|
||||
<option value="expense">Expense</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="button secondary" type="submit">Add Account</button>
|
||||
</form>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Manual Journal Entry</h2>
|
||||
<p className="section-copy">Enter one line per row as `account code,debit,credit`. Debits and credits must balance.</p>
|
||||
<form action={createManualJournalEntry} className="form-grid">
|
||||
<div className="form-row"><label htmlFor="description">Description</label><input className="input" id="description" name="description" /></div>
|
||||
<div className="form-row"><label htmlFor="lines">Lines</label><textarea className="textarea" id="lines" name="lines" placeholder={"1000,250,0\n3000,0,250"} required /></div>
|
||||
<button className="button" type="submit">Post Journal Entry</button>
|
||||
</form>
|
||||
<div className="muted">
|
||||
Available accounts: {accounts.map((account) => `${account.code} ${account.name}`).join(" | ")}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Account Balances</h2>
|
||||
<p className="section-copy">Balances are rolled up from every journal line currently posted.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Code</th><th>Account</th><th>Category</th><th>Debits</th><th>Credits</th><th>Balance</th></tr></thead>
|
||||
<tbody>
|
||||
{balances.map((balance) => (
|
||||
<tr key={balance.code}>
|
||||
<td>{balance.code}</td>
|
||||
<td>{balance.name}</td>
|
||||
<td>{balance.category}</td>
|
||||
<td>{formatCurrency(balance.debitTotal)}</td>
|
||||
<td>{formatCurrency(balance.creditTotal)}</td>
|
||||
<td>{formatCurrency(balance.balance)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Accounting Journal</h2>
|
||||
<p className="section-copy">Purchase receipts and sales shipments automatically create operational journal entries.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>When</th><th>Type</th><th>Reference</th><th>Description</th><th>Lines</th></tr></thead>
|
||||
<tbody>
|
||||
{entries.length === 0 ? (
|
||||
<tr><td colSpan={5} className="muted">Journal entries appear after PO receipts and SO shipments.</td></tr>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{formatDate(entry.createdAt)}</td>
|
||||
<td>{entry.entryType}</td>
|
||||
<td>{entry.referenceType} #{entry.referenceId ?? "n/a"}</td>
|
||||
<td>{entry.description}</td>
|
||||
<td>{entry.lines.map((line, index) => <div key={`${entry.id}-${index}`} className="muted">{line.accountCode} {line.accountName}: {formatCurrency(line.debit)} / {formatCurrency(line.credit)}</div>)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
app/api/health/route.ts
Normal file
11
app/api/health/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDashboardStats } from "@/lib/repository";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
modules: ["parts", "assemblies", "sales-orders", "purchase-orders", "customers", "vendors", "accounting"],
|
||||
stats: getDashboardStats()
|
||||
});
|
||||
}
|
||||
63
app/assemblies/page.tsx
Normal file
63
app/assemblies/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { addKitComponent, buildAssembly } from "@/lib/actions";
|
||||
import { getAssembliesWithComponents, getParts } from "@/lib/repository";
|
||||
|
||||
export default function AssembliesPage() {
|
||||
const parts = getParts();
|
||||
const assemblies = parts.filter((part) => part.kind === "assembly");
|
||||
const components = parts.filter((part) => part.kind === "part");
|
||||
const kitRows = getAssembliesWithComponents();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Bill of Materials</h2>
|
||||
<p className="section-copy">Define which stocked parts are consumed to build each assembly.</p>
|
||||
<form action={addKitComponent} className="form-grid">
|
||||
<div className="form-row">
|
||||
<label htmlFor="assemblySku">Assembly SKU</label>
|
||||
<select className="select" id="assemblySku" name="assemblySku">{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="componentSku">Component SKU</label>
|
||||
<select className="select" id="componentSku" name="componentSku">{components.map((component) => <option key={component.id} value={component.sku}>{component.sku} - {component.name}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-row"><label htmlFor="component-qty">Quantity Per Assembly</label><input className="input" id="component-qty" name="quantity" type="number" min="0.01" step="0.01" defaultValue="1" /></div>
|
||||
<button className="button" type="submit">Save Component</button>
|
||||
</form>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Build Assembly</h2>
|
||||
<p className="section-copy">Consume component stock and create finished kit inventory in one transaction flow.</p>
|
||||
<form action={buildAssembly} className="form-grid">
|
||||
<div className="form-row">
|
||||
<label htmlFor="build-assembly">Assembly SKU</label>
|
||||
<select className="select" id="build-assembly" name="assemblySku">{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-row"><label htmlFor="build-qty">Build Quantity</label><input className="input" id="build-qty" name="quantity" type="number" min="1" step="1" defaultValue="1" /></div>
|
||||
<button className="button secondary" type="submit">Build Now</button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Current Assemblies</h2>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Assembly</th><th>Name</th><th>Component</th><th>Component Name</th><th>Qty Per</th></tr></thead>
|
||||
<tbody>
|
||||
{kitRows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="muted">Add an assembly on the Parts page, then define its bill of materials here.</td></tr>
|
||||
) : (
|
||||
kitRows.map((row, index) => (
|
||||
<tr key={`${row.assemblySku}-${row.componentSku}-${index}`}>
|
||||
<td>{row.assemblySku}</td><td>{row.assemblyName}</td><td>{row.componentSku}</td><td>{row.componentName}</td><td>{row.quantity}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
app/customers/page.tsx
Normal file
43
app/customers/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createCustomer } from "@/lib/actions";
|
||||
import { getCustomers } from "@/lib/repository";
|
||||
|
||||
export default function CustomersPage() {
|
||||
const customers = getCustomers();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Add Customer</h2>
|
||||
<p className="section-copy">Customer records anchor sales orders, shipping, and accounts receivable activity.</p>
|
||||
<form action={createCustomer} className="form-grid">
|
||||
<div className="form-row"><label htmlFor="customer-code">Customer Code</label><input className="input" id="customer-code" name="code" required /></div>
|
||||
<div className="form-row"><label htmlFor="customer-name">Name</label><input className="input" id="customer-name" name="name" required /></div>
|
||||
<div className="form-row"><label htmlFor="customer-email">Email</label><input className="input" id="customer-email" name="email" type="email" /></div>
|
||||
<div className="form-row"><label htmlFor="customer-phone">Phone</label><input className="input" id="customer-phone" name="phone" /></div>
|
||||
<div className="form-row"><label htmlFor="billingAddress">Billing Address</label><textarea className="textarea" id="billingAddress" name="billingAddress" /></div>
|
||||
<div className="form-row"><label htmlFor="shippingAddress">Shipping Address</label><textarea className="textarea" id="shippingAddress" name="shippingAddress" /></div>
|
||||
<button className="button" type="submit">Save Customer</button>
|
||||
</form>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Customer Directory</h2>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Code</th><th>Name</th><th>Email</th><th>Phone</th></tr></thead>
|
||||
<tbody>
|
||||
{customers.length === 0 ? (
|
||||
<tr><td colSpan={4} className="muted">No customers yet.</td></tr>
|
||||
) : (
|
||||
customers.map((customer) => (
|
||||
<tr key={customer.id}><td>{customer.code}</td><td>{customer.name}</td><td>{customer.email || "—"}</td><td>{customer.phone || "—"}</td></tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
app/globals.css
Normal file
255
app/globals.css
Normal file
@@ -0,0 +1,255 @@
|
||||
:root {
|
||||
--bg: #f4f1e8;
|
||||
--surface: rgba(255, 252, 246, 0.88);
|
||||
--text: #1e1b16;
|
||||
--muted: #6b6258;
|
||||
--line: rgba(45, 39, 31, 0.14);
|
||||
--accent: #a54b1a;
|
||||
--accent-strong: #7b2d12;
|
||||
--success: #265d3d;
|
||||
--warning: #9a6400;
|
||||
--danger: #8f2718;
|
||||
--shadow: 0 18px 45px rgba(64, 40, 18, 0.12);
|
||||
--radius-lg: 24px;
|
||||
--font-body: "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(215, 116, 57, 0.24), transparent 30%),
|
||||
radial-gradient(circle at right 20%, rgba(100, 139, 120, 0.18), transparent 25%),
|
||||
linear-gradient(180deg, #fbf6ee 0%, #efe6d8 100%);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.sidebar,
|
||||
.card,
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 28px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.section-title {
|
||||
margin: 0;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.section-copy,
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
align-self: start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--accent), #d0834d);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
padding: 11px 14px;
|
||||
border-radius: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav a:hover,
|
||||
.nav a[data-active="true"] {
|
||||
color: var(--text);
|
||||
background: rgba(165, 75, 26, 0.1);
|
||||
}
|
||||
|
||||
.content,
|
||||
.grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.grid.cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
}
|
||||
|
||||
.card,
|
||||
.panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.two-up {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(59, 48, 39, 0.16);
|
||||
border-radius: 12px;
|
||||
padding: 11px 12px;
|
||||
background: rgba(255, 252, 247, 0.95);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 11px 18px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: rgba(165, 75, 26, 0.12);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table th {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
background: rgba(38, 93, 61, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.pill.warning {
|
||||
background: rgba(154, 100, 0, 0.12);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout,
|
||||
.two-up {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
51
app/invoices/page.tsx
Normal file
51
app/invoices/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { receiveCustomerPayment } from "@/lib/actions";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getInvoices } from "@/lib/repository";
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const invoices = getInvoices();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Customer Invoices</h2>
|
||||
<p className="section-copy">Invoices are created automatically when shipped sales orders post accounts receivable.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Invoice</th><th>Customer</th><th>Status</th><th>Invoice Date</th><th>Due</th><th>Total</th><th>Paid</th><th>Balance</th><th>Payment</th></tr></thead>
|
||||
<tbody>
|
||||
{invoices.length === 0 ? (
|
||||
<tr><td colSpan={9} className="muted">No invoices yet. Ship a sales order to create one.</td></tr>
|
||||
) : (
|
||||
invoices.map((invoice) => (
|
||||
<tr key={invoice.id}>
|
||||
<td>{invoice.invoiceNumber}</td>
|
||||
<td>{invoice.customerName}</td>
|
||||
<td><span className={`pill ${invoice.status === "paid" ? "" : "warning"}`}>{invoice.status}</span></td>
|
||||
<td>{formatDate(invoice.invoiceDate)}</td>
|
||||
<td>{invoice.dueDate ? formatDate(invoice.dueDate) : "-"}</td>
|
||||
<td>{formatCurrency(invoice.totalAmount)}</td>
|
||||
<td>{formatCurrency(invoice.paidAmount)}</td>
|
||||
<td>{formatCurrency(invoice.balanceDue)}</td>
|
||||
<td>
|
||||
{invoice.status === "paid" ? (
|
||||
<span className="muted">Paid</span>
|
||||
) : (
|
||||
<form action={receiveCustomerPayment} className="form-grid">
|
||||
<input type="hidden" name="invoiceId" value={invoice.id} />
|
||||
<input className="input" name="amount" type="number" min="0.01" step="0.01" defaultValue={invoice.balanceDue.toFixed(2)} />
|
||||
<input className="input" name="notes" placeholder="Payment notes" />
|
||||
<button className="button secondary" type="submit">Receive</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
app/layout.tsx
Normal file
47
app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { LogoutButton } from "@/components/logout-button";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Inven",
|
||||
description: "Inventory management with kits, sales, purchasing, and accounting."
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="shell">
|
||||
<section className="hero">
|
||||
<h1>Inven Control Center</h1>
|
||||
<p>
|
||||
A single-container operating system for stocked parts, kit assemblies, purchasing, shipping, customer
|
||||
records, vendor records, and accounting visibility.
|
||||
</p>
|
||||
{session ? (
|
||||
<div className="toolbar">
|
||||
<span className="muted">
|
||||
Signed in as {session.email} ({session.role})
|
||||
</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
{session ? (
|
||||
<div className="layout">
|
||||
<Sidebar />
|
||||
<main className="content">{children}</main>
|
||||
</div>
|
||||
) : (
|
||||
<main className="content" style={{ marginTop: 24 }}>{children}</main>
|
||||
)}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
25
app/login/page.tsx
Normal file
25
app/login/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { loginAction } from "@/lib/actions";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="panel" style={{ maxWidth: 520 }}>
|
||||
<h2 className="section-title">Sign In</h2>
|
||||
<p className="section-copy">Use the bootstrap admin credentials configured through environment variables.</p>
|
||||
<form action={loginAction} className="form-grid">
|
||||
<div className="form-row">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input className="input" id="email" name="email" type="email" required />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input className="input" id="password" name="password" type="password" required />
|
||||
</div>
|
||||
<button className="button" type="submit">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
app/page.tsx
Normal file
99
app/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { getDashboardStats, getLowStockParts, getPurchaseOrders, getSalesOrders } from "@/lib/repository";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const stats = getDashboardStats();
|
||||
const salesOrders = getSalesOrders().slice(0, 5);
|
||||
const purchaseOrders = getPurchaseOrders().slice(0, 5);
|
||||
const lowStockParts = getLowStockParts().slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="grid cards">
|
||||
<article className="card"><div className="muted">Stocked Parts</div><div className="stats-value">{stats.totalParts}</div></article>
|
||||
<article className="card"><div className="muted">Assemblies</div><div className="stats-value">{stats.totalAssemblies}</div></article>
|
||||
<article className="card"><div className="muted">Open Sales Orders</div><div className="stats-value">{stats.openSalesOrders}</div></article>
|
||||
<article className="card"><div className="muted">Open Purchase Orders</div><div className="stats-value">{stats.openPurchaseOrders}</div></article>
|
||||
<article className="card"><div className="muted">Open Invoices</div><div className="stats-value">{stats.openInvoices}</div></article>
|
||||
<article className="card"><div className="muted">Open Vendor Bills</div><div className="stats-value">{stats.openVendorBills}</div></article>
|
||||
<article className="card"><div className="muted">Low Stock Alerts</div><div className="stats-value">{stats.lowStockCount}</div></article>
|
||||
<article className="card"><div className="muted">Inventory Value</div><div className="stats-value">{formatCurrency(stats.inventoryValue)}</div></article>
|
||||
<article className="card"><div className="muted">Accounts Receivable</div><div className="stats-value">{formatCurrency(stats.accountsReceivable)}</div></article>
|
||||
<article className="card"><div className="muted">Accounts Payable</div><div className="stats-value">{formatCurrency(stats.accountsPayable)}</div></article>
|
||||
</section>
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Recent Sales Orders</h2>
|
||||
<p className="section-copy">Track outbound work and spot orders that are ready to ship.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{salesOrders.length === 0 ? (
|
||||
<tr><td colSpan={4} className="muted">No sales orders yet.</td></tr>
|
||||
) : (
|
||||
salesOrders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td>{order.orderNumber}</td>
|
||||
<td>{order.customerName}</td>
|
||||
<td><span className={`pill ${order.status === "shipped" ? "" : "warning"}`}>{order.status}</span></td>
|
||||
<td>{formatCurrency(order.totalAmount)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Recent Purchase Orders</h2>
|
||||
<p className="section-copy">Move supply from ordered to received and feed inventory automatically.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Order</th><th>Vendor</th><th>Status</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{purchaseOrders.length === 0 ? (
|
||||
<tr><td colSpan={4} className="muted">No purchase orders yet.</td></tr>
|
||||
) : (
|
||||
purchaseOrders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td>{order.orderNumber}</td>
|
||||
<td>{order.vendorName}</td>
|
||||
<td><span className={`pill ${order.status === "received" ? "" : "warning"}`}>{order.status}</span></td>
|
||||
<td>{formatCurrency(order.totalAmount)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Replenishment Watchlist</h2>
|
||||
<p className="section-copy">Items at or below reorder point, with a suggested quantity to bring them back up.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>SKU</th><th>Name</th><th>On Hand</th><th>Reorder Point</th><th>Suggested Reorder</th><th>Last Vendor</th></tr></thead>
|
||||
<tbody>
|
||||
{lowStockParts.length === 0 ? (
|
||||
<tr><td colSpan={6} className="muted">No low stock items right now.</td></tr>
|
||||
) : (
|
||||
lowStockParts.map((part) => (
|
||||
<tr key={part.id}>
|
||||
<td>{part.sku}</td>
|
||||
<td>{part.name}</td>
|
||||
<td>{part.quantityOnHand} {part.unitOfMeasure}</td>
|
||||
<td>{part.reorderPoint}</td>
|
||||
<td>{part.suggestedReorderQuantity}</td>
|
||||
<td>{part.preferredVendorName || "—"}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
app/parts/page.tsx
Normal file
65
app/parts/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createPart, recordAdjustment } from "@/lib/actions";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { getParts } from "@/lib/repository";
|
||||
|
||||
export default function PartsPage() {
|
||||
const parts = getParts();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Create Part or Assembly</h2>
|
||||
<p className="section-copy">Maintain a single item master for stocked components and sellable kits.</p>
|
||||
<form action={createPart} className="form-grid">
|
||||
<div className="form-row"><label htmlFor="sku">SKU</label><input className="input" id="sku" name="sku" required /></div>
|
||||
<div className="form-row"><label htmlFor="name">Name</label><input className="input" id="name" name="name" required /></div>
|
||||
<div className="form-row"><label htmlFor="description">Description</label><textarea className="textarea" id="description" name="description" /></div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="kind">Item Type</label>
|
||||
<select className="select" id="kind" name="kind" defaultValue="part">
|
||||
<option value="part">Part</option>
|
||||
<option value="assembly">Assembly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row"><label htmlFor="unitCost">Unit Cost</label><input className="input" id="unitCost" name="unitCost" type="number" min="0" step="0.01" defaultValue="0" /></div>
|
||||
<div className="form-row"><label htmlFor="salePrice">Sale Price</label><input className="input" id="salePrice" name="salePrice" type="number" min="0" step="0.01" defaultValue="0" /></div>
|
||||
<div className="form-row"><label htmlFor="reorderPoint">Reorder Point</label><input className="input" id="reorderPoint" name="reorderPoint" type="number" min="0" step="0.01" defaultValue="0" /></div>
|
||||
<div className="form-row"><label htmlFor="unitOfMeasure">Unit of Measure</label><input className="input" id="unitOfMeasure" name="unitOfMeasure" defaultValue="ea" /></div>
|
||||
<button className="button" type="submit">Save Item</button>
|
||||
</form>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Inventory Adjustment</h2>
|
||||
<p className="section-copy">Apply opening balances, cycle-count corrections, or manual stock adjustments.</p>
|
||||
<form action={recordAdjustment} className="form-grid">
|
||||
<div className="form-row"><label htmlFor="adjust-sku">SKU</label><input className="input" id="adjust-sku" name="sku" required /></div>
|
||||
<div className="form-row"><label htmlFor="quantityDelta">Quantity Delta</label><input className="input" id="quantityDelta" name="quantityDelta" type="number" step="0.01" required /></div>
|
||||
<div className="form-row"><label htmlFor="adjust-unit-cost">Unit Cost</label><input className="input" id="adjust-unit-cost" name="unitCost" type="number" min="0" step="0.01" defaultValue="0" /></div>
|
||||
<div className="form-row"><label htmlFor="notes">Notes</label><textarea className="textarea" id="notes" name="notes" /></div>
|
||||
<button className="button secondary" type="submit">Post Adjustment</button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Item Master</h2>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>SKU</th><th>Name</th><th>Type</th><th>On Hand</th><th>Reorder</th><th>Cost</th><th>Price</th></tr></thead>
|
||||
<tbody>
|
||||
{parts.length === 0 ? (
|
||||
<tr><td colSpan={7} className="muted">Add your first part or assembly to get started.</td></tr>
|
||||
) : (
|
||||
parts.map((part) => (
|
||||
<tr key={part.id}>
|
||||
<td>{part.sku}</td><td>{part.name}</td><td>{part.kind}</td><td>{part.quantityOnHand}</td><td>{part.reorderPoint}</td><td>{formatCurrency(part.unitCost)}</td><td>{formatCurrency(part.salePrice)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
app/purchase-orders/page.tsx
Normal file
92
app/purchase-orders/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createPurchaseOrder, receivePurchaseOrder } from "@/lib/actions";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getLowStockParts, getPurchaseOrders, getVendors } from "@/lib/repository";
|
||||
|
||||
export default function PurchaseOrdersPage() {
|
||||
const vendors = getVendors();
|
||||
const orders = getPurchaseOrders();
|
||||
const lowStockParts = getLowStockParts();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Create Purchase Order</h2>
|
||||
<p className="section-copy">Enter one line per row as `SKU,quantity,unit cost`.</p>
|
||||
<form action={createPurchaseOrder} className="form-grid">
|
||||
<div className="form-row">
|
||||
<label htmlFor="vendorCode">Vendor Code</label>
|
||||
<select className="select" id="vendorCode" name="vendorCode">
|
||||
{vendors.map((vendor) => <option key={vendor.id} value={vendor.code}>{vendor.code} - {vendor.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row"><label htmlFor="purchase-lines">Line Items</label><textarea className="textarea" id="purchase-lines" name="lines" placeholder={"PART-001,10,31.50\nPART-002,24,6.25"} required /></div>
|
||||
<div className="form-row"><label htmlFor="purchase-notes">Notes</label><textarea className="textarea" id="purchase-notes" name="notes" /></div>
|
||||
<button className="button" type="submit">Save Purchase Order</button>
|
||||
</form>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Receiving Flow</h2>
|
||||
<p className="section-copy">Leave line quantities blank to receive the remaining balance, or enter `SKU,quantity` rows for a partial receipt.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Order</th><th>Vendor</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
{orders.length === 0 ? (
|
||||
<tr><td colSpan={7} className="muted">No purchase orders yet.</td></tr>
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td>{order.orderNumber}</td>
|
||||
<td>{order.vendorName}</td>
|
||||
<td><span className={`pill ${order.status === "received" ? "" : "warning"}`}>{order.status}</span></td>
|
||||
<td>{formatCurrency(order.totalAmount)}</td>
|
||||
<td>{order.fulfilledQuantity} / {order.orderedQuantity}</td>
|
||||
<td>{formatDate(order.createdAt)}</td>
|
||||
<td>
|
||||
{order.status === "received" ? (
|
||||
<span className="muted">Received</span>
|
||||
) : (
|
||||
<form action={receivePurchaseOrder} className="form-grid">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<textarea className="textarea" name="lines" placeholder={"PART-001,4\nPART-002,10"} />
|
||||
<button className="button secondary" type="submit">Receive</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Restock Recommendations</h2>
|
||||
<p className="section-copy">Use this list to plan the next purchase order from items already below target.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>SKU</th><th>Name</th><th>On Hand</th><th>Reorder Point</th><th>Suggested Qty</th><th>Last Vendor</th></tr></thead>
|
||||
<tbody>
|
||||
{lowStockParts.length === 0 ? (
|
||||
<tr><td colSpan={6} className="muted">No parts currently need restocking.</td></tr>
|
||||
) : (
|
||||
lowStockParts.map((part) => (
|
||||
<tr key={part.id}>
|
||||
<td>{part.sku}</td>
|
||||
<td>{part.name}</td>
|
||||
<td>{part.quantityOnHand} {part.unitOfMeasure}</td>
|
||||
<td>{part.reorderPoint}</td>
|
||||
<td>{part.suggestedReorderQuantity}</td>
|
||||
<td>{part.preferredVendorName || "-"}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/sales-orders/page.tsx
Normal file
66
app/sales-orders/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createSalesOrder, shipSalesOrder } from "@/lib/actions";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getCustomers, getSalesOrders } from "@/lib/repository";
|
||||
|
||||
export default function SalesOrdersPage() {
|
||||
const customers = getCustomers();
|
||||
const orders = getSalesOrders();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Create Sales Order</h2>
|
||||
<p className="section-copy">Enter one line per row as `SKU,quantity,unit price`.</p>
|
||||
<form action={createSalesOrder} className="form-grid">
|
||||
<div className="form-row">
|
||||
<label htmlFor="customerCode">Customer Code</label>
|
||||
<select className="select" id="customerCode" name="customerCode">
|
||||
{customers.map((customer) => <option key={customer.id} value={customer.code}>{customer.code} - {customer.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row"><label htmlFor="sales-lines">Line Items</label><textarea className="textarea" id="sales-lines" name="lines" placeholder={"PART-001,2,79.99\nKIT-100,1,249.00"} required /></div>
|
||||
<div className="form-row"><label htmlFor="sales-notes">Notes</label><textarea className="textarea" id="sales-notes" name="notes" /></div>
|
||||
<button className="button" type="submit">Save Sales Order</button>
|
||||
</form>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Shipping Flow</h2>
|
||||
<p className="section-copy">Leave line quantities blank to ship the remaining balance, or enter `SKU,quantity` rows for a partial shipment.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
{orders.length === 0 ? (
|
||||
<tr><td colSpan={7} className="muted">No sales orders yet.</td></tr>
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td>{order.orderNumber}</td>
|
||||
<td>{order.customerName}</td>
|
||||
<td><span className={`pill ${order.status === "shipped" ? "" : "warning"}`}>{order.status}</span></td>
|
||||
<td>{formatCurrency(order.totalAmount)}</td>
|
||||
<td>{order.fulfilledQuantity} / {order.orderedQuantity}</td>
|
||||
<td>{formatDate(order.createdAt)}</td>
|
||||
<td>
|
||||
{order.status === "shipped" ? (
|
||||
<span className="muted">Shipped</span>
|
||||
) : (
|
||||
<form action={shipSalesOrder} className="form-grid">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<textarea className="textarea" name="lines" placeholder={"PART-001,1\nKIT-100,2"} />
|
||||
<button className="button secondary" type="submit">Ship</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
app/vendor-bills/page.tsx
Normal file
51
app/vendor-bills/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { payVendorBill } from "@/lib/actions";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { getVendorBills } from "@/lib/repository";
|
||||
|
||||
export default function VendorBillsPage() {
|
||||
const bills = getVendorBills();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="panel">
|
||||
<h2 className="section-title">Vendor Bills</h2>
|
||||
<p className="section-copy">Vendor bills are created automatically when purchase order receipts post accounts payable.</p>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Bill</th><th>Vendor</th><th>Status</th><th>Bill Date</th><th>Due</th><th>Total</th><th>Paid</th><th>Balance</th><th>Payment</th></tr></thead>
|
||||
<tbody>
|
||||
{bills.length === 0 ? (
|
||||
<tr><td colSpan={9} className="muted">No vendor bills yet. Receive a purchase order to create one.</td></tr>
|
||||
) : (
|
||||
bills.map((bill) => (
|
||||
<tr key={bill.id}>
|
||||
<td>{bill.billNumber}</td>
|
||||
<td>{bill.vendorName}</td>
|
||||
<td><span className={`pill ${bill.status === "paid" ? "" : "warning"}`}>{bill.status}</span></td>
|
||||
<td>{formatDate(bill.billDate)}</td>
|
||||
<td>{bill.dueDate ? formatDate(bill.dueDate) : "-"}</td>
|
||||
<td>{formatCurrency(bill.totalAmount)}</td>
|
||||
<td>{formatCurrency(bill.paidAmount)}</td>
|
||||
<td>{formatCurrency(bill.balanceDue)}</td>
|
||||
<td>
|
||||
{bill.status === "paid" ? (
|
||||
<span className="muted">Paid</span>
|
||||
) : (
|
||||
<form action={payVendorBill} className="form-grid">
|
||||
<input type="hidden" name="vendorBillId" value={bill.id} />
|
||||
<input className="input" name="amount" type="number" min="0.01" step="0.01" defaultValue={bill.balanceDue.toFixed(2)} />
|
||||
<input className="input" name="notes" placeholder="Payment notes" />
|
||||
<button className="button secondary" type="submit">Pay</button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
app/vendors/page.tsx
vendored
Normal file
42
app/vendors/page.tsx
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createVendor } from "@/lib/actions";
|
||||
import { getVendors } from "@/lib/repository";
|
||||
|
||||
export default function VendorsPage() {
|
||||
const vendors = getVendors();
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<section className="two-up">
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Add Vendor</h2>
|
||||
<p className="section-copy">Vendor records support purchasing, receipts, and accounts payable activity.</p>
|
||||
<form action={createVendor} className="form-grid">
|
||||
<div className="form-row"><label htmlFor="vendor-code">Vendor Code</label><input className="input" id="vendor-code" name="code" required /></div>
|
||||
<div className="form-row"><label htmlFor="vendor-name">Name</label><input className="input" id="vendor-name" name="name" required /></div>
|
||||
<div className="form-row"><label htmlFor="vendor-email">Email</label><input className="input" id="vendor-email" name="email" type="email" /></div>
|
||||
<div className="form-row"><label htmlFor="vendor-phone">Phone</label><input className="input" id="vendor-phone" name="phone" /></div>
|
||||
<div className="form-row"><label htmlFor="address">Address</label><textarea className="textarea" id="address" name="address" /></div>
|
||||
<button className="button" type="submit">Save Vendor</button>
|
||||
</form>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2 className="section-title">Vendor Directory</h2>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th>Code</th><th>Name</th><th>Email</th><th>Phone</th></tr></thead>
|
||||
<tbody>
|
||||
{vendors.length === 0 ? (
|
||||
<tr><td colSpan={4} className="muted">No vendors yet.</td></tr>
|
||||
) : (
|
||||
vendors.map((vendor) => (
|
||||
<tr key={vendor.id}><td>{vendor.code}</td><td>{vendor.name}</td><td>{vendor.email || "—"}</td><td>{vendor.phone || "—"}</td></tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user