Developer Guide
Lofty Golden Oil ERP — Complete Developer Reference
Table of Contents
- Development Environment Setup
- Repository Structure
- Frontend Development
- Backend Development
- Database Development
- Adding a New Feature End-to-End
- Code Style and Conventions
- Common Development Tasks
1. Development Environment Setup
Prerequisites
| Tool | Minimum Version | Recommended Version |
|---|---|---|
| Node.js | 22.x | 22 LTS |
| npm | 10.x | 10.x |
| MySQL | 8.0 | 8.0.46 |
| Git | 2.x | Latest |
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
| File | Purpose |
|---|---|
client/src/App.jsx | Root component, all routes, layout shell, theme provider |
client/src/services/api.js | Axios instance with JWT interceptor, all API method wrappers |
client/src/index.css | Tailwind directives, CSS custom properties for theming |
client/vite.config.js | Vite build configuration, dev server proxy |
server/server.js | Express application: middleware stack, route registration, DB pool, startup |
ecosystem.config.cjs | PM2 process definitions for prod and staging |
.github/workflows/deploy.yml | CI/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
| Shortcut | Action | Handler Location |
|---|---|---|
Cmd+K / Ctrl+K | Focus global search | App.jsx |
Cmd+N / Ctrl+N | Open new item form | View components |
Escape | Close active modal/dialog | Modal 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:
| Table | Purpose |
|---|---|
users | System user accounts and credentials |
settings | Application configuration key-value pairs |
inventory | Product catalog and stock levels |
purchases | Purchase orders and procurement records |
production_runs | Manufacturing/production batch records |
sales | Sales transactions |
expenses | Operational expense records |
inventory_adjustments | Manual stock adjustment records |
ar_invoices | Accounts receivable invoices |
ar_payments | Accounts receivable payment records |
requisitions | Internal requisition requests with approval workflow |
customers | Customer master records |
suppliers | Supplier/vendor master records |
audit_log | System 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:
- Automatic:
CREATE TABLE IF NOT EXISTSon server startup - Manual: Run
ALTER TABLEstatements 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
- Start the development server:
npm run dev - Test API endpoints with Postman or curl:
# Login and get tokenTOKEN=$(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 returncurl -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 returnscurl http://localhost:4000/api/returns -H "Authorization: Bearer $TOKEN"
- Test the frontend view in the browser
- Test role-based access by logging in as different users
- Verify audit log entries are created
7. Code Style and Conventions
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Database tables | snake_case, plural | inventory_adjustments, production_runs |
| Database columns | snake_case | created_at, stock_quantity |
| API endpoints | kebab-case, plural | /api/inventory-items, /api/production-runs |
| React components | PascalCase | DataTable.jsx, ConfirmDialog.jsx |
| View components | PascalCase | Dashboard.jsx, Inventory.jsx |
| JavaScript functions | camelCase | fetchData, handleSubmit |
| CSS classes | Tailwind utilities | bg-primary, text-gray-900 |
| Environment variables | SCREAMING_SNAKE_CASE | DB_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
useStatefor local state,useMemofor derived datauseEffectfor data fetching and event listeners- Inline event handlers in JSX
- Conditional rendering with
&&operator - Map rendering with
keyprop - Error boundaries not currently implemented (errors caught in try/catch)
Express Patterns
- Single
server.jsfile — no separate route files - Async/await for all database operations
- Parameterized SQL queries (never string concatenation)
- Consistent error response format:
{ message: string } - Middleware composition:
authenticateToken→requireRole([...])→ handler - Audit logging on all write operations
- Soft deletes (status = 'inactive') rather than hard deletes
8. Common Development Tasks
Adding a New API Endpoint
- Open
server/server.js - Find the appropriate route section
- 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' });
}
});
- Add the corresponding method in
client/src/services/api.js:
export default {
// ... existing methods
getNewEndpoint: () => api.get('/new-endpoint'),
};
Adding a New Form
- Create a modal component or inline form in the target view
- Follow the form pattern:
useStatefor formData and errors, validation function, submit handler - Use
toast.success()andtoast.error()for feedback - Add
PasswordInputcomponent for password fields - Use controlled inputs with
onChangehandlers
Adding a New DataTable Column
- Find the
columnsarray in the target view - 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()}` }
- Ensure the API returns the field in the response
Adding Email Templates
- Define the HTML template in the email sending section of
server.js - Templates use Resend's HTML email format
- 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>`,
});
- Available email types: welcome, password reset, requisition approval, requisition rejection, invoice, payment receipt, low stock alert