initial release testing
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
data
|
||||
docs
|
||||
skills
|
||||
hubs
|
||||
PROJECT-PROFILE-WORKBOOK.md
|
||||
ROUTING-EXAMPLES.md
|
||||
SKILLS.md
|
||||
AGENTS.md
|
||||
DEPLOYMENT-PROFILE.md
|
||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
DATABASE_PATH=/data/inven.sqlite
|
||||
APP_NAME=Inven
|
||||
AUTH_SECRET=change-me-to-a-long-random-secret
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=change-me-now
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.next
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
npm-debug.log*
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV DATABASE_PATH=/data/inven.sqlite
|
||||
RUN mkdir -p /data
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
120
README.md
120
README.md
@@ -1,47 +1,93 @@
|
||||
# Drop-In Agent Instruction Suite
|
||||
# Inven
|
||||
|
||||
This repository is a portable markdown instruction pack for coding agents.
|
||||
Inven is a single-container inventory management system for Unraid-style deployments. It manages stocked parts, assemblies built from parts, sales orders and shipping, purchase orders and restocking, customers, vendors, and a lightweight accounting journal on top of SQLite.
|
||||
|
||||
Copy these files into another repository to give the agent:
|
||||
- a root `AGENTS.md` entrypoint,
|
||||
- a central skill index,
|
||||
- category hubs for routing,
|
||||
- specialized skill files for common software, docs, UX, marketing, and ideation tasks.
|
||||
## Current Scope
|
||||
|
||||
## Structure
|
||||
- Parts and assemblies share one item master
|
||||
- Assemblies support bill-of-material component definitions
|
||||
- Inventory is tracked through a transaction ledger
|
||||
- Sales orders can be created and shipped
|
||||
- Purchase orders can be created and received
|
||||
- Sales orders support partial shipments
|
||||
- Purchase orders support partial receipts
|
||||
- Invoices are generated from shipped sales orders
|
||||
- Vendor bills are generated from received purchase orders
|
||||
- Customer and vendor payments can clear receivables and payables
|
||||
- Customer and vendor directories support the order flows
|
||||
- Low-stock and suggested reorder views help drive replenishment
|
||||
- A chart of accounts, account balances, and manual journals support the first accounting pass
|
||||
- Built-in authentication protects the app with a bootstrap admin login
|
||||
|
||||
- `AGENTS.md` - base instructions and routing rules
|
||||
- `DEPLOYMENT-PROFILE.md` - agent-readable prefilled deployment defaults
|
||||
- `INSTALL.md` - copy and customization guide for other repositories
|
||||
- `PROJECT-PROFILE-WORKBOOK.md` - one-time questionnaire for staging defaults
|
||||
- `SKILLS.md` - canonical skill index
|
||||
- `ROUTING-EXAMPLES.md` - representative prompt-to-skill routing examples
|
||||
- `hubs/` - category-level routing guides
|
||||
- `skills/` - specialized reusable skill files
|
||||
## Stack
|
||||
|
||||
## Design Goals
|
||||
- Next.js App Router
|
||||
- TypeScript
|
||||
- SQLite via `better-sqlite3`
|
||||
- Single Docker container with Next.js standalone output
|
||||
|
||||
- Plain markdown only
|
||||
- Cross-agent portability
|
||||
- Implementation-first defaults
|
||||
- On-demand skill loading instead of loading everything every session
|
||||
- Context-efficient routing for large skill libraries
|
||||
- Prefilled deployment defaults without per-install questioning
|
||||
- Repo-local instructions take precedence over this bundle
|
||||
## Quick Start
|
||||
|
||||
## Intended Workflow
|
||||
1. Install Node.js 22 or newer.
|
||||
2. Copy `.env.example` to `.env`.
|
||||
3. Set `AUTH_SECRET`, `ADMIN_EMAIL`, and `ADMIN_PASSWORD`.
|
||||
4. Update `DATABASE_PATH` if needed.
|
||||
5. Install dependencies:
|
||||
|
||||
1. The agent reads `AGENTS.md`.
|
||||
2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in.
|
||||
3. The agent checks `SKILLS.md`.
|
||||
4. The agent opens only the relevant hub and skill files for the task.
|
||||
5. The agent combines multiple skills when the task spans several domains.
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Core Categories
|
||||
6. Start the development server:
|
||||
|
||||
- Software development
|
||||
- Debugging
|
||||
- Documentation
|
||||
- UI/UX
|
||||
- Marketing
|
||||
- Brainstorming
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
7. Open `http://localhost:3000` and sign in with the bootstrap admin credentials.
|
||||
|
||||
The SQLite schema is created automatically on first run.
|
||||
If the database has no users yet, the bootstrap admin user is created from `ADMIN_EMAIL` and `ADMIN_PASSWORD`.
|
||||
|
||||
## Docker
|
||||
|
||||
Build and run locally:
|
||||
|
||||
```bash
|
||||
docker build -t inven .
|
||||
docker run --rm -p 3000:3000 -v $(pwd)/data:/data inven
|
||||
```
|
||||
|
||||
Suggested Unraid mapping:
|
||||
|
||||
- App data mount: `/data`
|
||||
- Container port: `3000`
|
||||
- Environment variable: `DATABASE_PATH=/data/inven.sqlite`
|
||||
- Environment variable: `AUTH_SECRET=<long random secret>`
|
||||
- Environment variable: `ADMIN_EMAIL=<admin email>`
|
||||
- Environment variable: `ADMIN_PASSWORD=<initial admin password>`
|
||||
|
||||
## Workflow Notes
|
||||
|
||||
- Add customers and vendors before creating orders.
|
||||
- Add parts and assemblies in the Parts module.
|
||||
- Define assembly components in the Assemblies module.
|
||||
- Use purchase orders to restock and receive inventory.
|
||||
- Use build transactions to convert component stock into assembly stock.
|
||||
- Use sales orders and ship them fully or partially to reduce stock and generate invoices plus journal entries.
|
||||
- Use purchase orders and receive them fully or partially to increase stock and generate vendor bills plus journal entries.
|
||||
- Use the Invoices page to receive customer payments against open AR.
|
||||
- Use the Vendor Bills page to pay vendor obligations against open AP.
|
||||
- Use the accounting page to add accounts, review balances, and post manual journals when needed.
|
||||
- Use the login page to access the application with the configured admin account.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- No lot, serial, warehouse, or bin tracking yet
|
||||
- No lockfile included because dependencies were not installed in this environment
|
||||
|
||||
## Project Docs
|
||||
|
||||
- [Architecture overview](./docs/architecture.md)
|
||||
- [ADR: monolith stack and data model](./docs/adr/0001-monolith-nextjs-sqlite.md)
|
||||
- [Unraid installation guide](./UNRAID.md)
|
||||
|
||||
175
UNRAID.md
Normal file
175
UNRAID.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Unraid Installation Guide
|
||||
|
||||
This document is part of the install contract for this repository.
|
||||
|
||||
If a change would alter how the container is built, started, configured, mapped, or authenticated, this file must be updated in the same change.
|
||||
|
||||
## What You Need
|
||||
|
||||
- Unraid server with Docker enabled
|
||||
- Network access from Unraid to clone the repository
|
||||
- A persistent appdata location for the SQLite database
|
||||
- Initial admin credentials and a long random auth secret
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
- `DATABASE_PATH`
|
||||
Recommended: `/data/inven.sqlite`
|
||||
- `AUTH_SECRET`
|
||||
Use a long random string. This signs login sessions.
|
||||
- `ADMIN_EMAIL`
|
||||
Initial bootstrap admin email.
|
||||
- `ADMIN_PASSWORD`
|
||||
Initial bootstrap admin password.
|
||||
|
||||
Important:
|
||||
|
||||
- The bootstrap admin is created only when the database has no users yet.
|
||||
- Changing `ADMIN_EMAIL` or `ADMIN_PASSWORD` after first boot does not replace an existing user automatically.
|
||||
- Keep `AUTH_SECRET` stable after deployment. Rotating it will invalidate active sessions.
|
||||
|
||||
## CLI Build And Run
|
||||
|
||||
These steps are useful if you want to build on the Unraid host or another Linux machine before using the Unraid GUI.
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone <YOUR-REPOSITORY-URL> inven
|
||||
cd inven
|
||||
```
|
||||
|
||||
### 2. Build the image
|
||||
|
||||
```bash
|
||||
docker build -t inven:latest .
|
||||
```
|
||||
|
||||
### 3. Create a persistent data directory
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/user/appdata/inven/data
|
||||
```
|
||||
|
||||
### 4. Run the container
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name inven \
|
||||
-p 3000:3000 \
|
||||
-v /mnt/user/appdata/inven/data:/data \
|
||||
-e DATABASE_PATH=/data/inven.sqlite \
|
||||
-e AUTH_SECRET='replace-with-a-long-random-secret' \
|
||||
-e ADMIN_EMAIL='admin@example.com' \
|
||||
-e ADMIN_PASSWORD='replace-with-a-strong-password' \
|
||||
--restart unless-stopped \
|
||||
inven:latest
|
||||
```
|
||||
|
||||
### 5. Open the app
|
||||
|
||||
- URL: `http://<UNRAID-IP>:3000`
|
||||
- Sign in with the configured bootstrap admin credentials
|
||||
|
||||
## Unraid GUI Setup
|
||||
|
||||
These instructions assume you are creating a custom Docker container in the Unraid web UI.
|
||||
|
||||
### 1. Build or provide an image
|
||||
|
||||
You need an image first. Use one of these paths:
|
||||
|
||||
- Build from the CLI steps above and tag/push it to a registry you can pull from Unraid
|
||||
- Or build locally on the Unraid host and reference that image name directly
|
||||
|
||||
Example image name:
|
||||
|
||||
- `inven:latest`
|
||||
|
||||
### 2. Add the container in Unraid
|
||||
|
||||
In the Unraid Docker tab:
|
||||
|
||||
1. Click `Add Container`
|
||||
2. Set `Name` to `inven`
|
||||
3. Set `Repository` to your image, for example `inven:latest`
|
||||
4. Set `Network Type` to `bridge`
|
||||
|
||||
### 3. Configure ports
|
||||
|
||||
Add this port mapping:
|
||||
|
||||
- `Container Port`: `3000`
|
||||
- `Host Port`: `3000`
|
||||
- `Connection Type`: `TCP`
|
||||
|
||||
If `3000` is already in use on the host, choose a different host port such as `8087`.
|
||||
|
||||
### 4. Configure paths
|
||||
|
||||
Add this path mapping:
|
||||
|
||||
- `Name`: `data`
|
||||
- `Container Path`: `/data`
|
||||
- `Host Path`: `/mnt/user/appdata/inven/data`
|
||||
- `Access Mode`: `Read/Write`
|
||||
|
||||
This path stores the SQLite database and must be persistent.
|
||||
|
||||
### 5. Configure environment variables
|
||||
|
||||
Add these variables:
|
||||
|
||||
- `DATABASE_PATH`
|
||||
Value: `/data/inven.sqlite`
|
||||
- `AUTH_SECRET`
|
||||
Value: a long random secret
|
||||
- `ADMIN_EMAIL`
|
||||
Value: your initial admin email
|
||||
- `ADMIN_PASSWORD`
|
||||
Value: your initial admin password
|
||||
|
||||
### 6. Apply and start
|
||||
|
||||
1. Click `Apply`
|
||||
2. Start the container
|
||||
3. Open `http://<UNRAID-IP>:3000`
|
||||
4. Log in with `ADMIN_EMAIL` and `ADMIN_PASSWORD`
|
||||
|
||||
## Updating The Container
|
||||
|
||||
When app changes do not require install changes:
|
||||
|
||||
1. Pull or rebuild the updated image
|
||||
2. Recreate the container with the same `/data` mapping
|
||||
3. Keep `DATABASE_PATH` and `AUTH_SECRET` consistent
|
||||
|
||||
When app changes do require install changes:
|
||||
|
||||
- Update this document
|
||||
- Update `.env.example`
|
||||
- Update the README install section if needed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App starts but login does not work
|
||||
|
||||
- Confirm `ADMIN_EMAIL` and `ADMIN_PASSWORD` were present on first boot
|
||||
- If the database already existed before auth was configured, the bootstrap user may not have been created
|
||||
- Confirm `AUTH_SECRET` is set and stable
|
||||
|
||||
### Sessions keep getting invalidated
|
||||
|
||||
- Confirm `AUTH_SECRET` is unchanged across restarts or image updates
|
||||
|
||||
### Data disappears after container recreation
|
||||
|
||||
- Confirm `/data` is mapped to a persistent Unraid host path
|
||||
- Confirm `DATABASE_PATH=/data/inven.sqlite`
|
||||
|
||||
### Container starts but app is unreachable
|
||||
|
||||
- Confirm the host port is mapped correctly
|
||||
- Confirm no other container is already using the selected host port
|
||||
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>
|
||||
);
|
||||
}
|
||||
11
components/logout-button.tsx
Normal file
11
components/logout-button.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { logoutAction } from "@/lib/actions";
|
||||
|
||||
export function LogoutButton() {
|
||||
return (
|
||||
<form action={logoutAction}>
|
||||
<button className="button secondary" type="submit">
|
||||
Log Out
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
42
components/sidebar.tsx
Normal file
42
components/sidebar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard" },
|
||||
{ href: "/parts", label: "Parts" },
|
||||
{ href: "/assemblies", label: "Assemblies" },
|
||||
{ href: "/sales-orders", label: "Sales Orders" },
|
||||
{ href: "/purchase-orders", label: "Purchase Orders" },
|
||||
{ href: "/invoices", label: "Invoices" },
|
||||
{ href: "/vendor-bills", label: "Vendor Bills" },
|
||||
{ href: "/customers", label: "Customers" },
|
||||
{ href: "/vendors", label: "Vendors" },
|
||||
{ href: "/accounting", label: "Accounting" }
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" />
|
||||
<strong>Inven</strong>
|
||||
<span className="muted">Inventory, kits, orders, and accounting in one container.</span>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
data-active={pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
55
docs/adr/0001-monolith-nextjs-sqlite.md
Normal file
55
docs/adr/0001-monolith-nextjs-sqlite.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# ADR 0001: Use a Next.js Monolith with SQLite for Initial Deployment
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-03-23
|
||||
|
||||
## Context
|
||||
|
||||
The system needs to support inventory management for stocked parts and assemblies, sales orders and shipping, purchase orders and restocking, customer and vendor records, and an initial accounting module. The first deployment target is a single Docker container on Unraid with SQLite as the database.
|
||||
|
||||
## Decision
|
||||
|
||||
Use a single Next.js application with TypeScript and SQLite.
|
||||
|
||||
Key aspects:
|
||||
|
||||
- UI and backend live in one deployable unit
|
||||
- SQLite is the primary datastore and is persisted through a mounted `/data` volume
|
||||
- Inventory is modeled as a transaction ledger, not an editable balance table
|
||||
- Assemblies are first-class items with bill-of-material component records
|
||||
- Accounting starts as an operational journal tied to stock-affecting workflows
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Separate frontend and API services
|
||||
|
||||
Rejected for now because it adds deployment and operational complexity that does not help the initial Unraid target.
|
||||
|
||||
### Python monolith
|
||||
|
||||
Reasonable, but not selected because the project defaults favor TypeScript for full-stack work and Next.js gives a fast path to a polished UI and server-rendered workflows in one application.
|
||||
|
||||
### Direct quantity-on-hand fields without a transaction ledger
|
||||
|
||||
Rejected because the system needs reliable traceability for receipts, builds, shipments, and later accounting reconciliation.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Simple deployment model
|
||||
- Minimal infrastructure requirements
|
||||
- Clear path from operational workflows to accounting entries
|
||||
- Good developer velocity for admin and internal-tool style features
|
||||
|
||||
### Negative
|
||||
|
||||
- SQLite concurrency limits will eventually matter at larger scale
|
||||
- Server actions are tightly coupled to the app process
|
||||
- Accounting capabilities are intentionally lightweight at this stage
|
||||
|
||||
## Follow-Up
|
||||
|
||||
- Add auth and permissions before multi-user production rollout
|
||||
- Add partial receipt and shipment workflows
|
||||
- Add lockfile and CI once dependencies can be installed in a build-capable environment
|
||||
87
docs/architecture.md
Normal file
87
docs/architecture.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Architecture Overview
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver a practical inventory and order management system that can run in a single Docker container on Unraid using SQLite for persistence.
|
||||
|
||||
## System Shape
|
||||
|
||||
The application is a monolithic web app:
|
||||
|
||||
- Next.js handles UI rendering and server-side actions
|
||||
- SQLite stores operational and accounting data
|
||||
- Server actions execute the main workflows directly against SQLite
|
||||
- The same process serves UI and API endpoints
|
||||
- Auth is handled in-process with a bootstrap admin user and signed session cookies
|
||||
|
||||
## Authentication
|
||||
|
||||
- The first user can be bootstrapped from `ADMIN_EMAIL` and `ADMIN_PASSWORD`
|
||||
- Session cookies are signed with `AUTH_SECRET`
|
||||
- Middleware protects application routes and redirects unauthenticated requests to `/login`
|
||||
- This model is intentionally simple and suited to a single-container internal deployment
|
||||
|
||||
## Domain Boundaries
|
||||
|
||||
### Item Master
|
||||
|
||||
- `parts` stores both stocked parts and assemblies
|
||||
- `kind` differentiates simple stocked parts from buildable assemblies
|
||||
- Assemblies remain sellable as normal items
|
||||
|
||||
### Bill of Materials
|
||||
|
||||
- `kit_components` maps assembly items to component parts
|
||||
- Building an assembly consumes component stock and creates finished stock
|
||||
|
||||
### Inventory Ledger
|
||||
|
||||
- `inventory_transactions` is the stock source of truth
|
||||
- On-hand stock is derived through aggregation rather than directly edited balances
|
||||
- Supported transaction types include manual adjustments, purchase receipts, sales shipments, and assembly builds
|
||||
|
||||
### Sales
|
||||
|
||||
- `customers` drives customer master data
|
||||
- `sales_orders` and `sales_order_lines` capture outbound demand
|
||||
- Shipping can be partial at the line level through `shipped_quantity`
|
||||
- Each shipment posts inventory movements and journal entries for only the quantity moved
|
||||
- Each shipment also creates a customer invoice that can later be partially or fully paid
|
||||
|
||||
### Purchasing
|
||||
|
||||
- `vendors` drives vendor master data
|
||||
- `purchase_orders` and `purchase_order_lines` capture replenishment demand
|
||||
- Receiving can be partial at the line level through `received_quantity`
|
||||
- Each receipt posts inventory movements and journal entries for only the quantity received
|
||||
- Each receipt also creates a vendor bill that can later be partially or fully paid
|
||||
|
||||
### Accounting
|
||||
|
||||
- `journal_entries` and `journal_lines` provide a lightweight operational journal
|
||||
- `accounts` provides a small chart of accounts for system-posted and manual entries
|
||||
- Purchase receipts debit inventory and credit accounts payable
|
||||
- Sales shipments debit accounts receivable and cost of goods sold, then credit sales revenue and inventory
|
||||
- Customer payments debit cash and credit accounts receivable
|
||||
- Vendor payments debit accounts payable and credit cash
|
||||
- Manual journals allow opening balances and non-operational bookkeeping adjustments
|
||||
|
||||
### Replenishment Visibility
|
||||
|
||||
- Low-stock reporting is derived from current on-hand balances against item reorder points
|
||||
- Suggested reorder quantity is calculated from the gap between on-hand and reorder point
|
||||
- The purchase order screen surfaces these items to speed up restocking decisions
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- Database file defaults to `/data/inven.sqlite`
|
||||
- Next.js standalone output keeps container runtime simple
|
||||
- A volume mount for `/data` preserves SQLite across container restarts
|
||||
|
||||
## Near-Term Expansion
|
||||
|
||||
- Authentication and role permissions
|
||||
- Partial receipts and shipments
|
||||
- Invoices, payments, and vendor bill settlement
|
||||
- Warehouse or location tracking
|
||||
- Reporting and export workflows
|
||||
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;
|
||||
};
|
||||
77
middleware.ts
Normal file
77
middleware.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const SESSION_COOKIE = "inven_session";
|
||||
|
||||
function getAuthSecret() {
|
||||
return process.env.AUTH_SECRET || "dev-insecure-auth-secret";
|
||||
}
|
||||
|
||||
async function sign(value: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(getAuthSecret()),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
return atob(padded);
|
||||
}
|
||||
|
||||
async function hasValidSession(request: NextRequest) {
|
||||
const raw = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [base, signature] = raw.split(".");
|
||||
if (!base || !signature || (await sign(base)) !== signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(base)) as { expiresAt?: number };
|
||||
return typeof payload.expiresAt === "number" && payload.expiresAt > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isPublic =
|
||||
pathname === "/login" ||
|
||||
pathname.startsWith("/_next") ||
|
||||
pathname.startsWith("/favicon") ||
|
||||
pathname === "/api/health";
|
||||
|
||||
const authenticated = await hasValidSession(request);
|
||||
|
||||
if (!authenticated && !isPublic) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (authenticated && pathname === "/login") {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!.*\\..*).*)"]
|
||||
};
|
||||
4
next-env.d.ts
vendored
Normal file
4
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// This file is auto-managed by Next.js.
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "inven",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
1
public/.gitkeep
Normal file
1
public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "es2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user