` (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
```typescript
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**:
1. Fetch data from API routes
2. Manage filter/pagination state
3. Orchestrate modals
4. Pass data to display components
**Structure**:
```typescript
'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 (
<>
>
)
}
```
---
### Modal Components
#### Add/Edit Modal Pattern
```typescript
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 (
)
}
```
---
#### View Modal Pattern
```typescript
export default function ViewEntityModal({ open, onClose, entityId }: ModalProps) {
const [entity, setEntity] = useState
(null)
useEffect(() => {
if (open && entityId) {
fetch(`/api/entities/${entityId}`)
.then(res => res.json())
.then(setEntity)
}
}, [open, entityId])
if (!open || !entity) return null
return (
{/* Read-only display */}
)
}
```
---
### Table/Display Components
**Props Pattern**:
```typescript
interface TableProps {
items: Item[]
onView: (id: string) => void
onEdit: (id: string) => void
onDelete: (id: string) => void
}
```
**Responsive Pattern**:
```tsx
{/* Desktop: Table */}
{/* Mobile: Cards */}
{items.map(item => (
{/* card content */}
))}
```
---
## API Design & Data Flow
### API Route Structure
**Location**: `/app/api/[resource]/route.ts`
**Pattern**: Export HTTP method handlers
```typescript
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`)
```typescript
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`**:
```typescript
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**:
```typescript
// List
{ items: [...], total: 100, page: 1 }
// Single item
{ _id: '...', name: '...', ... }
// Create
{ insertedId: '...' }
// Update
{ modifiedCount: 1 }
// Delete
{ success: true }
```
**Error Responses**:
```typescript
{ 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`
```typescript
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 {
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`
```typescript
'use client'
export default function OrdersPage() {
const [orders, setOrders] = useState([])
// 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`
```typescript
Orders
```
---
### 2. Adding Filters to Existing Lists
**Example**: Add date range filter to ingredients
**Step 1**: Add state to page component
```typescript
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
```
**Step 2**: Include in fetch params
```typescript
const params = new URLSearchParams({
page: currentPage.toString(),
dateFrom,
dateTo
})
```
**Step 3**: Update API route to handle new params
```typescript
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
```tsx
setDateFrom(e.target.value)}
/>
```
---
### 3. Adding Computed Fields to Forms
**Example**: Add "total price" calculation to ingredients
**Step 1**: Add derived state with `useMemo`
```typescript
const totalPrice = useMemo(() => {
return quantity * unitPrice * (1 + vat / 100)
}, [quantity, unitPrice, vat])
```
**Step 2**: Display in form
```tsx
Total: €{totalPrice.toFixed(2)}
```
**Step 3**: Optional - Save to database
```typescript
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
```typescript
const [showInlineCategory, setShowInlineCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
```
**Step 2**: Conditional rendering
```tsx
{showInlineCategory ? (
setNewCategoryName(e.target.value)}
placeholder="New category name"
/>
) : (
<>
>
)}
```
**Step 3**: Handle creation
```typescript
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
```typescript
const [searchTerm, setSearchTerm] = useState('')
```
**Step 2**: Debounced API call
```typescript
useEffect(() => {
const timer = setTimeout(() => {
fetchData() // Includes searchTerm in API call
}, 300)
return () => clearTimeout(timer)
}, [searchTerm])
```
**Step 3**: API route with case-insensitive search
```typescript
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**:
```typescript
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const itemsPerPage = 10
```
**API call**:
```typescript
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**:
```typescript
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**:
```tsx
Page {currentPage} of {totalPages}
```
---
## 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 `WithRefs` pattern 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**:
- [ ] `useState` for component state
- [ ] `useEffect` for data fetching
- [ ] `useCallback` for memoized functions
- [ ] `useMemo` for 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 `fetch` and use `within(container)` queries
- [ ] Cover validation errors, edge cases, and error paths
- [ ] Call `cleanup()` in `afterEach` for component tests
- [ ] Run `npx vitest run` before committing to verify all tests pass
---
## Common Patterns Reference
### Auto-Generated Codes
```typescript
// 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
```typescript
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
```typescript
{loading ? (
Loading...
) : items.length === 0 ? (
No items found
) : (
)}
```
---
**Last Updated**: 2026-02-12
**Version**: 1.0