988 lines
23 KiB
Markdown
988 lines
23 KiB
Markdown
# 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
|
|
1. [Project Overview](#project-overview)
|
|
2. [Folder Structure](#folder-structure)
|
|
3. [Coding Patterns & Conventions](#coding-patterns--conventions)
|
|
4. [Component Architecture](#component-architecture)
|
|
5. [API Design & Data Flow](#api-design--data-flow)
|
|
6. [Adding New Features](#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**: Add to `/components/` with descriptive names
|
|
**Naming convention**: PascalCase, suffix with type (e.g., `UserModal.tsx`, `OrderTable.tsx`)
|
|
|
|
---
|
|
|
|
### `/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`)
|
|
|
|
---
|
|
|
|
## Coding Patterns & Conventions
|
|
|
|
### TypeScript Interfaces
|
|
|
|
**Location**: `/lib/types.ts`
|
|
|
|
All entity interfaces are defined here. Key patterns:
|
|
```typescript
|
|
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`)
|
|
- `WithRefs` variant for aggregated/joined data (`IngredientWithRefs`)
|
|
|
|
---
|
|
|
|
### Component Patterns
|
|
|
|
#### Client Components
|
|
All pages and interactive components use `'use client'` directive:
|
|
```typescript
|
|
'use client'
|
|
import { useState, useEffect } from 'react'
|
|
|
|
export default function MyPage() {
|
|
const [data, setData] = useState([])
|
|
// Component logic
|
|
}
|
|
```
|
|
|
|
#### State Management
|
|
- **Local state**: Use `useState` for component-specific data
|
|
- **Derived state**: Use `useMemo` for computed values
|
|
- **Side effects**: Use `useEffect` for data fetching
|
|
- **Callbacks**: Use `useCallback` to memoize functions passed as props
|
|
|
|
Example from `page.tsx`:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
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**:
|
|
1. Parent component manages `open` state
|
|
2. Pass `onSaved` to refresh data after changes
|
|
3. Include `ingredientId` for edit mode
|
|
|
|
Example:
|
|
```typescript
|
|
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**:
|
|
```typescript
|
|
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
|
|
```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 (
|
|
<>
|
|
<FilterBar />
|
|
<DataTable data={data} />
|
|
<Modals />
|
|
</>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 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 (
|
|
<div className="modal-overlay">
|
|
<form onSubmit={handleSubmit}>
|
|
{/* Form fields */}
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
#### View Modal Pattern
|
|
```typescript
|
|
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**:
|
|
```typescript
|
|
interface TableProps {
|
|
items: Item[]
|
|
onView: (id: string) => void
|
|
onEdit: (id: string) => void
|
|
onDelete: (id: string) => void
|
|
}
|
|
```
|
|
|
|
**Responsive Pattern**:
|
|
```tsx
|
|
<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
|
|
```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<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`
|
|
```typescript
|
|
'use client'
|
|
export default function OrdersPage() {
|
|
const [orders, setOrders] = useState<OrderWithRefs[]>([])
|
|
// Fetch, filter, pagination logic
|
|
}
|
|
```
|
|
|
|
**Step 4**: Create modal components in `/components/`
|
|
- `AddOrderModal.tsx`
|
|
- `ViewOrderModal.tsx`
|
|
- `DeleteConfirmModal.tsx` (can reuse existing)
|
|
|
|
**Step 5**: Create display component
|
|
- `OrderTable.tsx` (responsive table/card view)
|
|
|
|
**Step 6**: Add navigation link in `Sidebar.tsx`
|
|
```typescript
|
|
<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
|
|
```typescript
|
|
const [dateFrom, setDateFrom] = useState<string>('')
|
|
const [dateTo, setDateTo] = useState<string>('')
|
|
```
|
|
|
|
**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
|
|
<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`
|
|
```typescript
|
|
const totalPrice = useMemo(() => {
|
|
return quantity * unitPrice * (1 + vat / 100)
|
|
}, [quantity, unitPrice, vat])
|
|
```
|
|
|
|
**Step 2**: Display in form
|
|
```tsx
|
|
<div className="bg-gray-50 p-3 rounded">
|
|
<span className="font-medium">Total: €{totalPrice.toFixed(2)}</span>
|
|
</div>
|
|
```
|
|
|
|
**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 ? (
|
|
<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
|
|
```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
|
|
<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 `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/`
|
|
- [ ] Utilities/types in `/lib/`
|
|
|
|
---
|
|
|
|
## 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 ? (
|
|
<div>Loading...</div>
|
|
) : items.length === 0 ? (
|
|
<div>No items found</div>
|
|
) : (
|
|
<Table items={items} />
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
**Last Updated**: 2026-02-12
|
|
**Version**: 1.0
|