| __tests__ | ||
| .claude | ||
| app | ||
| components | ||
| lib | ||
| scripts | ||
| .gitignore | ||
| docker-compose.yml | ||
| eslint.config.mjs | ||
| next.config.ts | ||
| package-lock.json | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
| vitest.setup.ts | ||
Icostpro - Developer Guide
Purpose: This README helps developers understand where to find code and how to write new features that align with the existing architecture.
Table of Contents
- Project Overview
- Folder Structure
- Testing
- Coding Patterns & Conventions
- Component Architecture
- API Design & Data Flow
- Adding New Features
Project Overview
Tech Stack: Next.js 16 (App Router) + React 19 + TypeScript 5 + MongoDB 7 + Tailwind CSS 4
Application Type: Full-stack restaurant inventory management system for tracking ingredients, pricing, suppliers, and categories.
Key Patterns:
- Client components for interactive pages (
'use client') - Server-side API routes for data operations
- MongoDB with aggregation pipelines for joins
- Modal-based workflows for CRUD operations
- Responsive design (mobile-first with Tailwind)
Folder Structure
/app - Next.js App Router
app/
├── api/ # API Routes (server-side only)
│ ├── categories/route.ts # GET, POST for categories
│ ├── subcategories/route.ts # GET, POST with category filtering
│ ├── suppliers/route.ts # GET, POST for suppliers
│ └── ingredients/
│ ├── route.ts # GET (list with filters), POST
│ └── [id]/route.ts # GET, PUT, DELETE by ID
├── dashboard/
│ └── ingredients/
│ └── page.tsx # Main ingredients page (client component)
├── layout.tsx # Root layout with metadata
├── page.tsx # Home page (redirects to /dashboard/ingredients)
└── globals.css # Tailwind imports + theme CSS variables
Where to add new pages: Create folders under /app/dashboard/ (e.g., /app/dashboard/reports/page.tsx)
Where to add new API routes: Create folders under /app/api/ (e.g., /app/api/orders/route.ts)
/components - Reusable React Components
components/
├── AddIngredientModal.tsx # Add/Edit modal with complex form
├── ViewIngredientModal.tsx # Read-only detail modal
├── DeleteConfirmModal.tsx # Confirmation dialog
├── IngredientTable.tsx # Responsive table/card display
└── Sidebar.tsx # Navigation sidebar
Component Types:
- Modals: Components ending in
Modal.tsx(handle open/close state via props) - Data Display: Components like
IngredientTable.tsx(receive data via props) - Layout: Components like
Sidebar.tsx(navigation, structure)
Where to add new components:
- Global
/components/: ONLY for components reused across multiple pages (e.g.,Sidebar.tsx,DeleteConfirmModal.tsx) - Page-specific components: Keep inside the page's own folder (e.g.,
/app/dashboard/orders/OrderSummaryCard.tsx)
⚠️ Important Rule: If a component is only used by ONE specific page, it should live in that page's folder, NOT in the global /components/ directory. This keeps the codebase organized and prevents component bloat.
Naming convention: PascalCase, suffix with type (e.g., UserModal.tsx, OrderTable.tsx)
Example Structure:
app/dashboard/orders/
├── page.tsx # Main orders page
├── OrderSummaryCard.tsx # Page-specific component
└── OrderFilters.tsx # Page-specific component
/lib - Utilities & Shared Logic
lib/
├── mongodb.ts # MongoDB connection pool (singleton pattern)
└── types.ts # TypeScript interfaces for all entities
Where to add:
- Database connections: Add to or extend
mongodb.ts - Type definitions: Add to
types.ts(interfaces for entities, API responses, props) - Utility functions: Create new files (e.g.,
lib/formatting.ts,lib/validation.ts)
Testing
Stack: Vitest + React Testing Library + jsdom
Running Tests
npm test # Run in watch mode
npx vitest run # Run all tests once
npx vitest run --reporter=verbose # Detailed output
npx vitest run __tests__/api # Run only API tests
npx vitest run __tests__/components # Run only component tests
Test Structure
__tests__/
├── api/ # API route tests (mock MongoDB)
│ ├── categories.test.ts
│ ├── subcategories.test.ts
│ ├── suppliers.test.ts
│ ├── ingredients.test.ts
│ └── ingredients-id.test.ts
└── components/ # Component tests (mock fetch)
├── DeleteConfirmModal.test.tsx
├── IngredientTable.test.tsx
├── ViewIngredientModal.test.tsx
└── AddIngredientModal.test.tsx
Writing API Route Tests
API tests mock getDb() from @/lib/mongodb to return a fake db object with chainable collection methods. No real database connection is needed.
const mockToArray = vi.fn();
const mockSort = vi.fn(() => ({ toArray: mockToArray }));
const mockFind = vi.fn(() => ({ sort: mockSort }));
const mockInsertOne = vi.fn();
const mockCollection = vi.fn(() => ({
find: mockFind,
insertOne: mockInsertOne,
}));
const mockDb = { collection: mockCollection };
vi.mock('@/lib/mongodb', () => ({
getDb: vi.fn(() => Promise.resolve(mockDb)),
}));
Then import and call the route handler directly:
import { GET, POST } from '@/app/api/categories/route';
it('returns sorted categories', async () => {
mockToArray.mockResolvedValue([{ _id: 'id1', name: 'Dairy' }]);
const response = await GET();
const data = await response.json();
expect(data).toEqual([{ _id: 'id1', name: 'Dairy' }]);
});
Writing Component Tests
Component tests mock fetch globally and use within(container) for scoped queries (required for React 19 compatibility). Always call cleanup() in afterEach.
import { render, fireEvent, waitFor, cleanup, within } from '@testing-library/react';
afterEach(() => {
cleanup();
});
it('loads data on open', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
json: () => Promise.resolve(mockData),
} as Response);
const { container } = render(<MyModal open={true} onClose={vi.fn()} />);
await waitFor(() => {
expect(within(container).getByText('Expected Text')).toBeInTheDocument();
});
});
Config Files
vitest.config.ts— Vitest config with React plugin, jsdom environment,@/path aliasvitest.setup.ts— Imports@testing-library/jest-dom/vitestfor DOM matchers (.toBeInTheDocument(),.toBeDisabled(), etc.)
Adding Tests for New Features
When adding a new entity or component, create a corresponding test file:
- New API route →
__tests__/api/[resource].test.ts— MockgetDb(), test each HTTP method, cover validation and edge cases - New component →
__tests__/components/[Component].test.tsx— Mockfetch, test render/hide, user interactions, loading states, and error handling
Coding Patterns & Conventions
TypeScript Interfaces
Location: /lib/types.ts
All entity interfaces are defined here. Key patterns:
export interface Ingredient {
_id?: string; // Optional for new documents
code: string; // Required fields
name: string;
// ... all fields
createdAt?: Date; // Auto-generated by MongoDB
updatedAt?: Date;
}
export interface IngredientWithRefs {
_id: string;
code: string;
category?: string; // Populated name (not ID)
subcategory?: string;
supplier?: string;
// ... rest of fields
}
Convention:
- Base interface for database schema (
Ingredient) WithRefsvariant for aggregated/joined data (IngredientWithRefs)
Component Patterns
Client Components
All pages and interactive components use 'use client' directive:
'use client'
import { useState, useEffect } from 'react'
export default function MyPage() {
const [data, setData] = useState([])
// Component logic
}
State Management
- Local state: Use
useStatefor component-specific data - Derived state: Use
useMemofor computed values - Side effects: Use
useEffectfor data fetching - Callbacks: Use
useCallbackto memoize functions passed as props
Example from page.tsx:
const [ingredients, setIngredients] = useState<IngredientWithRefs[]>([])
const [loading, setLoading] = useState(true)
const fetchIngredients = useCallback(async () => {
setLoading(true)
try {
const response = await fetch(`/api/ingredients?${params}`)
const data = await response.json()
setIngredients(data.ingredients)
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, categoryFilter, subcategoryFilter])
useEffect(() => {
fetchIngredients()
}, [fetchIngredients])
Modal Patterns
All modals follow this prop interface:
interface ModalProps {
open: boolean; // Controls visibility
onClose: () => void; // Called when modal closes
onSaved?: () => void; // Called after successful save
ingredientId?: string; // For edit mode (optional)
}
Usage:
- Parent component manages
openstate - Pass
onSavedto refresh data after changes - Include
ingredientIdfor edit mode
Example:
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | undefined>()
<AddIngredientModal
open={modalOpen}
onClose={() => {
setModalOpen(false)
setEditingId(undefined)
}}
onSaved={() => {
fetchIngredients() // Refresh data
setModalOpen(false)
}}
ingredientId={editingId}
/>
Styling Conventions
Tailwind CSS with Reusable Classes:
const inputClass = "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
const labelClass = "block text-sm font-medium text-gray-700 mb-1"
const selectClass = "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
Responsive Design:
- Mobile-first: Default styles are for mobile
- Breakpoints:
md:,lg:,xl:for larger screens - Example:
<div className="block md:hidden">(show only on mobile)
Color Palette:
- Primary:
blue-500(#2563eb) - Success:
green-500 - Danger:
red-500 - Backgrounds:
gray-50,gray-100 - Text:
gray-700,gray-900
Form Validation
Pattern: Client-side validation before API calls
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validation
if (!name.trim()) {
alert('Name is required')
return
}
if (quantity <= 0) {
alert('Quantity must be greater than 0')
return
}
// API call
const response = await fetch('/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (response.ok) {
onSaved?.()
}
}
Component Architecture
Page Components (/app/dashboard/*/page.tsx)
Responsibilities:
- Fetch data from API routes
- Manage filter/pagination state
- Orchestrate modals
- Pass data to display components
Structure:
'use client'
export default function IngredientsPage() {
// State
const [data, setData] = useState([])
const [filters, setFilters] = useState({})
const [modals, setModals] = useState({ add: false, view: false })
// Data fetching
useEffect(() => { /* fetch */ }, [filters])
// Render
return (
<>
<FilterBar />
<DataTable data={data} />
<Modals />
</>
)
}
Modal Components
Add/Edit Modal Pattern
export default function AddEntityModal({ open, onClose, onSaved, entityId }: ModalProps) {
const [formData, setFormData] = useState({ /* fields */ })
const isEditMode = !!entityId
useEffect(() => {
if (isEditMode && open) {
// Fetch existing data
fetch(`/api/entities/${entityId}`).then(/* populate form */)
} else {
// Reset form
setFormData({ /* defaults */ })
}
}, [entityId, open])
const handleSubmit = async () => {
const method = isEditMode ? 'PUT' : 'POST'
const url = isEditMode ? `/api/entities/${entityId}` : '/api/entities'
const response = await fetch(url, {
method,
body: JSON.stringify(formData)
})
if (response.ok) {
onSaved?.()
}
}
if (!open) return null
return (
<div className="modal-overlay">
<form onSubmit={handleSubmit}>
{/* Form fields */}
</form>
</div>
)
}
View Modal Pattern
export default function ViewEntityModal({ open, onClose, entityId }: ModalProps) {
const [entity, setEntity] = useState<Entity | null>(null)
useEffect(() => {
if (open && entityId) {
fetch(`/api/entities/${entityId}`)
.then(res => res.json())
.then(setEntity)
}
}, [open, entityId])
if (!open || !entity) return null
return (
<div className="modal-overlay">
{/* Read-only display */}
</div>
)
}
Table/Display Components
Props Pattern:
interface TableProps {
items: Item[]
onView: (id: string) => void
onEdit: (id: string) => void
onDelete: (id: string) => void
}
Responsive Pattern:
<div>
{/* Desktop: Table */}
<div className="hidden md:block">
<table>{/* table rows */}</table>
</div>
{/* Mobile: Cards */}
<div className="md:hidden space-y-4">
{items.map(item => (
<div key={item._id} className="card">
{/* card content */}
</div>
))}
</div>
</div>
API Design & Data Flow
API Route Structure
Location: /app/api/[resource]/route.ts
Pattern: Export HTTP method handlers
import { NextRequest, NextResponse } from 'next/server'
import clientPromise from '@/lib/mongodb'
// GET /api/entities
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = parseInt(searchParams.get('page') || '1')
const client = await clientPromise
const db = client.db('icostpro')
const items = await db.collection('entities')
.find({})
.skip((page - 1) * 10)
.limit(10)
.toArray()
return NextResponse.json({ items })
}
// POST /api/entities
export async function POST(request: NextRequest) {
const body = await request.json()
const client = await clientPromise
const db = client.db('icostpro')
const result = await db.collection('entities').insertOne({
...body,
createdAt: new Date(),
updatedAt: new Date()
})
return NextResponse.json({ insertedId: result.insertedId }, { status: 201 })
}
Dynamic Route Pattern (/api/[resource]/[id]/route.ts)
interface RouteParams {
params: Promise<{ id: string }>
}
// GET /api/entities/:id
export async function GET(
request: NextRequest,
{ params }: RouteParams
) {
const { id } = await params
const client = await clientPromise
const db = client.db('icostpro')
const item = await db.collection('entities')
.findOne({ _id: new ObjectId(id) })
if (!item) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(item)
}
// PUT /api/entities/:id
export async function PUT(
request: NextRequest,
{ params }: RouteParams
) {
const { id } = await params
const body = await request.json()
const client = await clientPromise
const db = client.db('icostpro')
const result = await db.collection('entities').updateOne(
{ _id: new ObjectId(id) },
{ $set: { ...body, updatedAt: new Date() } }
)
return NextResponse.json({ modifiedCount: result.modifiedCount })
}
// DELETE /api/entities/:id
export async function DELETE(
request: NextRequest,
{ params }: RouteParams
) {
const { id } = await params
const client = await clientPromise
const db = client.db('icostpro')
await db.collection('entities').deleteOne({ _id: new ObjectId(id) })
return NextResponse.json({ success: true })
}
MongoDB Aggregation Pattern
Use Case: Joining referenced data (e.g., populate category names)
Pattern from /api/ingredients/[id]/route.ts:
const pipeline = [
{ $match: { _id: new ObjectId(id) } },
{
$lookup: {
from: 'categories',
localField: 'categoryId',
foreignField: '_id',
as: 'categoryData'
}
},
{
$lookup: {
from: 'subcategories',
localField: 'subcategoryId',
foreignField: '_id',
as: 'subcategoryData'
}
},
{
$lookup: {
from: 'suppliers',
localField: 'supplierId',
foreignField: '_id',
as: 'supplierData'
}
},
{
$project: {
_id: 1,
code: 1,
name: 1,
category: { $arrayElemAt: ['$categoryData.name', 0] },
subcategory: { $arrayElemAt: ['$subcategoryData.name', 0] },
supplier: { $arrayElemAt: ['$supplierData.name', 0] },
categoryId: 1,
subcategoryId: 1,
supplierId: 1,
quantity: 1,
// ... rest of fields
}
}
]
const result = await db.collection('ingredients').aggregate(pipeline).toArray()
const ingredient = result[0] as IngredientWithRefs
When to use aggregation:
- When you need to display related entity names (not just IDs)
- For GET by ID endpoints that show full details
- For list endpoints where joins improve UX
When NOT to use aggregation:
- POST/PUT/DELETE operations (use IDs only)
- Simple list endpoints where IDs are sufficient
API Response Patterns
Success Responses:
// List
{ items: [...], total: 100, page: 1 }
// Single item
{ _id: '...', name: '...', ... }
// Create
{ insertedId: '...' }
// Update
{ modifiedCount: 1 }
// Delete
{ success: true }
Error Responses:
{ error: 'Not found' } // 404
{ error: 'Invalid request' } // 400
{ error: 'Internal server error' } // 500
Data Flow Diagram
User Action
↓
[Page Component] ← useState/useEffect
↓
API Call (fetch)
↓
[API Route Handler] (/app/api/*/route.ts)
↓
MongoDB Query/Aggregation
↓
[Response] NextResponse.json()
↓
[Page Component] setData(response)
↓
[Display Component] receives data via props
↓
UI Update
Adding New Features
1. Adding a New Entity (e.g., "Orders")
Step 1: Define TypeScript interface in /lib/types.ts
export interface Order {
_id?: string
orderNumber: string
supplierId: string
items: OrderItem[]
totalAmount: number
status: 'pending' | 'received' | 'cancelled'
createdAt?: Date
updatedAt?: Date
}
export interface OrderWithRefs extends Omit<Order, 'supplierId'> {
supplier?: string
}
Step 2: Create API routes in /app/api/orders/
/app/api/orders/route.ts(GET list, POST)/app/api/orders/[id]/route.ts(GET, PUT, DELETE)
Step 3: Create page component in /app/dashboard/orders/page.tsx
'use client'
export default function OrdersPage() {
const [orders, setOrders] = useState<OrderWithRefs[]>([])
// Fetch, filter, pagination logic
}
Step 4: Create page-specific components in /app/dashboard/orders/
AddOrderModal.tsx(orders-specific modal)ViewOrderModal.tsx(orders-specific modal)OrderTable.tsx(responsive table/card view)- Note: Only move to
/components/if these will be reused across multiple pages
Step 5: Reuse global components if applicable
DeleteConfirmModal.tsx(already exists in/components/- reusable across all pages)
Step 6: Add navigation link in Sidebar.tsx
<Link href="/dashboard/orders">Orders</Link>
2. Adding Filters to Existing Lists
Example: Add date range filter to ingredients
Step 1: Add state to page component
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
Step 2: Include in fetch params
const params = new URLSearchParams({
page: currentPage.toString(),
dateFrom,
dateTo
})
Step 3: Update API route to handle new params
const dateFrom = searchParams.get('dateFrom')
const dateTo = searchParams.get('dateTo')
const query: any = {}
if (dateFrom || dateTo) {
query.createdAt = {}
if (dateFrom) query.createdAt.$gte = new Date(dateFrom)
if (dateTo) query.createdAt.$lte = new Date(dateTo)
}
const items = await db.collection('ingredients').find(query)
Step 4: Add filter UI
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
/>
3. Adding Computed Fields to Forms
Example: Add "total price" calculation to ingredients
Step 1: Add derived state with useMemo
const totalPrice = useMemo(() => {
return quantity * unitPrice * (1 + vat / 100)
}, [quantity, unitPrice, vat])
Step 2: Display in form
<div className="bg-gray-50 p-3 rounded">
<span className="font-medium">Total: €{totalPrice.toFixed(2)}</span>
</div>
Step 3: Optional - Save to database
const handleSubmit = async () => {
const data = {
quantity,
unitPrice,
vat,
totalPrice // Include computed value
}
await fetch('/api/ingredients', { method: 'POST', body: JSON.stringify(data) })
}
4. Adding Inline Creation to Dropdowns
Pattern from AddIngredientModal.tsx:
Step 1: Add state for inline form
const [showInlineCategory, setShowInlineCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
Step 2: Conditional rendering
{showInlineCategory ? (
<div className="flex gap-2">
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder="New category name"
/>
<button onClick={handleCreateCategory}>Save</button>
<button onClick={() => setShowInlineCategory(false)}>Cancel</button>
</div>
) : (
<>
<select value={selectedCategory} onChange={e => setSelectedCategory(e.target.value)}>
{categories.map(cat => <option key={cat._id} value={cat._id}>{cat.name}</option>)}
</select>
<button onClick={() => setShowInlineCategory(true)}>+ New</button>
</>
)}
Step 3: Handle creation
const handleCreateCategory = async () => {
const response = await fetch('/api/categories', {
method: 'POST',
body: JSON.stringify({ name: newCategoryName })
})
const data = await response.json()
setCategories([...categories, { _id: data.insertedId, name: newCategoryName }])
setSelectedCategory(data.insertedId)
setShowInlineCategory(false)
}
5. Adding Search Functionality
Step 1: Add search state
const [searchTerm, setSearchTerm] = useState('')
Step 2: Debounced API call
useEffect(() => {
const timer = setTimeout(() => {
fetchData() // Includes searchTerm in API call
}, 300)
return () => clearTimeout(timer)
}, [searchTerm])
Step 3: API route with case-insensitive search
const searchTerm = searchParams.get('search')
const query: any = {}
if (searchTerm) {
query.$or = [
{ name: { $regex: searchTerm, $options: 'i' } },
{ code: { $regex: searchTerm, $options: 'i' } }
]
}
const items = await db.collection('entities').find(query)
6. Adding Pagination
Pattern (already implemented in ingredients):
State:
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const itemsPerPage = 10
API call:
const params = new URLSearchParams({
page: currentPage.toString(),
limit: itemsPerPage.toString()
})
const response = await fetch(`/api/entities?${params}`)
const data = await response.json()
setTotalPages(Math.ceil(data.total / itemsPerPage))
API route:
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const items = await db.collection('entities')
.find(query)
.skip((page - 1) * limit)
.limit(limit)
.toArray()
const total = await db.collection('entities').countDocuments(query)
return NextResponse.json({ items, total })
UI:
<div className="flex gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage(p => p - 1)}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
disabled={currentPage === totalPages}
onClick={() => setCurrentPage(p => p + 1)}
>
Next
</button>
</div>
Best Practices Checklist
When adding new code, ensure you follow these practices:
TypeScript:
- All interfaces defined in
/lib/types.ts - Proper typing for props, state, and API responses
- Use
WithRefspattern for aggregated data
Components:
- Use
'use client'for interactive components - Modal components follow standard prop interface
- Responsive design with Tailwind breakpoints
- Reusable class variables for consistent styling
API Routes:
- Proper HTTP methods (GET, POST, PUT, DELETE)
- Error handling with appropriate status codes
- Timestamps (
createdAt,updatedAt) on write operations - Use aggregation for joins when displaying related data
Data Flow:
useStatefor component stateuseEffectfor data fetchinguseCallbackfor memoized functionsuseMemofor derived/computed values
Forms:
- Client-side validation before API calls
- Loading states during submissions
- Success/error feedback to users
- Reset form after successful submission
Code Organization:
- Page components orchestrate, display components render
- API routes in
/app/api/[resource]/ - Shared components in
/components/(only if reused across multiple pages) - Page-specific components stay in the page's folder, NOT in global
/components/ - Utilities/types in
/lib/
Testing:
- API route tests mock
getDb()and test each HTTP method - Component tests mock
fetchand usewithin(container)queries - Cover validation errors, edge cases, and error paths
- Call
cleanup()inafterEachfor component tests - Run
npx vitest runbefore committing to verify all tests pass
Common Patterns Reference
Auto-Generated Codes
// Get next code number
const lastIngredient = await db.collection('ingredients')
.find({})
.sort({ code: -1 })
.limit(1)
.toArray()
const nextNumber = lastIngredient.length > 0
? parseInt(lastIngredient[0].code.split('-')[1]) + 1
: 1
const code = `ING-${String(nextNumber).padStart(3, '0')}`
Price Calculations
const netPrice = grossPrice * (1 + vat / 100)
// With discount
const effectiveGross = applyDiscountToGross
? grossPrice - discount
: grossPrice
const effectiveNet = applyDiscountToGross
? effectiveGross * (1 + vat / 100)
: netPrice - discount
Conditional Rendering
{loading ? (
<div>Loading...</div>
) : items.length === 0 ? (
<div>No items found</div>
) : (
<Table items={items} />
)}
Last Updated: 2026-02-12 Version: 1.0