Go to file
2026-02-12 21:40:57 +01:00
__tests__ Add tests 2026-02-12 21:40:57 +01:00
.claude connect with mongodb 2026-02-11 21:11:19 +01:00
app REFACTOR components 2026-02-12 21:13:15 +01:00
components REFACTOR components 2026-02-12 21:13:15 +01:00
lib rename products to ingredients 2026-02-11 21:25:53 +01:00
scripts rename products to ingredients 2026-02-11 21:25:53 +01:00
.gitignore first commit 2026-02-11 20:30:03 +01:00
docker-compose.yml connect with mongodb 2026-02-11 21:11:19 +01:00
eslint.config.mjs first commit 2026-02-11 20:30:03 +01:00
next.config.ts first commit 2026-02-11 20:30:03 +01:00
package-lock.json Add tests 2026-02-12 21:40:57 +01:00
package.json Add tests 2026-02-12 21:40:57 +01:00
postcss.config.mjs first commit 2026-02-11 20:30:03 +01:00
README.md Add tests 2026-02-12 21:40:57 +01:00
tsconfig.json first commit 2026-02-11 20:30:03 +01:00
vitest.config.ts Add tests 2026-02-12 21:40:57 +01:00
vitest.setup.ts Add tests 2026-02-12 21:40:57 +01:00

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
  2. Folder Structure
  3. Testing
  4. Coding Patterns & Conventions
  5. Component Architecture
  6. API Design & Data Flow
  7. 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 alias
  • vitest.setup.ts — Imports @testing-library/jest-dom/vitest for DOM matchers (.toBeInTheDocument(), .toBeDisabled(), etc.)

Adding Tests for New Features

When adding a new entity or component, create a corresponding test file:

  1. New API route__tests__/api/[resource].test.ts — Mock getDb(), test each HTTP method, cover validation and edge cases
  2. New component__tests__/components/[Component].test.tsx — Mock fetch, 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)
  • WithRefs variant 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 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:

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:

  1. Parent component manages open state
  2. Pass onSaved to refresh data after changes
  3. Include ingredientId for 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:

  1. Fetch data from API routes
  2. Manage filter/pagination state
  3. Orchestrate modals
  4. 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 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

// 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