ERP system for Vietnamese furniture & construction company. Built with Next.js 16, PostgreSQL, Prisma ORM.
Live: https://admin.tiktak.vn Repo: https://github.com/sherlock-126/motnha
- Node.js 18+ (recommend 22)
- Docker (for PostgreSQL)
- Git
git clone https://github.com/sherlock-126/motnha.git
cd motnha/motnhaerp
npm install
cp .env.example .env
# Edit .env if needed (defaults work with docker-compose)
docker compose up -d # Start PostgreSQL on port 5432
npm run db:migrate # Run migrations
npm run db:seed # Seed sample data
npm run dev # http://localhost:3000| Password | Role | |
|---|---|---|
| [email protected] | admin123 | Giam doc (Director) |
| [email protected] | admin123 | Pho GD (Vice Director) |
| [email protected] | admin123 | Ke toan (Accountant) |
| [email protected] | admin123 | Ky thuat (Technical) |
# .env (development)
DATABASE_URL="postgresql://postgres:laprifRoAXRDGRaEH8eq@localhost:5433/motnhaerp?schema=public"
NEXTAUTH_SECRET="motnha-erp-secret-key-change-in-production"
NEXTAUTH_URL="http://localhost:3000"Team dùng chung database production qua Cloudflare Tunnel, không cần Docker PostgreSQL local.
1. Cài cloudflared (chỉ cần lần đầu):
- Windows:
winget install Cloudflare.cloudflared - macOS:
brew install cloudflared - Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
2. Chạy proxy (mở terminal riêng, giữ chạy nền):
cloudflared access tcp --hostname db.tiktak.vn --url localhost:54333. Dùng bình thường: Code, DBeaver, psql... đều kết nối localhost:5433:
postgresql://postgres:laprifRoAXRDGRaEH8eq@localhost:5433/motnhaerp
Lưu ý: Không cần
docker compose upcho database nữa. Chỉ cần cloudflared proxy đang chạy.
| Command | Description |
|---|---|
npm run dev |
Start dev server (webpack) |
npm run build |
Production build |
npm start |
Start production server |
npm test |
Run tests (Vitest) |
npm run test:watch |
Watch mode |
npm run type-check |
TypeScript strict check |
npm run db:migrate |
Prisma migrate dev |
npm run db:seed |
Seed database |
npm run db:reset |
Reset database |
npm run db:studio |
Open Prisma Studio (GUI) |
npm run db:generate |
Regenerate Prisma client |
| Layer | Technology |
|---|---|
| Framework | Next.js 16.1.6 (App Router, standalone) |
| Language | JavaScript (TypeScript-ready, strict: true, allowJs: true) |
| Database | PostgreSQL 16 + Prisma 6.19.2 |
| Auth | NextAuth.js 4.24 (Credentials, JWT, 8h expiry) |
| Validation | Zod 4.3.6 (.strict() on all schemas) |
| State | React Query 5 + Context API |
| Icons | Lucide React |
| Testing | Vitest 4 + Testing Library |
| Deploy | Docker + GitHub Actions (self-hosted runner) |
motnhaerp/
├── app/ # Next.js App Router
│ ├── layout.js # Root layout (wraps Providers)
│ ├── page.js # Dashboard
│ ├── login/page.js # Login page
│ ├── globals.css # ALL styles (single CSS file)
│ │
│ ├── customers/ # CRM
│ │ ├── page.js # Customer list
│ │ └── [id]/page.js # Customer detail
│ ├── projects/ # Project management
│ │ ├── page.js
│ │ └── [id]/page.js
│ ├── contracts/ # Contracts
│ │ ├── page.js
│ │ ├── create/page.js
│ │ └── [id]/page.js
│ ├── quotations/ # Quotations
│ │ ├── page.js
│ │ ├── create/page.js
│ │ ├── [id]/edit/page.js
│ │ └── [id]/pdf/page.js
│ ├── finance/page.js # Finance overview
│ ├── payments/page.js # Payment tracking
│ ├── expenses/page.js # Expenses
│ ├── products/page.js # Product catalog
│ ├── inventory/page.js # Inventory
│ ├── work-orders/page.js # Work orders
│ ├── purchasing/page.js # Purchase orders
│ ├── hr/page.js # HR management
│ ├── contractors/page.js # Contractors
│ ├── suppliers/page.js # Suppliers
│ ├── pipeline/page.js # Sales pipeline
│ ├── partners/page.js # Partners
│ ├── reports/page.js # Reports
│ ├── progress/[code]/page.js # Public project tracking
│ │
│ └── api/ # REST API routes
│ ├── auth/[...nextauth]/ # NextAuth handler
│ ├── dashboard/ # GET - KPI stats
│ ├── customers/ # CRUD
│ ├── projects/ # CRUD
│ ├── contracts/ # CRUD + payments
│ ├── quotations/ # CRUD
│ ├── quotation-templates/ # CRUD
│ ├── products/ # CRUD
│ ├── employees/ # CRUD
│ ├── contractors/ # CRUD
│ ├── suppliers/ # CRUD
│ ├── work-orders/ # CRUD
│ ├── work-item-library/ # CRUD
│ ├── finance/ # Finance + receivables
│ ├── inventory/ # Stock management
│ ├── purchase-orders/ # Purchase orders
│ ├── material-plans/ # Material planning
│ ├── project-documents/ # Documents
│ ├── project-expenses/ # Expenses
│ ├── tracking-logs/ # CRM activity logs
│ ├── milestones/[id]/ # Milestone updates
│ ├── upload/ # File upload
│ └── progress/[code]/ # Public API
│
├── components/
│ ├── AppShell.js # Layout: sidebar + header + content
│ ├── Header.js # Top bar (title, search, user, theme)
│ ├── Sidebar.js # Navigation menu (role-aware)
│ ├── Providers.js # SessionProvider, QueryClient, Role, Toast
│ ├── ui/ # Reusable UI components
│ │ ├── Modal.js # Modal dialog
│ │ ├── DataTable.js # Sortable table
│ │ ├── Pagination.js # Pagination controls
│ │ ├── Toast.js # Toast notifications (context)
│ │ ├── ConfirmDialog.js # Confirmation dialog
│ │ ├── SearchBar.js # Search input
│ │ ├── FilterBar.js # Filter controls
│ │ ├── StatusBadge.js # Status badges
│ │ ├── FormGroup.js # Form field wrapper
│ │ └── KPICard.js # KPI stat card
│ └── quotation/ # Quotation-specific
│ ├── CategoryTable.js
│ ├── TreeSidebar.js
│ └── Summary.js
│
├── lib/ # Server & shared utilities
│ ├── prisma.js # Prisma singleton + soft delete extension
│ ├── auth.js # NextAuth config (CredentialsProvider)
│ ├── apiHandler.js # withAuth() wrapper
│ ├── fetchClient.js # Frontend fetch (apiFetch)
│ ├── pagination.js # parsePagination() + paginatedResponse()
│ ├── softDelete.js # Prisma $extends for soft delete
│ ├── generateCode.js # Auto-generate codes (DA-001, HD-001, etc.)
│ ├── format.js # Vietnamese locale formatting
│ ├── rateLimit.js # In-memory rate limiter (60 req/min)
│ ├── quotation-constants.js # Quotation enums/templates
│ └── validations/ # Zod schemas
│ ├── common.js # Shared types (optStr, optFloat, optDate, cuid)
│ ├── customer.js
│ ├── project.js
│ ├── contract.js
│ ├── quotation.js
│ ├── product.js
│ ├── employee.js
│ ├── contractor.js
│ ├── supplier.js
│ ├── expense.js
│ ├── workOrder.js
│ └── workItemLibrary.js
│
├── contexts/
│ └── RoleContext.js # 4-tier RBAC (permissions from session)
│
├── hooks/
│ ├── useQuotationForm.js # Quotation form state
│ └── useAutoSaveDraft.js # Auto-save draft
│
├── prisma/
│ ├── schema.prisma # 28 models + User (PostgreSQL)
│ └── seed.js # Full sample data
│
├── types/
│ ├── models.ts # TypeScript interfaces
│ └── api.ts # API response types
│
├── __tests__/ # Tests
│ ├── setup.ts
│ └── lib/
│ ├── format.test.ts
│ ├── pagination.test.ts
│ └── validations.test.ts
│
├── scripts/
│ └── entrypoint.sh # Docker entrypoint
│
├── docker-compose.yml # Dev: PostgreSQL only
├── docker-compose.prod.yml # Prod: PostgreSQL + App
├── Dockerfile # Multi-stage (Node 22 Alpine)
├── .github/workflows/deploy.yml # CI/CD pipeline
├── middleware.js # NextAuth route protection
├── next.config.mjs # standalone output, bcryptjs external
├── package.json
└── vitest.config.ts
ALL API routes use the withAuth() wrapper. Never write raw route handlers.
// app/api/customers/route.js
import { withAuth } from '@/lib/apiHandler';
import prisma from '@/lib/prisma';
import { parsePagination, paginatedResponse } from '@/lib/pagination';
import { customerCreateSchema } from '@/lib/validations/customer';
// GET /api/customers?page=1&limit=20&search=abc
export const GET = withAuth(async (request, context, session) => {
const { searchParams } = new URL(request.url);
const { page, limit, skip } = parsePagination(searchParams);
const search = searchParams.get('search') || '';
const where = search
? { name: { contains: search, mode: 'insensitive' } }
: {};
const [data, total] = await Promise.all([
prisma.customer.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }),
prisma.customer.count({ where }),
]);
return NextResponse.json(paginatedResponse(data, total, { page, limit }));
});
// POST /api/customers
export const POST = withAuth(async (request, context, session) => {
const body = await request.json();
const validated = customerCreateSchema.parse(body); // Zod validates + strips unknown fields
const customer = await prisma.customer.create({ data: validated });
return NextResponse.json(customer, { status: 201 });
});Key points:
withAuth()handles: auth check, rate limiting (60/min), error catching, Prisma/Zod error formattingwithAuth(handler, { public: true })for public routes (no auth)- Session available as 3rd argument:
session.user.id,session.user.name,session.user.role - Always use
parsePagination()for list endpoints - Always validate with Zod schema (
.strict()blocks unknown fields)
Use apiFetch() from lib/fetchClient.js for all API calls. It auto-redirects to /login on 401.
'use client';
import { useState, useEffect } from 'react';
import { apiFetch } from '@/lib/fetchClient';
import { useToast } from '@/components/ui/Toast';
export default function CustomersPage() {
const [data, setData] = useState({ data: [], pagination: {} });
const [loading, setLoading] = useState(true);
const { showToast } = useToast();
const fetchData = async (page = 1) => {
setLoading(true);
try {
const res = await apiFetch(`/api/customers?page=${page}&limit=20`);
setData(res);
} catch (err) {
showToast(err.message, 'error');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, []);
// Create
const handleCreate = async (formData) => {
try {
await apiFetch('/api/customers', {
method: 'POST',
body: JSON.stringify(formData),
});
showToast('Them thanh cong!', 'success');
fetchData();
} catch (err) {
showToast(err.message, 'error');
}
};
return (/* ... */);
}Key points:
- All pages are
'use client'components - Use
showToast()instead ofalert()/window.confirm() - For confirmations, use
ConfirmDialogcomponent - Paginated responses:
res.data(array) +res.pagination(metadata)
All Zod schemas live in lib/validations/. Each entity has createSchema and updateSchema (partial).
// lib/validations/customer.js
import { z } from 'zod';
import { optStr, optFloat, optDate } from './common';
export const customerCreateSchema = z.object({
name: z.string().trim().min(1, 'Ten khach hang bat buoc'),
phone: z.string().trim().min(1, 'So dien thoai bat buoc'),
email: optStr,
address: optStr,
type: optStr.default('Ca nhan'),
// ...more fields
}).strict(); // IMPORTANT: .strict() rejects unknown fields
export const customerUpdateSchema = customerCreateSchema.partial();10 models have soft delete: Customer, Project, Product, Quotation, Contract, Contractor, Supplier, Employee, WorkOrder, ProjectExpense.
Handled automatically by Prisma $extends in lib/softDelete.js. Queries auto-filter deletedAt: null. Use prisma.model.delete() normally - it sets deletedAt instead of hard deleting.
lib/generateCode.js generates sequential codes (DA-001, HD-001, BG-001, etc.) using PostgreSQL Serializable transactions to prevent race conditions.
import { generateCode } from '@/lib/generateCode';
const code = await generateCode('Customer', 'code', 'KH'); // -> KH-001, KH-002, ...4 roles defined in contexts/RoleContext.js:
| Role | Permissions |
|---|---|
| Giam doc | Full access |
| Pho GD | Full access, cannot delete expenses |
| Ke toan | Finance, expenses, suppliers; no approve/reject/collect |
| Ky thuat | Read-only projects; no finance, no expenses |
Use in components:
import { useRole } from '@/contexts/RoleContext';
const { role, can } = useRole();
if (can('editContracts')) { /* show edit button */ }CRM: Customer, TrackingLog Sales: Quotation, QuotationCategory, QuotationItem, QuotationTemplate (+Category, +Item) Projects: Project, ProjectMilestone, ProjectBudget, ProjectExpense, ProjectDocument, ProjectEmployee Contracts: Contract, ContractPayment Operations: WorkOrder, Product, InventoryTransaction, Warehouse, MaterialPlan, PurchaseOrder, PurchaseOrderItem HR: Employee, Department Finance: Transaction, Contractor, ContractorPayment, Supplier
npm run db:studio # Open Prisma Studio GUI (browse data)
npm run db:migrate # Create migration after schema change
npm run db:generate # Regenerate client after schema change
npm run db:seed # Seed sample data
npm run db:reset # Reset DB + re-seed# 1. Edit prisma/schema.prisma
# 2. Create migration
npm run db:migrate
# 3. (If migration fails, fix schema, try again)
# 4. Prisma client auto-regenerates via postinstallWARNING: Do NOT run npx prisma without npm install first. It may install Prisma v7 which is incompatible with our v6 schema.
All styles are in a single file: app/globals.css. No CSS modules, no Tailwind.
Conventions:
- CSS variables for theming (light/dark):
var(--bg-primary),var(--text-primary),var(--border), etc. - Component classes:
.card,.stat-card,.data-table,.modal,.btn,.badge,.form-input, etc. - Responsive breakpoints: 1024px, 768px (tablet), 480px (mobile)
mainbranch only- Push to
maintriggers auto-deploy
# 1. Make your changes
# 2. Stage files
git add <files>
# 3. Commit
git commit -m "Short description of what and why"
# 4. Push (triggers auto-deploy)
git push origin main- GitHub Actions self-hosted runner on server picks up the job
- Checks out code
- Creates
.envfrom GitHub Secrets - Starts PostgreSQL container
- Runs
npm ci+prisma generate+prisma db push - Builds Docker image + deploys containers
- Cleans up old images
Deploy time: ~2-3 minutes Server: https://admin.tiktak.vn
- Check GitHub Actions tab for logs
- Common issues:
- Schema change needs
prisma db push(auto-handled) - New env vars need to be added to GitHub Secrets
- Docker build failure = check Dockerfile
- Schema change needs
Host: 100.111.242.16 (Tailscale IP)
User: root (or any user with sudo)
OS: Ubuntu 24.04
App URL: https://admin.tiktak.vn
# SSH into server
ssh [email protected]# Check running containers
docker ps --filter "name=motnha"
# Expected containers:
# motnha-app (Next.js app, port 3000)
# motnha-postgres (PostgreSQL 16, port 5432 localhost only)The database runs inside Docker and is only accessible from the server itself (bound to 127.0.0.1).
# Open psql shell inside the PostgreSQL container
docker exec -it motnha-postgres psql -U postgres -d motnhaerpOnce inside psql:
-- List all tables
\dt
-- View table structure
\d "Customer"
\d "Project"
-- Count records
SELECT COUNT(*) FROM "Customer";
SELECT COUNT(*) FROM "Project";
-- Query data (table/column names are PascalCase, must use double quotes)
SELECT id, code, name, phone, status FROM "Customer" LIMIT 10;
SELECT id, code, name, budget, progress, status FROM "Project" LIMIT 10;
-- Search
SELECT * FROM "Customer" WHERE name ILIKE '%nguyen%';
-- Insert
INSERT INTO "Customer" (id, code, name, phone, status, "pipelineStage", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), 'KH-999', 'Test Customer', '0901234567', 'Lead', 'Lead', NOW(), NOW());
-- Update
UPDATE "Customer" SET phone = '0909999999', "updatedAt" = NOW() WHERE code = 'KH-999';
-- Soft delete (do NOT use DELETE, set deletedAt instead)
UPDATE "Customer" SET "deletedAt" = NOW() WHERE code = 'KH-999';
-- Hard delete (use with caution, only for test data)
DELETE FROM "Customer" WHERE code = 'KH-999';
-- Exit psql
\q# Go to project directory on server
cd ~/actions-runner/_work/motnha/motnha/motnhaerp
# Get DATABASE_URL from the .env file
source .env
export DATABASE_URL="postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/motnhaerp?schema=public"
# Open Prisma Studio (browser GUI on port 5555)
npx prisma studio --port 5555Then open http://100.111.242.16:5555 in browser (Tailscale access only).
Press Ctrl+C to stop when done.
# Create a SQL file
cat > /tmp/fix.sql << 'EOF'
UPDATE "Customer" SET status = 'Active' WHERE code = 'KH-001';
UPDATE "Project" SET progress = 50 WHERE code = 'DA-001';
EOF
# Execute it
docker exec -i motnha-postgres psql -U postgres -d motnhaerp < /tmp/fix.sqlAll table/column names use PascalCase (Prisma convention). Must use double quotes in SQL.
Main tables:
| Table | Description | Has Soft Delete |
|---|---|---|
"User" |
Login accounts & roles | - |
"Customer" |
Customers/leads | Yes |
"Project" |
Construction/furniture projects | Yes |
"Product" |
Product catalog | Yes |
"Quotation" |
Price quotations | Yes |
"QuotationCategory" |
Quotation sections | - |
"QuotationItem" |
Quotation line items | - |
"Contract" |
Contracts | Yes |
"ContractPayment" |
Payment phases per contract | - |
"WorkOrder" |
Work orders/tasks | Yes |
"Employee" |
Staff | Yes |
"Department" |
Departments | - |
"Contractor" |
Subcontractors | Yes |
"ContractorPayment" |
Contractor payments | - |
"Supplier" |
Material suppliers | Yes |
"Transaction" |
Revenue/expense records | - |
"Warehouse" |
Warehouses | - |
"InventoryTransaction" |
Stock in/out | - |
"PurchaseOrder" |
Purchase orders | - |
"PurchaseOrderItem" |
PO line items | - |
"MaterialPlan" |
Material requirements | - |
"ProjectMilestone" |
Project milestones | - |
"ProjectBudget" |
Budget tracking | - |
"ProjectExpense" |
Project expenses | Yes |
"ProjectDocument" |
Attached documents | - |
"ProjectEmployee" |
Staff assignments | - |
"TrackingLog" |
CRM activity logs | - |
"WorkItemLibrary" |
Work item templates | - |
"QuotationTemplate" |
Quotation templates | - |
"QuotationTemplateCategory" |
Template sections | - |
"QuotationTemplateItem" |
Template line items | - |
Soft Delete = "Yes" means: records are NOT actually deleted. The app sets "deletedAt" timestamp instead. When modifying data manually, follow the same pattern: UPDATE "Table" SET "deletedAt" = NOW() WHERE ... instead of DELETE.
If you change prisma/schema.prisma and push to main, the CI/CD pipeline automatically runs prisma db push which applies schema changes to production. No manual action needed.
If you need to manually sync schema:
# On the server
cd ~/actions-runner/_work/motnha/motnha/motnhaerp
source .env
export DATABASE_URL="postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/motnhaerp?schema=public"
npx prisma db push# Backup (creates a SQL dump file)
docker exec motnha-postgres pg_dump -U postgres motnhaerp > ~/backup_$(date +%Y%m%d_%H%M%S).sql
# Restore from backup (WARNING: overwrites all data)
docker exec -i motnha-postgres psql -U postgres -d motnhaerp < ~/backup_20260228_120000.sqlOnly use this to reset to sample data. All existing data will be lost.
cd ~/actions-runner/_work/motnha/motnha/motnhaerp
source .env
export DATABASE_URL="postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/motnhaerp?schema=public"
node prisma/seed.js# App logs (Next.js)
docker logs motnha-app --tail 100 -f
# Database logs
docker logs motnha-postgres --tail 50 -f
# Restart app (after manual config changes)
docker restart motnha-appEdit prisma/schema.prisma:
model Supplier {
id String @id @default(cuid())
code String @unique
name String
phone String?
// ...fields
deletedAt DateTime? // For soft delete
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Then: npm run db:migrate
Create lib/validations/supplier.js:
import { z } from 'zod';
import { optStr } from './common';
export const supplierCreateSchema = z.object({
name: z.string().trim().min(1, 'Ten NCC bat buoc'),
phone: optStr,
// ...
}).strict();
export const supplierUpdateSchema = supplierCreateSchema.partial();Create app/api/suppliers/route.js:
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/apiHandler';
import prisma from '@/lib/prisma';
import { parsePagination, paginatedResponse } from '@/lib/pagination';
import { supplierCreateSchema } from '@/lib/validations/supplier';
export const GET = withAuth(async (request, context, session) => {
const { searchParams } = new URL(request.url);
const { page, limit, skip } = parsePagination(searchParams);
const search = searchParams.get('search') || '';
const where = search
? { name: { contains: search, mode: 'insensitive' } }
: {};
const [data, total] = await Promise.all([
prisma.supplier.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }),
prisma.supplier.count({ where }),
]);
return NextResponse.json(paginatedResponse(data, total, { page, limit }));
});
export const POST = withAuth(async (request, context, session) => {
const body = await request.json();
const validated = supplierCreateSchema.parse(body);
const supplier = await prisma.supplier.create({ data: validated });
return NextResponse.json(supplier, { status: 201 });
});Create app/api/suppliers/[id]/route.js for GET/PUT/DELETE by ID.
Create app/suppliers/page.js - use existing pages as reference (e.g., app/customers/page.js).
Edit components/Sidebar.js - add menu item to menuItems array.
Edit components/Header.js - add to pageTitles object.
| Issue | Solution |
|---|---|
npx prisma installs wrong version |
Always npm install first, never bare npx prisma |
Prisma $use not found |
Use $extends (Prisma 6 removed $use) |
useSearchParams() SSR crash |
Wrap component in <Suspense> |
| Paginated response undefined | Access res.data not res directly |
| Soft delete not filtering | Import prisma from @/lib/prisma (has $extends) |
| Zod rejects valid data | Check for unknown fields (.strict() mode) |
| Windows path errors in bash | Use forward slashes: cd C:/Users/... |
| Auth returns null in API | Ensure withAuth() wraps handler |
| Toast not showing | Ensure component is inside <Providers> tree |
This section is for AI assistants (Claude Code, Cursor, Copilot, etc.) working on this codebase.
- Read the relevant existing code before making changes
- Follow existing patterns - don't invent new ones
- Check
lib/validations/for schema patterns - Check existing pages for UI patterns (e.g.,
app/customers/page.js)
- API routes: Always use
withAuth()wrapper - Validation: Always use Zod schema with
.strict() - Fetching: Always use
apiFetch()fromlib/fetchClient.js - Notifications: Use
showToast(), neveralert()orwindow.confirm() - Pagination: Use
parsePagination()+paginatedResponse() - Prisma import: Always from
@/lib/prisma(has soft delete extension) - Search: Use
{ contains: search, mode: 'insensitive' }for PostgreSQL - Cascade deletes: Wrap in
prisma.$transaction(async (tx) => {...}) - Code generation: Use
generateCode(model, field, prefix) - Styling: Add CSS to
app/globals.css, use existing CSS variables
npm run build # Verify no build errors
npm test # Run tests
npm run type-check # TypeScript validationgit add <specific-files>
git commit -m "Description of changes"
git push origin main # Auto-deploys to server-
prisma/schema.prisma- Model (if new entity) -
lib/validations/<entity>.js- Zod schema -
app/api/<entity>/route.js- API (GET, POST) -
app/api/<entity>/[id]/route.js- API (GET, PUT, DELETE) -
app/<entity>/page.js- Frontend page -
components/Sidebar.js- Menu item -
components/Header.js- Page title -
app/globals.css- Styles (if needed) -
npm run db:migrate- Migration (if schema changed)