Skip to main content

Developer Guide

Lofty Golden Oil ERP — Complete Developer Reference


Table of Contents

  1. Development Environment Setup
  2. Repository Structure
  3. Frontend Development
  4. Backend Development
  5. Database Development
  6. Adding a New Feature End-to-End
  7. Code Style and Conventions
  8. Common Development Tasks

1. Development Environment Setup

Prerequisites

ToolMinimum VersionRecommended Version
Node.js22.x22 LTS
npm10.x10.x
MySQL8.08.0.46
Git2.xLatest

Install Prerequisites (macOS)

# Install Homebrew if not present
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Install Node.js 22
brew install node@22

# Install MySQL
brew install mysql@8.0
brew services start mysql@8.0

# Verify installations
node --version # v22.x.x
npm --version # 10.x.x
mysql --version # mysql Ver 8.0.46

Clone and Install

# Clone the repository
git clone <repository-url> lofty-golden-oil-erp
cd lofty-golden-oil-erp

# Install all dependencies (root, client, and server)
npm install

# This runs concurrently:
# - root package.json (concurrently, nodemon)
# - client/ (React + Vite)
# - server/ (Express 5)

Environment Configuration

Create the required environment files:

# Server environment
cat > server/.env << EOF
NODE_ENV=development
PORT=4000
DB_HOST=localhost
DB_PORT=3306
DB_USER=lgo
DB_PASSWORD=your_password_here
DB_NAME=lgo_erp_staging
JWT_SECRET=dev-secret-change-in-production
RESEND_API_KEY=re_your_key_here
RESEND_FROM_EMAIL=noreply@loftygoldenoil.com
FRONTEND_URL=http://localhost:5173
EOF

Start Development Servers

# Start both client and server concurrently
npm run dev

# Or start individually:
npm run server:dev # Express backend on port 4000
npm run client:dev # Vite dev server on port 5173

Database Setup

-- Connect to MySQL
mysql -u root -p

-- Create the database
CREATE DATABASE IF NOT EXISTS lgo_erp_staging;

-- Create the application user
CREATE USER IF NOT EXISTS 'lgo'@'localhost' IDENTIFIED BY 'your_password_here';
GRANT ALL PRIVILEGES ON lgo_erp_staging.* TO 'lgo'@'localhost';
FLUSH PRIVILEGES;

Tables are created automatically by the server on first startup via the schema initialization in server.js.


2. Repository Structure

lofty-golden-oil-erp/
├── .github/
│ └── workflows/
│ └── deploy.yml # CI/CD pipeline (GitHub Actions)
├── client/ # React frontend
│ ├── public/ # Static assets
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ │ ├── AlertBadge.jsx
│ │ │ ├── BulkActions.jsx
│ │ │ ├── ConfirmDialog.jsx
│ │ │ ├── DataTable.jsx
│ │ │ ├── Pagination.jsx
│ │ │ ├── PasswordInput.jsx
│ │ │ ├── SearchBar.jsx
│ │ │ └── UndoButton.jsx
│ │ ├── views/ # Page-level components (routes)
│ │ │ ├── Dashboard.jsx
│ │ │ ├── Inventory.jsx
│ │ │ ├── Purchases.jsx
│ │ │ ├── Production.jsx
│ │ │ ├── Sales.jsx
│ │ │ ├── Expenses.jsx
│ │ │ ├── AR.jsx
│ │ │ ├── Requisitions.jsx
│ │ │ ├── Customers.jsx
│ │ │ ├── Suppliers.jsx
│ │ │ ├── Reports.jsx
│ │ │ ├── Settings.jsx
│ │ │ ├── Users.jsx
│ │ │ └── AuditLog.jsx
│ │ ├── services/ # API client and utilities
│ │ │ └── api.js # Axios instance + API methods
│ │ ├── App.jsx # Root component, routing, layout
│ │ ├── main.jsx # React DOM entry point
│ │ └── index.css # Tailwind directives + CSS variables
│ ├── index.html
│ ├── vite.config.js
│ ├── tailwind.config.js
│ ├── postcss.config.js
│ └── package.json
├── server/ # Express backend
│ ├── server.js # Main entry point, middleware, routes
│ ├── .env # Environment variables (gitignored)
│ └── package.json
├── docs/ # Documentation
│ ├── 01-getting-started/
│ ├── 02-administration/
│ ├── 03-modules/
│ ├── 04-processes/
│ ├── 05-api-reference/
│ ├── 06-deployment/
│ ├── 07-design-system/
│ ├── 08-data-dictionary/
│ ├── 09-reference/
│ └── 10-appendix/
├── ecosystem.config.cjs # PM2 configuration
├── package.json # Root package.json (scripts, devDeps)
├── .gitignore
├── README.md
└── LICENSE

Key Files

FilePurpose
client/src/App.jsxRoot component, all routes, layout shell, theme provider
client/src/services/api.jsAxios instance with JWT interceptor, all API method wrappers
client/src/index.cssTailwind directives, CSS custom properties for theming
client/vite.config.jsVite build configuration, dev server proxy
server/server.jsExpress application: middleware stack, route registration, DB pool, startup
ecosystem.config.cjsPM2 process definitions for prod and staging
.github/workflows/deploy.ymlCI/CD: build, lint, deploy to VPS

3. Frontend Development

Technology Stack

  • React 19 — UI library
  • Vite 6 — Build tool and dev server
  • Tailwind CSS 4 — Utility-first styling
  • Axios — HTTP client

Component Architecture

src/
├── components/ # Reusable, stateless or lightly stateful UI primitives
│ ├── DataTable.jsx # Universal data table
│ ├── SearchBar.jsx # Global search with Cmd+K
│ ├── Pagination.jsx # Page navigation
│ ├── AlertBadge.jsx # Status badges
│ ├── BulkActions.jsx# Selection action bar
│ ├── UndoButton.jsx # Undo with timer
│ ├── ConfirmDialog.jsx # Confirmation modal
│ └── PasswordInput.jsx # Password field with toggle
├── views/ # Route-level page components (one per module)
├── services/
│ └── api.js # Centralized API client
├── App.jsx # Root: routing, layout, theme, auth
├── main.jsx # ReactDOM.createRoot entry
└── index.css # Global styles, CSS variables, Tailwind

State Management

The application uses React hooks exclusively for state management — no external state library (Redux, Zustand, etc.).

Common patterns:

// Local state for UI concerns
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState([]);

// Derived state
const filteredData = useMemo(() => {
return data.filter(item => item.status === 'active');
}, [data]);

// Side effects for data fetching
useEffect(() => {
fetchData();
}, []);

// Form state
const [formData, setFormData] = useState({
name: '',
quantity: 0,
price: 0,
});

Styling

Tailwind CSS is the primary styling approach. CSS custom properties provide theming.

// Tailwind utility classes
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">

// Theme-aware via CSS variables in index.css
<div className="bg-primary text-on-primary">

// Conditional dark mode
<h1 className="text-gray-900 dark:text-white text-2xl font-bold">

Theme System

The theme is defined via CSS custom properties in client/src/index.css:

:root {
/* Light theme (default) */
--color-primary: #D4A843;
--color-primary-hover: #C49A38;
--color-bg: #FDF8F0;
--color-surface: #FFFFFF;
--color-text: #1A1A1A;
--color-text-secondary: #6B7280;
--color-border: #E5E7EB;
}

.dark {
--color-primary: #D4A843;
--color-primary-hover: #E0B94E;
--color-bg: #121212;
--color-surface: #1E1E1E;
--color-text: #F5F5F5;
--color-text-secondary: #9CA3AF;
--color-border: #333333;
}

Toggle via a class on the <html> element:

// In App.jsx or a theme context
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');

useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);

Adding a New View/Page — Step by Step

1. Create the view component:

// client/src/views/NewModule.jsx
import { useState, useEffect } from 'react';
import DataTable from '../components/DataTable';
import SearchBar from '../components/SearchBar';
import api from '../services/api';

export default function NewModule() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetchItems();
}, []);

const fetchItems = async () => {
try {
setLoading(true);
const response = await api.get('/new-module');
setItems(response.data);
} catch (err) {
console.error('Failed to fetch items:', err);
} finally {
setLoading(false);
}
};

const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'name', label: 'Name', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
];

return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
New Module
</h1>

<DataTable
data={items}
columns={columns}
searchable
searchPlaceholder="Search items..."
onAdd={() => {/* open modal */}}
addLabel="Add Item"
exportable
/>
</div>
);
}

2. Add the route in App.jsx:

import NewModule from './views/NewModule';

// Inside the <Routes> block:
<Route path="/new-module" element={<NewModule />} />

3. Add to the navigation sidebar in App.jsx:

// In the sidebar/nav component:
<NavLink to="/new-module">
<SomeIcon /> New Module
</NavLink>

4. Add role-based visibility (if needed):

{user.role === 'admin' && (
<NavLink to="/new-module">New Module</NavLink>
)}

Adding a New Component — Step by Step

1. Create the component file:

// client/src/components/StatusIndicator.jsx
export default function StatusIndicator({ status }) {
const colors = {
active: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
error: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};

return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.inactive}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}

2. Import and use in views:

import StatusIndicator from '../components/StatusIndicator';

<StatusIndicator status={item.status} />

Form Patterns

Forms follow a consistent pattern across the application:

function ProductForm({ onSubmit, initialData = {} }) {
const [formData, setFormData] = useState({
name: '',
category: '',
unit: 'litres',
price: 0,
...initialData,
});
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);

const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error on change
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};

const validate = () => {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = 'Name is required';
if (formData.price <= 0) newErrors.price = 'Price must be positive';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;

try {
setSubmitting(true);
await onSubmit(formData);
toast.success('Product saved successfully');
} catch (err) {
toast.error(err.response?.data?.message || 'Failed to save product');
} finally {
setSubmitting(false);
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
className={`mt-1 block w-full rounded-md border ${
errors.name ? 'border-red-500' : 'border-gray-300'
} px-3 py-2`}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-500">{errors.name}</p>
)}
</div>

<button
type="submit"
disabled={submitting}
className="bg-primary text-on-primary px-4 py-2 rounded-md"
>
{submitting ? 'Saving...' : 'Save Product'}
</button>
</form>
);
}

DataTable Usage Patterns

// Basic usage
<DataTable
data={products}
columns={[
{ key: 'name', label: 'Product Name', sortable: true },
{ key: 'stock', label: 'Stock', sortable: true },
{ key: 'price', label: 'Price', sortable: true, render: (val) => `${val.toLocaleString()}` },
]}
searchable
searchPlaceholder="Search products..."
/>

// With selection and bulk actions
<DataTable
data={products}
columns={columns}
selectable
onSelectionChange={setSelectedItems}
onBulkDelete={handleBulkDelete}
searchable
exportable
pagination={{
enabled: true,
pageSize: 20,
currentPage: page,
totalItems: totalCount,
onPageChange: setPage,
onPageSizeChange: setPageSize,
}}
/>

// With add button
<DataTable
data={products}
columns={columns}
onAdd={() => setShowAddModal(true)}
addLabel="Add Product"
searchable
exportable
/>

Toast Notifications

import { toast } from 'react-hot-toast'; // or the project's toast system

// Success
toast.success('Product saved successfully');

// Error
toast.error('Failed to delete product');

// Warning
toast.warning('Stock is running low');

// Info
toast.info('Report generation started');

Toast notifications appear in the top-right corner and auto-dismiss after 3 seconds.

Keyboard Shortcuts

ShortcutActionHandler Location
Cmd+K / Ctrl+KFocus global searchApp.jsx
Cmd+N / Ctrl+NOpen new item formView components
EscapeClose active modal/dialogModal components

Implementation pattern:

useEffect(() => {
const handleKeyDown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
searchRef.current?.focus();
}
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
e.preventDefault();
setShowAddModal(true);
}
if (e.key === 'Escape') {
setShowAddModal(false);
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);

4. Backend Development

Technology Stack

  • Express 5 — Web framework
  • Node.js 22 — Runtime
  • MySQL 8.0.46 — Database
  • mysql2 — Database driver with promise support
  • jsonwebtoken — JWT authentication
  • bcryptjs — Password hashing (10 rounds)
  • resend — Email delivery
  • cors — Cross-origin resource sharing

server.js Structure

The server is structured as a single server.js file with logical sections:

server.js
├── Imports and configuration
├── MySQL connection pool (5 connections)
├── Middleware stack
│ ├── CORS
│ ├── JSON body parser (15MB limit)
│ ├── URL-encoded parser
│ ├── Static file serving
│ ├── Request logging
│ └── Error handler
├── Auth middleware (JWT verification)
├── Role middleware (role-based access)
├── Route definitions
│ ├── POST /api/auth/login
│ ├── POST /api/auth/forgot-password
│ ├── POST /api/auth/reset-password
│ ├── GET /api/users
│ ├── POST /api/users
│ ├── PUT /api/users/:id
│ ├── DELETE /api/users/:id
│ ├── GET /api/inventory
│ ├── POST /api/inventory
│ ├── ... (all CRUD endpoints)
│ └── GET /api/audit-log
├── Health check endpoint
└── Server startup (listen on PORT)

Middleware Stack

// CORS — allows all origins in development/production
app.use(cors());

// JSON body parser — 15MB limit for large payloads
app.use(express.json({ limit: '15mb' }));

// URL-encoded parser
app.use(express.urlencoded({ extended: true }));

// Request logging (development)
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});

// Static file serving (production)
app.use(express.static(path.join(__dirname, '../client/dist')));

// SPA fallback — serve index.html for all non-API routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});

// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ message: 'Internal server error' });
});

Route Organization

Routes are defined inline in server.js using Express Router patterns. All API routes are prefixed with /api/.

// Authentication routes (public)
app.post('/api/auth/login', async (req, res) => { ... });
app.post('/api/auth/forgot-password', async (req, res) => { ... });
app.post('/api/auth/reset-password', async (req, res) => { ... });

// Protected routes — require valid JWT
app.get('/api/users', authenticateToken, requireRole(['admin']), async (req, res) => { ... });
app.post('/api/users', authenticateToken, requireRole(['admin']), async (req, res) => { ... });
app.get('/api/inventory', authenticateToken, async (req, res) => { ... });
app.post('/api/inventory', authenticateToken, requireRole(['admin', 'production']), async (req, res) => { ... });

Database Queries

All database queries use parameterized SQL to prevent SQL injection.

// GET — list with optional filters
app.get('/api/inventory', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM inventory WHERE status = ? ORDER BY created_at DESC',
['active']
);
res.json(rows);
} catch (err) {
console.error('Error fetching inventory:', err);
res.status(500).json({ message: 'Failed to fetch inventory' });
}
});

// POST — create
app.post('/api/inventory', authenticateToken, requireRole(['admin', 'production']), async (req, res) => {
try {
const { name, category, quantity, unit, price } = req.body;

if (!name || quantity === undefined) {
return res.status(400).json({ message: 'Name and quantity are required' });
}

const [result] = await pool.query(
'INSERT INTO inventory (name, category, quantity, unit, price, status, created_at) VALUES (?, ?, ?, ?, ?, ?, NOW())',
[name, category, quantity, unit, price, 'active']
);

// Log to audit trail
await pool.query(
'INSERT INTO audit_log (user_id, action, table_name, record_id, details, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[req.user.id, 'CREATE', 'inventory', result.insertId, JSON.stringify({ name })]
);

res.status(201).json({ id: result.insertId, message: 'Product created' });
} catch (err) {
console.error('Error creating inventory item:', err);
res.status(500).json({ message: 'Failed to create product' });
}
});

// PUT — update
app.put('/api/inventory/:id', authenticateToken, requireRole(['admin', 'production']), async (req, res) => {
try {
const { id } = req.params;
const { name, category, quantity, unit, price } = req.body;

await pool.query(
'UPDATE inventory SET name = ?, category = ?, quantity = ?, unit = ?, price = ? WHERE id = ?',
[name, category, quantity, unit, price, id]
);

res.json({ message: 'Product updated' });
} catch (err) {
console.error('Error updating inventory:', err);
res.status(500).json({ message: 'Failed to update product' });
}
});

// DELETE — soft delete
app.delete('/api/inventory/:id', authenticateToken, requireRole(['admin']), async (req, res) => {
try {
const { id } = req.params;
await pool.query('UPDATE inventory SET status = ? WHERE id = ?', ['inactive', id]);
res.json({ message: 'Product deactivated' });
} catch (err) {
console.error('Error deleting inventory:', err);
res.status(500).json({ message: 'Failed to delete product' });
}
});

JWT Authentication Middleware

function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

if (!token) {
return res.status(401).json({ message: 'Access token required' });
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { id, username, role, email }
next();
} catch (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
}

Role Middleware

function requireRole(allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ message: 'Authentication required' });
}

if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
message: `Access denied. Required roles: ${allowedRoles.join(', ')}`,
});
}

next();
};
}

// Usage
app.delete('/api/users/:id', authenticateToken, requireRole(['admin']), handler);

Error Handling Patterns

// Standard error response pattern
try {
// ... business logic
} catch (err) {
console.error('Context description:', err);
res.status(500).json({ message: 'User-friendly error message' });
}

// Validation error (400)
if (!name || !quantity) {
return res.status(400).json({ message: 'Name and quantity are required' });
}

// Not found (404)
const [rows] = await pool.query('SELECT * FROM inventory WHERE id = ?', [id]);
if (rows.length === 0) {
return res.status(404).json({ message: 'Product not found' });
}

// Unauthorized (401/403)
return res.status(403).json({ message: 'Insufficient permissions' });

5. Database Development

MySQL 8.0 Configuration

  • Version: 8.0.46
  • Databases: lgo_erp_prod (production), lgo_erp_staging (staging)
  • User: lgo
  • Connection Pool: 5 connections
  • Charset: UTF-8 (utf8mb4)

Schema Management

Tables are created automatically on server startup. The schema is defined inline in server.js using CREATE TABLE IF NOT EXISTS statements.

The 14 tables:

TablePurpose
usersSystem user accounts and credentials
settingsApplication configuration key-value pairs
inventoryProduct catalog and stock levels
purchasesPurchase orders and procurement records
production_runsManufacturing/production batch records
salesSales transactions
expensesOperational expense records
inventory_adjustmentsManual stock adjustment records
ar_invoicesAccounts receivable invoices
ar_paymentsAccounts receivable payment records
requisitionsInternal requisition requests with approval workflow
customersCustomer master records
suppliersSupplier/vendor master records
audit_logSystem audit trail

Adding a New Table

Step 1: Add the CREATE TABLE IF NOT EXISTS statement in server.js within the schema initialization block:

// In the schema initialization section of server.js
await pool.query(`
CREATE TABLE IF NOT EXISTS new_table (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
status ENUM('active', 'inactive') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);

Step 2: Add API endpoints for CRUD operations in server.js.

Step 3: Add corresponding frontend view and API methods.

Migrations

There is no formal migration framework. Schema changes are applied via:

  1. Automatic: CREATE TABLE IF NOT EXISTS on server startup
  2. Manual: Run ALTER TABLE statements directly on the database for schema changes

For production schema changes:

# Connect to production database
mysql -u lgo -p lgo_erp_prod

# Apply migration
ALTER TABLE inventory ADD COLUMN batch_number VARCHAR(100) AFTER name;

Always back up the database before running manual migrations:

mysqldump -u lgo -p lgo_erp_prod > backup_$(date +%Y%m%d_%H%M%S).sql

Seeding

Seed data is inserted during initial setup or can be added via the Settings panel. For development, use direct SQL:

INSERT INTO inventory (name, category, quantity, unit, price, status)
VALUES
('Palm Oil 5L', 'Cooking Oil', 500, 'litres', 4500.00, 'active'),
('Vegetable Oil 3L', 'Cooking Oil', 300, 'litres', 3200.00, 'active');

INSERT INTO customers (name, email, phone, address, status)
VALUES
('ABC Stores', 'info@abcstores.com', '+2348012345678', '123 Lagos St', 'active');

6. Adding a New Feature End-to-End

Example: Adding a "Returns" module

Step 1: Define the Database Table

-- Run in MySQL
CREATE TABLE IF NOT EXISTS returns (
id INT AUTO_INCREMENT PRIMARY KEY,
sale_id INT NOT NULL,
customer_id INT NOT NULL,
product_id INT NOT NULL,
quantity DECIMAL(10,2) NOT NULL,
reason TEXT,
status ENUM('pending', 'approved', 'rejected', 'completed') DEFAULT 'pending',
created_by INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (sale_id) REFERENCES sales(id),
FOREIGN KEY (customer_id) REFERENCES customers(id),
FOREIGN KEY (product_id) REFERENCES inventory(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);

Step 2: Add API Endpoints

In server.js, add after existing route blocks:

// GET /api/returns — list all returns
app.get('/api/returns', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT r.*, c.name AS customer_name, i.name AS product_name
FROM returns r
JOIN customers c ON r.customer_id = c.id
JOIN inventory i ON r.product_id = i.id
ORDER BY r.created_at DESC
`);
res.json(rows);
} catch (err) {
console.error('Error fetching returns:', err);
res.status(500).json({ message: 'Failed to fetch returns' });
}
});

// POST /api/returns — create a return
app.post('/api/returns', authenticateToken, requireRole(['admin', 'sales']), async (req, res) => {
try {
const { sale_id, customer_id, product_id, quantity, reason } = req.body;

if (!sale_id || !customer_id || !product_id || !quantity) {
return res.status(400).json({ message: 'All fields are required' });
}

const [result] = await pool.query(
'INSERT INTO returns (sale_id, customer_id, product_id, quantity, reason, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, NOW())',
[sale_id, customer_id, product_id, quantity, reason, req.user.id]
);

await pool.query(
'INSERT INTO audit_log (user_id, action, table_name, record_id, details, created_at) VALUES (?, ?, ?, ?, ?, NOW())',
[req.user.id, 'CREATE', 'returns', result.insertId, JSON.stringify({ sale_id, product_id })]
);

res.status(201).json({ id: result.insertId, message: 'Return created' });
} catch (err) {
console.error('Error creating return:', err);
res.status(500).json({ message: 'Failed to create return' });
}
});

// PUT /api/returns/:id — update return status
app.put('/api/returns/:id', authenticateToken, requireRole(['admin']), async (req, res) => {
try {
const { id } = req.params;
const { status } = req.body;

await pool.query('UPDATE returns SET status = ? WHERE id = ?', [status, id]);
res.json({ message: 'Return updated' });
} catch (err) {
console.error('Error updating return:', err);
res.status(500).json({ message: 'Failed to update return' });
}
});

Step 3: Add Frontend View

// client/src/views/Returns.jsx
import { useState, useEffect } from 'react';
import DataTable from '../components/DataTable';
import ConfirmDialog from '../components/ConfirmDialog';
import api from '../services/api';
import { toast } from 'react-hot-toast';

export default function Returns() {
const [returns, setReturns] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);

useEffect(() => {
fetchReturns();
}, []);

const fetchReturns = async () => {
try {
setLoading(true);
const response = await api.get('/returns');
setReturns(response.data);
} catch (err) {
toast.error('Failed to load returns');
} finally {
setLoading(false);
}
};

const handleApprove = async (id) => {
try {
await api.put(`/returns/${id}`, { status: 'approved' });
toast.success('Return approved');
fetchReturns();
} catch (err) {
toast.error('Failed to approve return');
}
};

const columns = [
{ key: 'id', label: 'ID', sortable: true },
{ key: 'customer_name', label: 'Customer', sortable: true },
{ key: 'product_name', label: 'Product', sortable: true },
{ key: 'quantity', label: 'Qty', sortable: true },
{ key: 'reason', label: 'Reason' },
{
key: 'status',
label: 'Status',
sortable: true,
render: (val) => (
<span className={`badge ${val === 'approved' ? 'badge-green' : val === 'pending' ? 'badge-yellow' : 'badge-gray'}`}>
{val}
</span>
),
},
];

return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Returns</h1>

<DataTable
data={returns}
columns={columns}
searchable
searchPlaceholder="Search returns..."
onAdd={() => setShowAddModal(true)}
addLabel="New Return"
loading={loading}
/>
</div>
);
}

Step 4: Add to Navigation

In App.jsx, add the nav link:

<NavLink to="/returns" className={({ isActive }) => isActive ? 'nav-active' : 'nav'}>
Returns
</NavLink>

Add the route:

import Returns from './views/Returns';

<Route path="/returns" element={<Returns />} />

Step 5: Add Role Permissions

In server.js, define which roles can access each endpoint:

// Only admin and sales can view returns
app.get('/api/returns', authenticateToken, requireRole(['admin', 'sales']), ...);

// Only admin and sales can create returns
app.post('/api/returns', authenticateToken, requireRole(['admin', 'sales']), ...);

// Only admin can approve/reject returns
app.put('/api/returns/:id', authenticateToken, requireRole(['admin']), ...);

In the frontend, conditionally show nav links based on role:

{(user.role === 'admin' || user.role === 'sales') && (
<NavLink to="/returns">Returns</NavLink>
)}

Step 6: Test

  1. Start the development server: npm run dev
  2. Test API endpoints with Postman or curl:
    # Login and get token
    TOKEN=$(curl -s -X POST http://localhost:4000/api/auth/login \
    -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"password"}' | jq -r '.token')

    # Create a return
    curl -X POST http://localhost:4000/api/returns \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"sale_id":1,"customer_id":1,"product_id":1,"quantity":5,"reason":"Damaged"}'

    # List returns
    curl http://localhost:4000/api/returns -H "Authorization: Bearer $TOKEN"
  3. Test the frontend view in the browser
  4. Test role-based access by logging in as different users
  5. Verify audit log entries are created

7. Code Style and Conventions

Naming Conventions

ElementConventionExample
Database tablessnake_case, pluralinventory_adjustments, production_runs
Database columnssnake_casecreated_at, stock_quantity
API endpointskebab-case, plural/api/inventory-items, /api/production-runs
React componentsPascalCaseDataTable.jsx, ConfirmDialog.jsx
View componentsPascalCaseDashboard.jsx, Inventory.jsx
JavaScript functionscamelCasefetchData, handleSubmit
CSS classesTailwind utilitiesbg-primary, text-gray-900
Environment variablesSCREAMING_SNAKE_CASEDB_HOST, JWT_SECRET

File Organization

  • One component per file — Each component in src/components/ is a single default export
  • One view per module — Each route has its own file in src/views/
  • Services centralized — All API calls go through src/services/api.js
  • No nested components — Components are flat in the components/ directory

React Patterns

  • Functional components exclusively (no class components)
  • Hooks for state and side effects
  • useState for local state, useMemo for derived data
  • useEffect for data fetching and event listeners
  • Inline event handlers in JSX
  • Conditional rendering with && operator
  • Map rendering with key prop
  • Error boundaries not currently implemented (errors caught in try/catch)

Express Patterns

  • Single server.js file — no separate route files
  • Async/await for all database operations
  • Parameterized SQL queries (never string concatenation)
  • Consistent error response format: &#123; message: string &#125;
  • Middleware composition: authenticateTokenrequireRole([...]) → handler
  • Audit logging on all write operations
  • Soft deletes (status = 'inactive') rather than hard deletes

8. Common Development Tasks

Adding a New API Endpoint

  1. Open server/server.js
  2. Find the appropriate route section
  3. Add the endpoint with authentication middleware:
app.get('/api/new-endpoint', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM table_name');
res.json(rows);
} catch (err) {
console.error('Error:', err);
res.status(500).json({ message: 'Failed to fetch data' });
}
});
  1. Add the corresponding method in client/src/services/api.js:
export default {
// ... existing methods
getNewEndpoint: () => api.get('/new-endpoint'),
};

Adding a New Form

  1. Create a modal component or inline form in the target view
  2. Follow the form pattern: useState for formData and errors, validation function, submit handler
  3. Use toast.success() and toast.error() for feedback
  4. Add PasswordInput component for password fields
  5. Use controlled inputs with onChange handlers

Adding a New DataTable Column

  1. Find the columns array in the target view
  2. Add the column definition:
{ key: 'new_field', label: 'New Field', sortable: true }

// With custom render:
{ key: 'new_field', label: 'New Field', sortable: true, render: (val) => `${val.toLocaleString()}` }
  1. Ensure the API returns the field in the response

Adding Email Templates

  1. Define the HTML template in the email sending section of server.js
  2. Templates use Resend's HTML email format
  3. Send via the Resend API:
const { Resend } = require('resend');
const resend = new Resend(process.env.RESEND_API_KEY);

await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL,
to: recipientEmail,
subject: 'Subject Line',
html: `<html>...template HTML...</html>`,
});
  1. Available email types: welcome, password reset, requisition approval, requisition rejection, invoice, payment receipt, low stock alert