Tutorial AI Integration LangChain dengan Next js Bikin Projek Kasir Digital Sederhana

Panduan lengkap bikin aplikasi kasir yang bisa "mikir" sendiri β€” dari smart search sampai AI assistant yang bantu kasir kerja lebih cepat.

Bagian 1: Pembuka β€” Kenapa Kasir Digital Butuh AI?

Halo teman-teman developer! πŸ‘‹

Jadi ceritanya beberapa minggu lalu, saya lagi ngopi di cafe langganan. Sambil nunggu pesanan, saya perhatiin kasirnya yang lagi struggle banget. Ada customer nanya "ada minuman yang manis tapi gak pake susu gak?", si kasir harus scroll-scroll menu di iPad-nya, cari satu-satu. Belum lagi antrian di belakang makin panjang.

Saya langsung kepikiran: "Kenapa gak bikin search-nya pake AI aja? Tinggal ketik 'minuman manis tanpa susu', langsung keluar hasilnya."

Dan dari situlah ide artikel ini muncul.

Masalah Kasir Tradisional

Kalau kita breakdown, ini pain points yang sering terjadi di sistem kasir konvensional:

1. Search yang Kaku Kasir ketik "kopi" yang muncul cuma yang namanya ada kata "kopi". Padahal customer kadang bilang "yang ada espresso-nya dong" atau "minuman item yang pahit". Sistem tradisional gak ngerti konteks.

2. Onboarding Staff Baru Lama Setiap kali ada kasir baru, butuh waktu berminggu-minggu buat hafal menu, harga, dan stok. Belum lagi kalau ada promo atau menu seasonal.

3. Owner Butuh Data tapi Gak Paham SQL Mau tau "produk apa yang paling laris minggu ini?" atau "jam berapa penjualan paling rame?", harus minta tolong tim IT atau buka spreadsheet yang ribet.

4. Response Time Lambat = Customer Kabur Di era serba cepat, customer gak mau nunggu lama. Kasir yang lambat cari produk = antrian panjang = customer pergi ke kompetitor.

Solusi: AI-Powered POS

Nah, di tutorial ini kita akan bikin sistem kasir yang punya "otak" sendiri. Bukan cuma input-output biasa, tapi beneran bisa mikir dan bantu kasir.

KASIR DIGITAL + AI = SUPERPOWER

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                              β”‚
β”‚   SEBELUM (Traditional POS)        SESUDAH (AI-Powered)     β”‚
β”‚   ─────────────────────────        ────────────────────     β”‚
β”‚                                                              β”‚
β”‚   Search: exact match only    β†’    Natural language search  β”‚
β”‚   "kopi" = hasil "kopi"            "minuman manis dingin"   β”‚
β”‚                                     = Es Teh, Matcha, dll   β”‚
β”‚                                                              β”‚
β”‚   Help: baca manual/tanya   β†’      AI Assistant 24/7        β”‚
β”‚         senior                     "stok apa yang mau       β”‚
β”‚                                     habis?" = instant answerβ”‚
β”‚                                                              β”‚
β”‚   Analytics: export Excel,  β†’      Tanya langsung:          β”‚
β”‚              pivot table           "penjualan tertinggi     β”‚
β”‚                                     bulan ini apa?"         β”‚
β”‚                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Apa yang Akan Kita Bangun?

Di akhir tutorial ini, teman-teman akan punya aplikasi kasir dengan 3 fitur AI utama:

FiturDeskripsiContoh
Smart SearchCari produk pakai bahasa natural"kopi yang gak terlalu manis" β†’ filtered results
AI AssistantChatbot yang paham konteks kasir"stok apa yang menipis?" β†’ list produk + jumlah
Natural Language AnalyticsTanya data pakai bahasa manusia"revenue minggu ini berapa?" β†’ angka + insight

Dan yang paling penting: semua ini akan kita build from scratch. Bukan pakai template jadi, tapi beneran nulis code-nya bareng-bareng. Jadi teman-teman paham konsepnya, bukan cuma copy-paste.

Tech Stack yang Kita Pakai

TECH STACK

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  FRONTEND                                                    β”‚
β”‚  β”œβ”€β”€ Next.js 15 (App Router)                                β”‚
β”‚  β”œβ”€β”€ React 19                                                β”‚
β”‚  β”œβ”€β”€ TypeScript (wajib untuk AI projects!)                  β”‚
β”‚  β”œβ”€β”€ Tailwind CSS                                            β”‚
β”‚  └── Shadcn/ui                                               β”‚
β”‚                                                              β”‚
β”‚  AI LAYER                                                    β”‚
β”‚  β”œβ”€β”€ LangChain.js (orchestration)                           β”‚
β”‚  β”œβ”€β”€ OpenAI GPT-4o-mini (LLM)                               β”‚
β”‚  └── Vercel AI SDK (streaming)                              β”‚
β”‚                                                              β”‚
β”‚  BACKEND & DATABASE                                          β”‚
β”‚  β”œβ”€β”€ Next.js API Routes                                      β”‚
β”‚  β”œβ”€β”€ Prisma ORM                                              β”‚
β”‚  └── SQLite (dev) / PostgreSQL (prod)                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Kenapa LangChain?

LangChain itu ibarat "framework-nya AI apps". Sama kayak Next.js bikin React development lebih terstruktur, LangChain bikin AI development lebih organized. Dia handle:

  • Prompt management
  • Tool calling (biar AI bisa akses database, API, dll)
  • Memory (biar AI inget konteks conversation)
  • Streaming responses

Tanpa LangChain, kita harus nulis boilerplate yang banyak banget. Dengan LangChain, kita bisa fokus ke business logic.

Prerequisites

Sebelum lanjut, pastikan teman-teman sudah:

  • βœ… Paham JavaScript/TypeScript dasar
  • βœ… Familiar dengan React dan Next.js
  • βœ… Punya Node.js 18+ di komputer
  • βœ… Punya OpenAI API key (free tier cukup untuk development)
  • βœ… Code editor (VS Code recommended)

Kalau belum punya OpenAI API key, daftar dulu di platform.openai.com. Free tier dapat $5 credit, cukup banget untuk tutorial ini.


πŸ’‘ Mini Tips #1

Mulai dengan fitur paling simple dulu β€” Smart Search. Kenapa? Karena impact-nya langsung kerasa. Kasir yang biasanya butuh 30 detik cari produk, bisa jadi 3 detik. Setelah itu baru expand ke chatbot dan analytics. Jangan langsung bikin semuanya sekaligus, nanti overwhelmed.


Bagian 2: Project Setup & Database Schema

Oke, sekarang kita mulai ngoding! πŸš€

2.1 Initialize Next.js Project

Buka terminal, jalankan command ini:

# Create Next.js project dengan semua best practices
npx create-next-app@latest kasir-ai --typescript --tailwind --eslint --app --src-dir

# Masuk ke folder project
cd kasir-ai

Waktu ditanya opsi-opsi, pilih:

  • Would you like to use TypeScript? β†’ Yes
  • Would you like to use ESLint? β†’ Yes
  • Would you like to use Tailwind CSS? β†’ Yes
  • Would you like to use src/ directory? β†’ Yes
  • Would you like to use App Router? β†’ Yes
  • Would you like to customize the default import alias? β†’ No

2.2 Install Dependencies

Sekarang install semua packages yang kita butuhkan:

# LangChain & AI packages
npm install @langchain/openai @langchain/core langchain ai

# Database
npm install prisma @prisma/client

# Utilities
npm install zod lucide-react

# Development
npm install -D @types/node

Penjelasan packages:

  • @langchain/openai β€” Integrasi LangChain dengan OpenAI
  • @langchain/core β€” Core utilities LangChain
  • langchain β€” Main LangChain library
  • ai β€” Vercel AI SDK untuk streaming
  • prisma & @prisma/client β€” ORM untuk database
  • zod β€” Schema validation (penting untuk AI outputs!)
  • lucide-react β€” Icon library

Sekarang setup Shadcn/ui untuk komponen UI:

# Initialize Shadcn
npx shadcn@latest init

# Pilih opsi default, atau sesuaikan dengan preference

Install komponen yang kita butuhkan:

npx shadcn@latest add button input card table dialog toast badge scroll-area select

2.3 Project Structure

Ini struktur folder yang akan kita gunakan. Saya sudah design supaya scalable dan maintainable:

kasir-ai/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app/
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   β”œβ”€β”€ chat/
β”‚   β”‚   β”‚   β”‚   └── route.ts         # AI Chat endpoint
β”‚   β”‚   β”‚   β”œβ”€β”€ search/
β”‚   β”‚   β”‚   β”‚   └── route.ts         # Smart search endpoint
β”‚   β”‚   β”‚   β”œβ”€β”€ analytics/
β”‚   β”‚   β”‚   β”‚   └── route.ts         # Analytics endpoint
β”‚   β”‚   β”‚   β”œβ”€β”€ products/
β”‚   β”‚   β”‚   β”‚   └── route.ts         # Product CRUD
β”‚   β”‚   β”‚   └── transactions/
β”‚   β”‚   β”‚       └── route.ts         # Transaction handling
β”‚   β”‚   β”‚
β”‚   β”‚   β”œβ”€β”€ kasir/
β”‚   β”‚   β”‚   └── page.tsx             # Main POS interface
β”‚   β”‚   β”œβ”€β”€ analytics/
β”‚   β”‚   β”‚   └── page.tsx             # AI Analytics dashboard
β”‚   β”‚   β”œβ”€β”€ layout.tsx
β”‚   β”‚   └── page.tsx                 # Landing/redirect
β”‚   β”‚
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”œβ”€β”€ pos/
β”‚   β”‚   β”‚   β”œβ”€β”€ ProductGrid.tsx      # Product display grid
β”‚   β”‚   β”‚   β”œβ”€β”€ Cart.tsx             # Shopping cart
β”‚   β”‚   β”‚   β”œβ”€β”€ SearchBar.tsx        # AI-powered search
β”‚   β”‚   β”‚   β”œβ”€β”€ AIAssistant.tsx      # Chat interface
β”‚   β”‚   β”‚   └── PaymentDialog.tsx    # Payment modal
β”‚   β”‚   └── ui/                      # Shadcn components
β”‚   β”‚
β”‚   β”œβ”€β”€ lib/
β”‚   β”‚   β”œβ”€β”€ ai/
β”‚   β”‚   β”‚   β”œβ”€β”€ langchain.ts         # LangChain configuration
β”‚   β”‚   β”‚   β”œβ”€β”€ tools.ts             # Custom AI tools
β”‚   β”‚   β”‚   └── prompts.ts           # System prompts
β”‚   β”‚   β”œβ”€β”€ db.ts                    # Prisma client
β”‚   β”‚   └── utils.ts                 # Helper functions
β”‚   β”‚
β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   └── useDebounce.ts           # Debounce hook for search
β”‚   β”‚
β”‚   └── types/
β”‚       └── index.ts                 # TypeScript types
β”‚
β”œβ”€β”€ prisma/
β”‚   β”œβ”€β”€ schema.prisma                # Database schema
β”‚   └── seed.ts                      # Seed data
β”‚
β”œβ”€β”€ .env.local                       # Environment variables
└── package.json

Filosofi struktur ini:

  • /app/api β€” Semua backend logic di sini, organized by feature
  • /components/pos β€” Komponen khusus untuk POS system
  • /lib/ai β€” Semua AI-related code terisolasi di sini
  • /types β€” TypeScript types terpusat

2.4 Setup Prisma & Database Schema

Initialize Prisma:

npx prisma init --datasource-provider sqlite

Sekarang buka file prisma/schema.prisma dan replace dengan schema berikut:

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

// ============================================
// CATEGORY
// ============================================
model Category {
  id          String    @id @default(cuid())
  name        String
  description String?
  icon        String?   // emoji atau icon name

  products    Product[]

  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

// ============================================
// PRODUCT
// ============================================
model Product {
  id          String   @id @default(cuid())
  name        String
  description String?  // Penting untuk AI search!
  price       Float
  cost        Float?   // Harga modal (optional)
  stock       Int      @default(0)
  minStock    Int      @default(5)  // Threshold warning
  sku         String   @unique
  barcode     String?
  imageUrl    String?
  isActive    Boolean  @default(true)

  // Relations
  categoryId  String
  category    Category @relation(fields: [categoryId], references: [id])

  transactionItems TransactionItem[]

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  // Indexes untuk performa search
  @@index([name])
  @@index([sku])
  @@index([categoryId])
  @@index([isActive])
}

// ============================================
// TRANSACTION
// ============================================
model Transaction {
  id            String   @id @default(cuid())
  invoiceNumber String   @unique  // INV-20241222-001

  // Amounts
  subtotal      Float
  discountAmount Float   @default(0)
  discountPercent Float  @default(0)
  taxAmount     Float    @default(0)
  taxPercent    Float    @default(10)  // Default 10% PPN
  total         Float

  // Payment
  paymentMethod String   // cash, card, qris, transfer
  amountPaid    Float
  changeAmount  Float    @default(0)

  // Status
  status        String   @default("completed") // completed, voided, pending
  notes         String?

  // Staff (optional, untuk tracking)
  cashierName   String?

  // Relations
  items         TransactionItem[]

  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  @@index([createdAt])
  @@index([status])
  @@index([paymentMethod])
  @@index([invoiceNumber])
}

// ============================================
// TRANSACTION ITEM
// ============================================
model TransactionItem {
  id            String   @id @default(cuid())

  quantity      Int
  unitPrice     Float    // Harga saat transaksi (bisa beda dari current price)
  subtotal      Float
  discountAmount Float   @default(0)

  // Snapshot product info (untuk historical record)
  productName   String
  productSku    String

  // Relations
  productId     String
  product       Product  @relation(fields: [productId], references: [id])

  transactionId String
  transaction   Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade)

  createdAt     DateTime @default(now())

  @@index([productId])
  @@index([transactionId])
}

Penjelasan design decisions:

  1. description di Product wajib diisi β€” Ini yang akan dibaca AI untuk smart search. Semakin deskriptif, semakin akurat search-nya.
  2. minStock untuk threshold β€” AI bisa alert kalau stok di bawah minimum.
  3. Snapshot di TransactionItem β€” Kita simpan productName dan productSku karena harga/nama produk bisa berubah, tapi historical record harus tetap akurat.
  4. Indexes yang tepat β€” Untuk performa query yang sering dipakai.

2.5 Environment Variables

Buat file .env.local di root project:

# .env.local

# Database
DATABASE_URL="file:./dev.db"

# OpenAI
OPENAI_API_KEY=sk-your-api-key-here

# Optional: LangSmith untuk debugging & monitoring
# Daftar gratis di smith.langchain.com
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your-langsmith-key
LANGCHAIN_PROJECT=kasir-ai

# App
NEXT_PUBLIC_APP_NAME="Kasir AI"

⚠️ PENTING: Jangan pernah commit .env.local ke git! Pastikan sudah ada di .gitignore.

2.6 Prisma Client Setup

Buat file untuk Prisma client:

// src/lib/db.ts

import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma = globalForPrisma.prisma ?? new PrismaClient()

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

Pattern ini mencegah multiple Prisma instances saat development (hot reload).

2.7 Seed Data

Sekarang kita buat data dummy yang realistis. Buat file prisma/seed.ts:

// prisma/seed.ts

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  console.log('🌱 Seeding database...')

  // Clean existing data
  await prisma.transactionItem.deleteMany()
  await prisma.transaction.deleteMany()
  await prisma.product.deleteMany()
  await prisma.category.deleteMany()

  // ============================================
  // CATEGORIES
  // ============================================
  const categories = await Promise.all([
    prisma.category.create({
      data: {
        name: 'Kopi',
        description: 'Berbagai jenis minuman kopi',
        icon: 'β˜•'
      }
    }),
    prisma.category.create({
      data: {
        name: 'Non-Kopi',
        description: 'Minuman tanpa kopi',
        icon: 'πŸ§‹'
      }
    }),
    prisma.category.create({
      data: {
        name: 'Makanan',
        description: 'Makanan ringan dan berat',
        icon: '🍽️'
      }
    }),
    prisma.category.create({
      data: {
        name: 'Snack',
        description: 'Cemilan dan dessert',
        icon: 'πŸͺ'
      }
    })
  ])

  const [kopi, nonKopi, makanan, snack] = categories

  // ============================================
  // PRODUCTS
  // ============================================
  const products = [
    // KOPI
    {
      name: 'Espresso',
      description: 'Kopi hitam pekat, strong, tanpa susu. Cocok untuk pecinta kopi murni.',
      price: 18000,
      cost: 5000,
      stock: 100,
      sku: 'KPI-001',
      categoryId: kopi.id
    },
    {
      name: 'Americano',
      description: 'Espresso dengan air panas. Rasa kopi yang lebih ringan tapi tetap bold. Tanpa susu.',
      price: 22000,
      cost: 5500,
      stock: 100,
      sku: 'KPI-002',
      categoryId: kopi.id
    },
    {
      name: 'Kopi Susu Gula Aren',
      description: 'Kopi susu kekinian dengan gula aren. Manis, creamy, favorit anak muda.',
      price: 24000,
      cost: 7000,
      stock: 80,
      sku: 'KPI-003',
      categoryId: kopi.id
    },
    {
      name: 'Cappuccino',
      description: 'Espresso dengan susu dan foam tebal. Creamy dan lembut.',
      price: 28000,
      cost: 8000,
      stock: 75,
      sku: 'KPI-004',
      categoryId: kopi.id
    },
    {
      name: 'Cafe Latte',
      description: 'Espresso dengan banyak susu steamed. Lebih milky dari cappuccino.',
      price: 28000,
      cost: 8000,
      stock: 70,
      sku: 'KPI-005',
      categoryId: kopi.id
    },
    {
      name: 'Mocha',
      description: 'Kopi dengan coklat dan susu. Manis, cocok untuk yang gak terlalu suka pahit.',
      price: 32000,
      cost: 10000,
      stock: 60,
      sku: 'KPI-006',
      categoryId: kopi.id
    },
    {
      name: 'Affogato',
      description: 'Espresso panas dituang ke ice cream vanilla. Dessert coffee.',
      price: 35000,
      cost: 12000,
      stock: 40,
      sku: 'KPI-007',
      categoryId: kopi.id
    },
    {
      name: 'Cold Brew',
      description: 'Kopi yang diseduh dingin 12 jam. Smooth, less acidic, refreshing.',
      price: 28000,
      cost: 6000,
      stock: 50,
      sku: 'KPI-008',
      categoryId: kopi.id
    },
    {
      name: 'Es Kopi Susu',
      description: 'Kopi susu dingin klasik. Simple tapi nagih.',
      price: 20000,
      cost: 6000,
      stock: 100,
      sku: 'KPI-009',
      categoryId: kopi.id
    },
    {
      name: 'Vietnamese Coffee',
      description: 'Kopi dengan susu kental manis. Strong dan sangat manis.',
      price: 25000,
      cost: 7000,
      stock: 45,
      sku: 'KPI-010',
      categoryId: kopi.id
    },

    // NON-KOPI
    {
      name: 'Matcha Latte',
      description: 'Green tea Jepang dengan susu. Creamy, earthy, sedikit pahit alami.',
      price: 30000,
      cost: 10000,
      stock: 60,
      sku: 'NKP-001',
      categoryId: nonKopi.id
    },
    {
      name: 'Taro Latte',
      description: 'Minuman taro ungu dengan susu. Manis, creamy, wangi.',
      price: 28000,
      cost: 9000,
      stock: 55,
      sku: 'NKP-002',
      categoryId: nonKopi.id
    },
    {
      name: 'Coklat Panas',
      description: 'Hot chocolate klasik. Rich, manis, comfort drink.',
      price: 25000,
      cost: 8000,
      stock: 70,
      sku: 'NKP-003',
      categoryId: nonKopi.id
    },
    {
      name: 'Es Coklat',
      description: 'Coklat dingin dengan susu. Refreshing dan manis.',
      price: 25000,
      cost: 8000,
      stock: 75,
      sku: 'NKP-004',
      categoryId: nonKopi.id
    },
    {
      name: 'Es Teh Manis',
      description: 'Teh manis dingin klasik Indonesia. Murah meriah.',
      price: 10000,
      cost: 2000,
      stock: 200,
      sku: 'NKP-005',
      categoryId: nonKopi.id
    },
    {
      name: 'Lemon Tea',
      description: 'Teh dengan perasan lemon segar. Asam manis segar.',
      price: 15000,
      cost: 4000,
      stock: 80,
      sku: 'NKP-006',
      categoryId: nonKopi.id
    },
    {
      name: 'Thai Tea',
      description: 'Teh ala Thailand dengan susu. Orange color, manis creamy.',
      price: 22000,
      cost: 6000,
      stock: 65,
      sku: 'NKP-007',
      categoryId: nonKopi.id
    },
    {
      name: 'Strawberry Smoothie',
      description: 'Smoothie strawberry segar dengan yogurt. Fruity dan sehat.',
      price: 30000,
      cost: 12000,
      stock: 40,
      sku: 'NKP-008',
      categoryId: nonKopi.id
    },
    {
      name: 'Mango Smoothie',
      description: 'Smoothie mangga manis. Tropical vibes.',
      price: 30000,
      cost: 12000,
      stock: 35,
      sku: 'NKP-009',
      categoryId: nonKopi.id
    },
    {
      name: 'Air Mineral',
      description: 'Air putih kemasan. Untuk yang mau sehat.',
      price: 8000,
      cost: 3000,
      stock: 150,
      sku: 'NKP-010',
      categoryId: nonKopi.id
    },

    // MAKANAN
    {
      name: 'Nasi Goreng Spesial',
      description: 'Nasi goreng dengan telur, ayam, dan sayuran. Porsi besar, mengenyangkan.',
      price: 35000,
      cost: 15000,
      stock: 30,
      sku: 'MKN-001',
      categoryId: makanan.id
    },
    {
      name: 'Mie Goreng',
      description: 'Mie goreng dengan telur dan sayuran. Comfort food.',
      price: 30000,
      cost: 12000,
      stock: 35,
      sku: 'MKN-002',
      categoryId: makanan.id
    },
    {
      name: 'Sandwich Tuna',
      description: 'Roti dengan isian tuna mayo, sayuran segar. Light meal.',
      price: 32000,
      cost: 14000,
      stock: 20,
      sku: 'MKN-003',
      categoryId: makanan.id
    },
    {
      name: 'Croissant',
      description: 'Pastry butter klasik Prancis. Flaky dan buttery.',
      price: 25000,
      cost: 10000,
      stock: 25,
      sku: 'MKN-004',
      categoryId: makanan.id
    },
    {
      name: 'Roti Bakar Coklat Keju',
      description: 'Roti bakar dengan topping coklat dan keju. Manis gurih.',
      price: 22000,
      cost: 8000,
      stock: 30,
      sku: 'MKN-005',
      categoryId: makanan.id
    },
    {
      name: 'French Fries',
      description: 'Kentang goreng crispy. Snack klasik.',
      price: 20000,
      cost: 7000,
      stock: 40,
      sku: 'MKN-006',
      categoryId: makanan.id
    },
    {
      name: 'Chicken Wings',
      description: '6 pcs sayap ayam goreng dengan saus. Spicy atau BBQ.',
      price: 38000,
      cost: 18000,
      stock: 25,
      sku: 'MKN-007',
      categoryId: makanan.id
    },

    // SNACK
    {
      name: 'Brownies',
      description: 'Brownies coklat fudgy. Rich dan decadent.',
      price: 18000,
      cost: 6000,
      stock: 30,
      sku: 'SNK-001',
      categoryId: snack.id
    },
    {
      name: 'Cheesecake',
      description: 'Cheesecake creamy dengan base biskuit. Lembut.',
      price: 28000,
      cost: 12000,
      stock: 15,
      sku: 'SNK-002',
      categoryId: snack.id
    },
    {
      name: 'Cookies',
      description: 'Cookies chocolate chip. Crunchy di luar, chewy di dalam.',
      price: 15000,
      cost: 5000,
      stock: 50,
      sku: 'SNK-003',
      categoryId: snack.id
    },
    {
      name: 'Pisang Goreng',
      description: 'Pisang goreng crispy dengan topping keju/coklat.',
      price: 18000,
      cost: 6000,
      stock: 25,
      sku: 'SNK-004',
      categoryId: snack.id
    },
    {
      name: 'Donat',
      description: 'Donat empuk dengan berbagai topping.',
      price: 12000,
      cost: 4000,
      stock: 40,
      sku: 'SNK-005',
      categoryId: snack.id
    }
  ]

  for (const product of products) {
    await prisma.product.create({ data: product })
  }

  console.log(`βœ… Created ${categories.length} categories`)
  console.log(`βœ… Created ${products.length} products`)

  // ============================================
  // SAMPLE TRANSACTIONS (untuk testing analytics)
  // ============================================
  const allProducts = await prisma.product.findMany()

  // Generate 50 sample transactions untuk 7 hari terakhir
  for (let i = 0; i < 50; i++) {
    const daysAgo = Math.floor(Math.random() * 7)
    const hoursAgo = Math.floor(Math.random() * 12) + 8 // 8 AM - 8 PM

    const transactionDate = new Date()
    transactionDate.setDate(transactionDate.getDate() - daysAgo)
    transactionDate.setHours(hoursAgo, Math.floor(Math.random() * 60), 0, 0)

    // Random 1-5 items per transaction
    const itemCount = Math.floor(Math.random() * 5) + 1
    const selectedProducts = allProducts
      .sort(() => Math.random() - 0.5)
      .slice(0, itemCount)

    const items = selectedProducts.map(product => ({
      productId: product.id,
      productName: product.name,
      productSku: product.sku,
      quantity: Math.floor(Math.random() * 3) + 1,
      unitPrice: product.price,
      subtotal: 0, // akan dihitung
      discountAmount: 0
    }))

    // Calculate subtotals
    items.forEach(item => {
      item.subtotal = item.unitPrice * item.quantity
    })

    const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0)
    const taxAmount = subtotal * 0.1
    const total = subtotal + taxAmount

    const paymentMethods = ['cash', 'qris', 'card', 'transfer']
    const paymentMethod = paymentMethods[Math.floor(Math.random() * paymentMethods.length)]

    // Generate invoice number
    const dateStr = transactionDate.toISOString().slice(0, 10).replace(/-/g, '')
    const invoiceNumber = `INV-${dateStr}-${String(i + 1).padStart(3, '0')}`

    await prisma.transaction.create({
      data: {
        invoiceNumber,
        subtotal,
        taxAmount,
        taxPercent: 10,
        total,
        paymentMethod,
        amountPaid: paymentMethod === 'cash'
          ? Math.ceil(total / 10000) * 10000  // Pembulatan ke atas
          : total,
        changeAmount: paymentMethod === 'cash'
          ? Math.ceil(total / 10000) * 10000 - total
          : 0,
        status: 'completed',
        cashierName: ['Budi', 'Ani', 'Dewi'][Math.floor(Math.random() * 3)],
        createdAt: transactionDate,
        items: {
          create: items
        }
      }
    })
  }

  console.log(`βœ… Created 50 sample transactions`)
  console.log('πŸŽ‰ Seeding completed!')
}

main()
  .catch((e) => {
    console.error('❌ Seeding failed:', e)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

2.8 Run Migration & Seed

Tambahkan script di package.json:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "db:generate": "prisma generate",
    "db:push": "prisma db push",
    "db:seed": "npx tsx prisma/seed.ts",
    "db:studio": "prisma studio",
    "db:reset": "prisma db push --force-reset && npm run db:seed"
  }
}

Sekarang jalankan:

# Generate Prisma Client
npm run db:generate

# Push schema ke database
npm run db:push

# Seed data
npm run db:seed

Kalau sukses, akan muncul:

🌱 Seeding database...
βœ… Created 4 categories
βœ… Created 32 products
βœ… Created 50 sample transactions
πŸŽ‰ Seeding completed!

2.9 Utility Files

Buat beberapa utility files:

// src/lib/utils.ts

import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('id-ID', {
    style: 'currency',
    currency: 'IDR',
    minimumFractionDigits: 0,
    maximumFractionDigits: 0
  }).format(amount)
}

export function generateInvoiceNumber(): string {
  const date = new Date()
  const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '')
  const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0')
  return `INV-${dateStr}-${random}`
}

// src/hooks/useDebounce.ts

import { useState, useEffect } from 'react'

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])

  return debouncedValue
}

// src/types/index.ts

export interface Product {
  id: string
  name: string
  description: string | null
  price: number
  stock: number
  sku: string
  categoryId: string
  category: {
    id: string
    name: string
  }
}

export interface CartItem extends Product {
  quantity: number
}

export interface Transaction {
  id: string
  invoiceNumber: string
  subtotal: number
  discountAmount: number
  taxAmount: number
  total: number
  paymentMethod: string
  amountPaid: number
  changeAmount: number
  status: string
  items: TransactionItem[]
  createdAt: Date
}

export interface TransactionItem {
  id: string
  productId: string
  productName: string
  productSku: string
  quantity: number
  unitPrice: number
  subtotal: number
}

2.10 Test Database Connection

Buat simple API route untuk test:

// src/app/api/health/route.ts

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'

export async function GET() {
  try {
    const productCount = await prisma.product.count()
    const categoryCount = await prisma.category.count()
    const transactionCount = await prisma.transaction.count()

    return NextResponse.json({
      status: 'ok',
      database: 'connected',
      counts: {
        products: productCount,
        categories: categoryCount,
        transactions: transactionCount
      }
    })
  } catch (error) {
    return NextResponse.json(
      { status: 'error', message: 'Database connection failed' },
      { status: 500 }
    )
  }
}

Jalankan development server:

npm run dev

Buka browser, akses http://localhost:3000/api/health. Kalau dapat response seperti ini, berarti setup berhasil:

{
  "status": "ok",
  "database": "connected",
  "counts": {
    "products": 32,
    "categories": 4,
    "transactions": 50
  }
}


πŸ’‘ Mini Tips #2

Selalu pakai TypeScript untuk project AI. Kenapa? Karena response dari LLM itu unpredictable β€” bisa string, bisa object, bisa array, bisa malah error. Dengan TypeScript, kita bisa catch potential issues di compile time, bukan di production. Trust me, debugging AI apps tanpa types itu nightmare.


Checkpoint! πŸŽ‰

Sampai sini kita sudah:

  • βœ… Setup Next.js project dengan semua dependencies
  • βœ… Design database schema yang AI-friendly
  • βœ… Seed data realistis dengan 32 produk dan 50 transaksi
  • βœ… Setup Prisma client dan utilities
  • βœ… Test database connection

Di bagian selanjutnya, kita akan setup LangChain dan mulai bikin fitur AI pertama β€” Smart Product Search. Get ready! πŸš€


Bagian 3: Setup LangChain.js & Konfigurasi AI

Nah, sekarang masuk ke bagian yang paling seru β€” setup AI-nya! πŸ€–

Di bagian ini kita akan:

  1. Konfigurasi LangChain dengan OpenAI
  2. Bikin system prompts yang efektif
  3. Define custom tools untuk kasir

3.1 LangChain Configuration

Pertama, kita buat file konfigurasi utama LangChain:

// src/lib/ai/langchain.ts

import { ChatOpenAI } from '@langchain/openai'

// ============================================
// MODEL CONFIGURATIONS
// ============================================

/**
 * Model utama untuk general tasks
 * - gpt-4o-mini: murah, cepat, cukup pintar untuk kebanyakan use case
 * - temperature 0.3: sedikit kreatif tapi tetap konsisten
 */
export const llm = new ChatOpenAI({
  modelName: 'gpt-4o-mini',
  temperature: 0.3,
  maxTokens: 1000,
})

/**
 * Model untuk tasks yang butuh akurasi tinggi
 * - Parsing structured data
 * - Keputusan penting
 * - temperature 0: deterministic, consistent output
 */
export const llmStrict = new ChatOpenAI({
  modelName: 'gpt-4o-mini',
  temperature: 0,
  maxTokens: 500,
})

/**
 * Model untuk streaming responses (chat)
 * - streaming: true untuk real-time typing effect
 */
export const llmStreaming = new ChatOpenAI({
  modelName: 'gpt-4o-mini',
  temperature: 0.5,
  streaming: true,
  maxTokens: 1500,
})

/**
 * Model untuk analytics yang kompleks
 * - Bisa ganti ke gpt-4o kalau butuh reasoning lebih bagus
 * - Tapi untuk cost efficiency, gpt-4o-mini biasanya cukup
 */
export const llmAnalytics = new ChatOpenAI({
  modelName: 'gpt-4o-mini',
  temperature: 0.2,
  maxTokens: 2000,
})

Kenapa ada banyak model config?

Ini strategi cost optimization. Daripada pakai satu model untuk semua (yang bisa mahal), kita pilih model yang tepat untuk setiap task:

ConfigUse CaseTemperatureReasoning
llmGeneral purpose0.3Balance antara kreatif dan konsisten
llmStrictParsing, structured output0Butuh output yang predictable
llmStreamingChat responses0.5Sedikit variasi biar natural
llmAnalyticsData analysis0.2Akurat tapi bisa kasih insight

3.2 System Prompts

Prompts itu ibarat "job description" untuk AI. Semakin jelas, semakin bagus hasilnya.

// src/lib/ai/prompts.ts

// ============================================
// POS ASSISTANT PROMPT
// ============================================
export const POS_ASSISTANT_SYSTEM_PROMPT = `Kamu adalah "Kasir AI", asisten cerdas untuk sistem Point of Sale (POS).

## IDENTITAS
- Nama: Kasir AI
- Peran: Membantu kasir dan staff dalam operasional harian
- Gaya komunikasi: Ramah, helpful, to the point

## KEMAMPUAN
Kamu bisa membantu dengan:
1. **Pencarian Produk** - Cari produk berdasarkan nama, deskripsi, atau kategori
2. **Cek Stok** - Informasi ketersediaan dan level stok produk
3. **Kalkulasi** - Hitung diskon, total belanja, kembalian
4. **Rekomendasi** - Suggest produk berdasarkan preferensi customer
5. **Laporan Singkat** - Rangkuman penjualan dan insights

## ATURAN PENTING
- Selalu jawab dalam Bahasa Indonesia
- Gunakan format currency Rupiah (Rp)
- Jawaban singkat dan actionable
- Jika tidak punya informasi, bilang dengan jujur
- JANGAN mengarang data yang tidak ada
- Untuk pertanyaan di luar konteks POS, arahkan kembali ke topik kasir

## CONTEXT DATA
{context}

## TANGGAL HARI INI
{currentDate}`

// ============================================
// SEARCH PARSER PROMPT
// ============================================
export const SEARCH_PARSER_PROMPT = `Kamu adalah parser untuk smart search sistem kasir.

## TUGAS
Convert query natural language dari user menjadi structured search criteria dalam format JSON.

## INPUT
- User query: "{query}"
- Available categories: {categories}

## OUTPUT FORMAT
Return HANYA JSON object (tanpa markdown, tanpa penjelasan):
{{
  "searchTerms": ["kata", "kunci", "relevan"],
  "category": "nama kategori" atau null,
  "priceRange": {{
    "min": number atau null,
    "max": number atau null
  }},
  "sortBy": "price_asc" | "price_desc" | "name" | "stock" | null,
  "attributes": {{
    "isSweet": boolean atau null,
    "isCold": boolean atau null,
    "hasMilk": boolean atau null,
    "isSpicy": boolean atau null
  }}
}}

## CONTOH PARSING

Query: "kopi yang gak terlalu manis"
Output: {{"searchTerms": ["kopi"], "category": "Kopi", "priceRange": {{"min": null, "max": null}}, "sortBy": null, "attributes": {{"isSweet": false, "isCold": null, "hasMilk": null, "isSpicy": null}}}}

Query: "minuman dingin murah"
Output: {{"searchTerms": ["minuman"], "category": null, "priceRange": {{"min": null, "max": 25000}}, "sortBy": "price_asc", "attributes": {{"isSweet": null, "isCold": true, "hasMilk": null, "isSpicy": null}}}}

Query: "makanan berat yang mengenyangkan"
Output: {{"searchTerms": ["makanan", "mengenyangkan", "berat"], "category": "Makanan", "priceRange": {{"min": 25000, "max": null}}, "sortBy": null, "attributes": {{"isSweet": null, "isCold": null, "hasMilk": null, "isSpicy": null}}}}

Query: "yang ada coklat"
Output: {{"searchTerms": ["coklat", "chocolate", "moca"], "category": null, "priceRange": {{"min": null, "max": null}}, "sortBy": null, "attributes": {{"isSweet": true, "isCold": null, "hasMilk": null, "isSpicy": null}}}}

## PANDUAN HARGA (Indonesia)
- "murah" = max 25000
- "mahal" / "premium" = min 30000
- "sedang" / "standar" = 15000 - 30000

## PANDUAN KATEGORI
- Kopi: espresso, latte, cappuccino, americano, kopi susu
- Non-Kopi: teh, matcha, coklat, smoothie, jus
- Makanan: nasi, mie, sandwich, croissant
- Snack: brownies, cookies, cake, pisang goreng

INGAT: Output HANYA JSON, tidak ada text lain!`

// ============================================
// ANALYTICS PROMPT
// ============================================
export const ANALYTICS_PROMPT = `Kamu adalah data analyst untuk sistem kasir.

## TUGAS
Analyze data penjualan dan jawab pertanyaan user dengan insight yang actionable.

## DATA YANG TERSEDIA
{data}

## PERTANYAAN USER
{question}

## FORMAT JAWABAN
1. Jawab pertanyaan dengan angka spesifik
2. Format currency dalam Rupiah (Rp)
3. Berikan insight atau rekomendasi jika relevan
4. Gunakan bullet points untuk list
5. Maksimal 200 kata

## CONTOH JAWABAN BAGUS
"Penjualan hari ini mencapai Rp 2.450.000 dari 45 transaksi.

Highlights:
β€’ Produk terlaris: Kopi Susu Gula Aren (28 cup)
β€’ Jam tersibuk: 12:00-14:00 (lunch time)
β€’ Metode pembayaran favorit: QRIS (60%)

πŸ’‘ Insight: Penjualan meningkat 15% dibanding kemarin. Pertimbangkan untuk restock Kopi Susu karena stok tinggal 12 cup."`

// ============================================
// PRODUCT RECOMMENDATION PROMPT
// ============================================
export const RECOMMENDATION_PROMPT = `Kamu adalah product recommender untuk cafe/resto.

## CONTEXT
Customer preference: {preference}
Available products: {products}
Current cart: {cart}

## TUGAS
Rekomendasikan 3 produk yang cocok. Pertimbangkan:
1. Preferensi yang disebutkan
2. Produk yang sering dibeli bareng (pairing)
3. Margin/profit (jika ada data cost)

## OUTPUT FORMAT
Return JSON array:
[
  {{"productId": "xxx", "reason": "alasan singkat"}},
  {{"productId": "yyy", "reason": "alasan singkat"}},
  {{"productId": "zzz", "reason": "alasan singkat"}}
]`

Tips bikin prompt yang bagus:

  1. Kasih identitas yang jelas β€” AI perform lebih baik kalau tau "siapa" dia
  2. List kemampuan eksplisit β€” Jadi AI tau scope-nya
  3. Kasih contoh β€” Few-shot learning sangat membantu
  4. Definisikan batasan β€” Apa yang TIDAK boleh dilakukan
  5. Format output jelas β€” Terutama untuk structured data

3.3 Custom AI Tools

Tools adalah "tangan" AI untuk berinteraksi dengan sistem kita. Tanpa tools, AI cuma bisa ngobrol. Dengan tools, AI bisa akses database, hitung, dan take actions.

// src/lib/ai/tools.ts

import { tool } from '@langchain/core/tools'
import { z } from 'zod'
import { prisma } from '@/lib/db'
import { formatCurrency } from '@/lib/utils'

// ============================================
// TOOL 1: SEARCH PRODUCTS
// ============================================
export const searchProductsTool = tool(
  async ({ query, category, maxPrice, minPrice, limit }) => {
    try {
      // Build where clause dynamically
      const whereClause: any = {
        isActive: true,
        stock: { gt: 0 }
      }

      // Text search
      if (query && query.trim()) {
        whereClause.OR = [
          { name: { contains: query, mode: 'insensitive' } },
          { description: { contains: query, mode: 'insensitive' } }
        ]
      }

      // Category filter
      if (category) {
        whereClause.category = {
          name: { equals: category, mode: 'insensitive' }
        }
      }

      // Price filters
      if (minPrice !== undefined || maxPrice !== undefined) {
        whereClause.price = {}
        if (minPrice !== undefined) whereClause.price.gte = minPrice
        if (maxPrice !== undefined) whereClause.price.lte = maxPrice
      }

      const products = await prisma.product.findMany({
        where: whereClause,
        include: { category: true },
        take: limit || 10,
        orderBy: { name: 'asc' }
      })

      if (products.length === 0) {
        return 'Tidak ada produk yang ditemukan dengan kriteria tersebut.'
      }

      // Format response for AI
      const formatted = products.map(p =>
        `β€’ ${p.name} (${p.category.name}) - ${formatCurrency(p.price)} - Stok: ${p.stock}`
      ).join('\\n')

      return `Ditemukan ${products.length} produk:\\n${formatted}`
    } catch (error) {
      console.error('Search tool error:', error)
      return 'Terjadi kesalahan saat mencari produk.'
    }
  },
  {
    name: 'search_products',
    description: 'Cari produk berdasarkan keyword, kategori, atau range harga. Gunakan tool ini ketika user ingin mencari atau melihat produk.',
    schema: z.object({
      query: z.string().optional().describe('Kata kunci pencarian (nama atau deskripsi produk)'),
      category: z.string().optional().describe('Filter berdasarkan kategori: Kopi, Non-Kopi, Makanan, atau Snack'),
      minPrice: z.number().optional().describe('Harga minimum dalam Rupiah'),
      maxPrice: z.number().optional().describe('Harga maksimum dalam Rupiah'),
      limit: z.number().optional().describe('Jumlah maksimal hasil (default 10)')
    })
  }
)

// ============================================
// TOOL 2: CHECK STOCK
// ============================================
export const checkStockTool = tool(
  async ({ productName, checkLowStock }) => {
    try {
      if (checkLowStock) {
        // Get all products with low stock
        const lowStockProducts = await prisma.product.findMany({
          where: {
            isActive: true,
            stock: { lte: prisma.product.fields.minStock }
          },
          include: { category: true },
          orderBy: { stock: 'asc' }
        })

        // Fallback: get products with stock <= 10 if the above doesn't work
        const products = lowStockProducts.length > 0
          ? lowStockProducts
          : await prisma.product.findMany({
              where: {
                isActive: true,
                stock: { lte: 10 }
              },
              include: { category: true },
              orderBy: { stock: 'asc' }
            })

        if (products.length === 0) {
          return 'βœ… Semua produk stoknya aman! Tidak ada yang perlu di-restock.'
        }

        const formatted = products.map(p => {
          const status = p.stock === 0 ? 'πŸ”΄ HABIS' : p.stock <= 5 ? '🟠 KRITIS' : '🟑 MENIPIS'
          return `${status} ${p.name}: ${p.stock} unit`
        }).join('\\n')

        return `⚠️ Produk dengan stok menipis:\\n${formatted}\\n\\nRekomendasi: Segera lakukan restock untuk produk dengan status KRITIS dan HABIS.`
      }

      // Search specific product
      if (!productName) {
        return 'Mohon sebutkan nama produk yang ingin dicek stoknya, atau tanya "stok menipis" untuk melihat semua produk yang perlu restock.'
      }

      const products = await prisma.product.findMany({
        where: {
          name: { contains: productName, mode: 'insensitive' },
          isActive: true
        },
        include: { category: true }
      })

      if (products.length === 0) {
        return `Produk "${productName}" tidak ditemukan. Coba kata kunci lain?`
      }

      const formatted = products.map(p => {
        let status = 'βœ… Aman'
        if (p.stock === 0) status = 'πŸ”΄ HABIS'
        else if (p.stock <= 5) status = '🟠 Stok kritis'
        else if (p.stock <= 10) status = '🟑 Stok menipis'

        return `${p.name}:\\n  Stok: ${p.stock} unit (${status})\\n  Kategori: ${p.category.name}\\n  Harga: ${formatCurrency(p.price)}`
      }).join('\\n\\n')

      return formatted
    } catch (error) {
      console.error('Check stock tool error:', error)
      return 'Terjadi kesalahan saat mengecek stok.'
    }
  },
  {
    name: 'check_stock',
    description: 'Cek stok produk tertentu atau lihat semua produk dengan stok menipis. Gunakan checkLowStock=true untuk melihat produk yang perlu restock.',
    schema: z.object({
      productName: z.string().optional().describe('Nama produk yang ingin dicek stoknya'),
      checkLowStock: z.boolean().optional().describe('Set true untuk melihat semua produk dengan stok menipis')
    })
  }
)

// ============================================
// TOOL 3: CALCULATE DISCOUNT
// ============================================
export const calculateDiscountTool = tool(
  async ({ subtotal, discountType, discountValue }) => {
    try {
      let discountAmount = 0
      let discountDescription = ''

      if (discountType === 'percent') {
        if (discountValue > 100) {
          return 'Diskon tidak boleh lebih dari 100%'
        }
        discountAmount = subtotal * (discountValue / 100)
        discountDescription = `${discountValue}%`
      } else {
        if (discountValue > subtotal) {
          return 'Diskon tidak boleh lebih besar dari subtotal'
        }
        discountAmount = discountValue
        discountDescription = formatCurrency(discountValue)
      }

      const total = subtotal - discountAmount
      const tax = total * 0.1 // PPN 10%
      const grandTotal = total + tax

      return `πŸ“ Kalkulasi:

Subtotal: ${formatCurrency(subtotal)}
Diskon (${discountDescription}): -${formatCurrency(discountAmount)}
─────────────────
Setelah Diskon: ${formatCurrency(total)}
PPN 10%: +${formatCurrency(tax)}
─────────────────
TOTAL: ${formatCurrency(grandTotal)}

πŸ’‘ Customer hemat ${formatCurrency(discountAmount)} dengan diskon ini!`
    } catch (error) {
      console.error('Calculate discount tool error:', error)
      return 'Terjadi kesalahan saat menghitung diskon.'
    }
  },
  {
    name: 'calculate_discount',
    description: 'Hitung total belanja dengan diskon. Bisa diskon persen atau nominal.',
    schema: z.object({
      subtotal: z.number().describe('Total belanja sebelum diskon (dalam Rupiah)'),
      discountType: z.enum(['percent', 'nominal']).describe('Tipe diskon: percent atau nominal'),
      discountValue: z.number().describe('Nilai diskon (angka persen atau nominal Rupiah)')
    })
  }
)

// ============================================
// TOOL 4: GET SALES SUMMARY
// ============================================
export const getSalesSummaryTool = tool(
  async ({ period }) => {
    try {
      const now = new Date()
      let startDate: Date
      let periodLabel: string

      switch (period) {
        case 'today':
          startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
          periodLabel = 'Hari ini'
          break
        case 'yesterday':
          startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
          const endYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
          periodLabel = 'Kemarin'

          // Special handling for yesterday
          const yesterdayData = await prisma.transaction.aggregate({
            where: {
              createdAt: { gte: startDate, lt: endYesterday },
              status: 'completed'
            },
            _sum: { total: true, discountAmount: true },
            _avg: { total: true },
            _count: true
          })

          const yesterdayTopProducts = await getTopProducts(startDate, endYesterday, 5)

          return formatSalesSummary(periodLabel, yesterdayData, yesterdayTopProducts)

        case 'week':
          startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
          periodLabel = '7 hari terakhir'
          break
        case 'month':
          startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate())
          periodLabel = '30 hari terakhir'
          break
        default:
          startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
          periodLabel = 'Hari ini'
      }

      // Get aggregate data
      const salesData = await prisma.transaction.aggregate({
        where: {
          createdAt: { gte: startDate },
          status: 'completed'
        },
        _sum: { total: true, discountAmount: true },
        _avg: { total: true },
        _count: true
      })

      // Get top products
      const topProducts = await getTopProducts(startDate, now, 5)

      return formatSalesSummary(periodLabel, salesData, topProducts)
    } catch (error) {
      console.error('Sales summary tool error:', error)
      return 'Terjadi kesalahan saat mengambil data penjualan.'
    }
  },
  {
    name: 'get_sales_summary',
    description: 'Dapatkan ringkasan penjualan untuk periode tertentu. Termasuk total revenue, jumlah transaksi, dan produk terlaris.',
    schema: z.object({
      period: z.enum(['today', 'yesterday', 'week', 'month']).describe('Periode laporan: today, yesterday, week, atau month')
    })
  }
)

// Helper function untuk get top products
async function getTopProducts(startDate: Date, endDate: Date, limit: number) {
  const topItems = await prisma.transactionItem.groupBy({
    by: ['productId', 'productName'],
    where: {
      transaction: {
        createdAt: { gte: startDate, lte: endDate },
        status: 'completed'
      }
    },
    _sum: { quantity: true, subtotal: true },
    orderBy: { _sum: { subtotal: 'desc' } },
    take: limit
  })

  return topItems.map((item, index) => ({
    rank: index + 1,
    name: item.productName,
    quantity: item._sum.quantity || 0,
    revenue: item._sum.subtotal || 0
  }))
}

// Helper function untuk format sales summary
function formatSalesSummary(
  periodLabel: string,
  salesData: any,
  topProducts: any[]
) {
  const totalRevenue = salesData._sum.total || 0
  const totalTransactions = salesData._count || 0
  const avgTransaction = salesData._avg.total || 0
  const totalDiscount = salesData._sum.discountAmount || 0

  let response = `πŸ“Š LAPORAN PENJUALAN - ${periodLabel.toUpperCase()}

πŸ’° Total Revenue: ${formatCurrency(totalRevenue)}
🧾 Jumlah Transaksi: ${totalTransactions}
πŸ“ˆ Rata-rata/Transaksi: ${formatCurrency(avgTransaction)}
🏷️ Total Diskon: ${formatCurrency(totalDiscount)}
`

  if (topProducts.length > 0) {
    response += `\\nπŸ† TOP ${topProducts.length} PRODUK TERLARIS:\\n`
    topProducts.forEach(p => {
      response += `   ${p.rank}. ${p.name} - ${p.quantity} terjual (${formatCurrency(p.revenue)})\\n`
    })
  }

  // Add insight
  if (totalTransactions > 0) {
    response += `\\nπŸ’‘ Insight: `
    if (avgTransaction > 50000) {
      response += 'Rata-rata transaksi cukup tinggi. Customer cenderung beli banyak item.'
    } else if (avgTransaction > 30000) {
      response += 'Rata-rata transaksi standar. Coba upselling untuk meningkatkan nilai transaksi.'
    } else {
      response += 'Rata-rata transaksi rendah. Pertimbangkan bundling atau promo untuk meningkatkan basket size.'
    }
  }

  return response
}

// ============================================
// TOOL 5: GET PAYMENT METHODS BREAKDOWN
// ============================================
export const getPaymentBreakdownTool = tool(
  async ({ period }) => {
    try {
      const now = new Date()
      let startDate: Date

      switch (period) {
        case 'today':
          startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
          break
        case 'week':
          startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
          break
        case 'month':
          startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate())
          break
        default:
          startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
      }

      const breakdown = await prisma.transaction.groupBy({
        by: ['paymentMethod'],
        where: {
          createdAt: { gte: startDate },
          status: 'completed'
        },
        _count: true,
        _sum: { total: true }
      })

      if (breakdown.length === 0) {
        return 'Belum ada transaksi di periode ini.'
      }

      const total = breakdown.reduce((sum, item) => sum + (item._sum.total || 0), 0)
      const totalCount = breakdown.reduce((sum, item) => sum + item._count, 0)

      const methodLabels: Record<string, string> = {
        'cash': 'πŸ’΅ Tunai',
        'qris': 'πŸ“± QRIS',
        'card': 'πŸ’³ Kartu',
        'transfer': '🏦 Transfer'
      }

      let response = `πŸ’³ BREAKDOWN METODE PEMBAYARAN\\n\\n`

      breakdown
        .sort((a, b) => (b._sum.total || 0) - (a._sum.total || 0))
        .forEach(item => {
          const percentage = ((item._sum.total || 0) / total * 100).toFixed(1)
          const label = methodLabels[item.paymentMethod] || item.paymentMethod
          response += `${label}:\\n`
          response += `  β€’ ${item._count} transaksi (${percentage}%)\\n`
          response += `  β€’ ${formatCurrency(item._sum.total || 0)}\\n\\n`
        })

      response += `πŸ“Š Total: ${totalCount} transaksi = ${formatCurrency(total)}`

      return response
    } catch (error) {
      console.error('Payment breakdown tool error:', error)
      return 'Terjadi kesalahan saat mengambil data pembayaran.'
    }
  },
  {
    name: 'get_payment_breakdown',
    description: 'Lihat breakdown transaksi berdasarkan metode pembayaran (cash, QRIS, kartu, transfer).',
    schema: z.object({
      period: z.enum(['today', 'week', 'month']).describe('Periode: today, week, atau month')
    })
  }
)

// ============================================
// EXPORT ALL TOOLS
// ============================================
export const allPosTools = [
  searchProductsTool,
  checkStockTool,
  calculateDiscountTool,
  getSalesSummaryTool,
  getPaymentBreakdownTool
]

// Export individual tools for specific use cases
export {
  searchProductsTool,
  checkStockTool,
  calculateDiscountTool,
  getSalesSummaryTool,
  getPaymentBreakdownTool
}

Penjelasan setiap tool:

ToolFungsiKapan AI pakai
search_productsCari produk di databaseUser nanya "ada apa aja?", "cari kopi", dll
check_stockCek stok produkUser nanya "stok X berapa?", "apa yang mau habis?"
calculate_discountHitung diskonUser minta "hitung kalau diskon 10%"
get_sales_summaryLaporan penjualanUser nanya "penjualan hari ini gimana?"
get_payment_breakdownBreakdown payment methodUser nanya "pembayaran terbanyak pakai apa?"

3.4 Test LangChain Setup

Sebelum lanjut, kita test dulu apakah setup sudah benar. Buat simple test endpoint:

// src/app/api/test-ai/route.ts

import { NextResponse } from 'next/server'
import { llm } from '@/lib/ai/langchain'
import { StringOutputParser } from '@langchain/core/output_parsers'

export async function GET() {
  try {
    const parser = new StringOutputParser()

    const response = await llm.pipe(parser).invoke(
      'Jawab dalam 1 kalimat: Apa fungsi utama sistem kasir?'
    )

    return NextResponse.json({
      status: 'ok',
      message: 'LangChain is working!',
      aiResponse: response
    })
  } catch (error: any) {
    console.error('AI Test Error:', error)
    return NextResponse.json(
      {
        status: 'error',
        message: error.message,
        hint: 'Pastikan OPENAI_API_KEY sudah di-set di .env.local'
      },
      { status: 500 }
    )
  }
}

Buka http://localhost:3000/api/test-ai. Kalau dapat response seperti ini, berarti LangChain sudah ready:

{
  "status": "ok",
  "message": "LangChain is working!",
  "aiResponse": "Fungsi utama sistem kasir adalah mencatat dan memproses transaksi penjualan dengan akurat dan efisien."
}


πŸ’‘ Mini Tips #3

Define tools dengan schema Zod yang detail. Semakin jelas description di setiap parameter, semakin pintar AI milih kapan dan gimana pakai tool tersebut. Jangan males nulis description β€” itu "instruction manual" untuk AI.


Bagian 4: Smart Product Search dengan AI

Sekarang kita build fitur pertama yang langsung kerasa impact-nya β€” Smart Search! πŸ”

4.1 Search API Endpoint

// src/app/api/search/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { JsonOutputParser } from '@langchain/core/output_parsers'
import { llmStrict } from '@/lib/ai/langchain'
import { SEARCH_PARSER_PROMPT } from '@/lib/ai/prompts'
import { prisma } from '@/lib/db'
import { z } from 'zod'

// Schema untuk validasi AI output
const SearchCriteriaSchema = z.object({
  searchTerms: z.array(z.string()),
  category: z.string().nullable(),
  priceRange: z.object({
    min: z.number().nullable(),
    max: z.number().nullable()
  }),
  sortBy: z.enum(['price_asc', 'price_desc', 'name', 'stock']).nullable(),
  attributes: z.object({
    isSweet: z.boolean().nullable(),
    isCold: z.boolean().nullable(),
    hasMilk: z.boolean().nullable(),
    isSpicy: z.boolean().nullable()
  }).optional()
})

type SearchCriteria = z.infer<typeof SearchCriteriaSchema>

export async function POST(req: NextRequest) {
  try {
    const { query } = await req.json()

    // Jika query kosong, return semua produk aktif
    if (!query || query.trim().length === 0) {
      const products = await prisma.product.findMany({
        where: { isActive: true, stock: { gt: 0 } },
        include: { category: true },
        orderBy: { name: 'asc' },
        take: 50
      })

      return NextResponse.json({
        products,
        aiParsed: null,
        source: 'all_products'
      })
    }

    // Get categories untuk context
    const categories = await prisma.category.findMany()
    const categoryNames = categories.map(c => c.name).join(', ')

    // Parse query dengan AI
    const prompt = ChatPromptTemplate.fromTemplate(SEARCH_PARSER_PROMPT)
    const parser = new JsonOutputParser()
    const chain = prompt.pipe(llmStrict).pipe(parser)

    let aiParsed: SearchCriteria | null = null
    let parseError: string | null = null

    try {
      const parsed = await chain.invoke({
        query,
        categories: categoryNames
      })

      // Validate dengan Zod
      const validated = SearchCriteriaSchema.safeParse(parsed)

      if (validated.success) {
        aiParsed = validated.data
      } else {
        console.warn('AI output validation failed:', validated.error)
        parseError = 'AI parsing validation failed'
      }
    } catch (aiError) {
      console.error('AI parsing error:', aiError)
      parseError = 'AI parsing failed'
    }

    // Build Prisma query
    const whereClause: any = {
      AND: [
        { isActive: true },
        { stock: { gt: 0 } }
      ]
    }

    if (aiParsed) {
      // Search terms
      if (aiParsed.searchTerms.length > 0) {
        whereClause.AND.push({
          OR: aiParsed.searchTerms.flatMap(term => [
            { name: { contains: term, mode: 'insensitive' } },
            { description: { contains: term, mode: 'insensitive' } }
          ])
        })
      }

      // Category filter
      if (aiParsed.category) {
        whereClause.AND.push({
          category: { name: { equals: aiParsed.category, mode: 'insensitive' } }
        })
      }

      // Price range
      if (aiParsed.priceRange.min !== null) {
        whereClause.AND.push({ price: { gte: aiParsed.priceRange.min } })
      }
      if (aiParsed.priceRange.max !== null) {
        whereClause.AND.push({ price: { lte: aiParsed.priceRange.max } })
      }

      // Attribute-based search (search in description)
      if (aiParsed.attributes) {
        const attrFilters: any[] = []

        if (aiParsed.attributes.isSweet === true) {
          attrFilters.push({ description: { contains: 'manis', mode: 'insensitive' } })
        } else if (aiParsed.attributes.isSweet === false) {
          // Cari yang pahit atau tidak manis
          attrFilters.push({
            OR: [
              { description: { contains: 'pahit', mode: 'insensitive' } },
              { description: { contains: 'strong', mode: 'insensitive' } },
              { description: { contains: 'tanpa gula', mode: 'insensitive' } }
            ]
          })
        }

        if (aiParsed.attributes.isCold === true) {
          attrFilters.push({
            OR: [
              { description: { contains: 'dingin', mode: 'insensitive' } },
              { description: { contains: 'cold', mode: 'insensitive' } },
              { description: { contains: 'es ', mode: 'insensitive' } },
              { name: { contains: 'es ', mode: 'insensitive' } }
            ]
          })
        }

        if (aiParsed.attributes.hasMilk === false) {
          // Exclude susu - ini tricky, kita cari yang explicitly tanpa susu
          attrFilters.push({
            OR: [
              { description: { contains: 'tanpa susu', mode: 'insensitive' } },
              { description: { contains: 'hitam', mode: 'insensitive' } },
              { name: { contains: 'Americano', mode: 'insensitive' } },
              { name: { contains: 'Espresso', mode: 'insensitive' } }
            ]
          })
        }

        if (attrFilters.length > 0) {
          whereClause.AND.push(...attrFilters)
        }
      }
    } else {
      // Fallback: simple text search jika AI parsing gagal
      whereClause.AND.push({
        OR: [
          { name: { contains: query, mode: 'insensitive' } },
          { description: { contains: query, mode: 'insensitive' } }
        ]
      })
    }

    // Determine sort order
    let orderBy: any = { name: 'asc' }
    if (aiParsed?.sortBy) {
      switch (aiParsed.sortBy) {
        case 'price_asc':
          orderBy = { price: 'asc' }
          break
        case 'price_desc':
          orderBy = { price: 'desc' }
          break
        case 'name':
          orderBy = { name: 'asc' }
          break
        case 'stock':
          orderBy = { stock: 'desc' }
          break
      }
    }

    // Execute query
    const products = await prisma.product.findMany({
      where: whereClause,
      include: { category: true },
      orderBy,
      take: 30
    })

    return NextResponse.json({
      products,
      aiParsed,
      originalQuery: query,
      parseError,
      resultCount: products.length
    })

  } catch (error) {
    console.error('Search error:', error)
    return NextResponse.json(
      { error: 'Search failed', details: String(error) },
      { status: 500 }
    )
  }
}

4.2 Search Component dengan AI Indicator

// src/components/pos/SearchBar.tsx

'use client'

import { useState, useEffect, useCallback } from 'react'
import { Search, Sparkles, Loader2, X, Filter } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useDebounce } from '@/hooks/useDebounce'
import { cn } from '@/lib/utils'

interface Product {
  id: string
  name: string
  description: string | null
  price: number
  stock: number
  sku: string
  category: {
    id: string
    name: string
  }
}

interface SearchBarProps {
  onResults: (products: Product[]) => void
  onLoading?: (loading: boolean) => void
  className?: string
}

interface AIParsed {
  searchTerms: string[]
  category: string | null
  priceRange: {
    min: number | null
    max: number | null
  }
  sortBy: string | null
  attributes?: {
    isSweet: boolean | null
    isCold: boolean | null
    hasMilk: boolean | null
    isSpicy: boolean | null
  }
}

export function SearchBar({ onResults, onLoading, className }: SearchBarProps) {
  const [query, setQuery] = useState('')
  const [isSearching, setIsSearching] = useState(false)
  const [aiParsed, setAiParsed] = useState<AIParsed | null>(null)
  const [resultCount, setResultCount] = useState<number | null>(null)

  const debouncedQuery = useDebounce(query, 400)

  const handleSearch = useCallback(async (searchQuery: string) => {
    setIsSearching(true)
    onLoading?.(true)

    try {
      const res = await fetch('/api/search', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query: searchQuery })
      })

      if (!res.ok) throw new Error('Search failed')

      const data = await res.json()
      onResults(data.products)
      setAiParsed(data.aiParsed)
      setResultCount(data.products.length)
    } catch (error) {
      console.error('Search error:', error)
      onResults([])
      setAiParsed(null)
      setResultCount(0)
    } finally {
      setIsSearching(false)
      onLoading?.(false)
    }
  }, [onResults, onLoading])

  // Auto-search on debounced query change
  useEffect(() => {
    handleSearch(debouncedQuery)
  }, [debouncedQuery, handleSearch])

  const clearSearch = () => {
    setQuery('')
    setAiParsed(null)
  }

  // Format attribute badges
  const getAttributeBadges = () => {
    if (!aiParsed?.attributes) return []

    const badges: { label: string; variant: 'default' | 'secondary' }[] = []

    if (aiParsed.attributes.isSweet === true) badges.push({ label: '🍬 Manis', variant: 'secondary' })
    if (aiParsed.attributes.isSweet === false) badges.push({ label: 'β˜• Tidak Manis', variant: 'secondary' })
    if (aiParsed.attributes.isCold === true) badges.push({ label: '🧊 Dingin', variant: 'secondary' })
    if (aiParsed.attributes.hasMilk === false) badges.push({ label: 'πŸ₯› Tanpa Susu', variant: 'secondary' })

    return badges
  }

  return (
    <div className={cn('space-y-2', className)}>
      {/* Search Input */}
      <div className="relative">
        <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />

        <Input
          type="text"
          placeholder='Cari produk... (coba: "kopi dingin murah" atau "snack manis")'
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          className="pl-10 pr-20 h-12 text-base"
        />

        <div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
          {query && (
            <Button
              variant="ghost"
              size="icon"
              className="h-6 w-6"
              onClick={clearSearch}
            >
              <X className="h-4 w-4" />
            </Button>
          )}

          {isSearching ? (
            <Loader2 className="h-4 w-4 animate-spin text-primary" />
          ) : aiParsed ? (
            <Sparkles className="h-4 w-4 text-yellow-500" />
          ) : null}
        </div>
      </div>

      {/* AI Parsing Result */}
      {aiParsed && query && (
        <div className="flex flex-wrap items-center gap-2 px-1">
          {/* AI Indicator */}
          <Badge variant="outline" className="text-xs bg-yellow-50 border-yellow-200">
            <Sparkles className="h-3 w-3 mr-1 text-yellow-500" />
            AI Search
          </Badge>

          {/* Category Badge */}
          {aiParsed.category && (
            <Badge variant="secondary" className="text-xs">
              πŸ“ {aiParsed.category}
            </Badge>
          )}

          {/* Price Range Badge */}
          {(aiParsed.priceRange.min || aiParsed.priceRange.max) && (
            <Badge variant="secondary" className="text-xs">
              πŸ’° {aiParsed.priceRange.min
                ? `Min Rp${(aiParsed.priceRange.min / 1000).toFixed(0)}K`
                : ''}
              {aiParsed.priceRange.min && aiParsed.priceRange.max ? ' - ' : ''}
              {aiParsed.priceRange.max
                ? `Max Rp${(aiParsed.priceRange.max / 1000).toFixed(0)}K`
                : ''}
            </Badge>
          )}

          {/* Sort Badge */}
          {aiParsed.sortBy && (
            <Badge variant="secondary" className="text-xs">
              ↕️ {aiParsed.sortBy === 'price_asc' ? 'Termurah' :
                  aiParsed.sortBy === 'price_desc' ? 'Termahal' :
                  aiParsed.sortBy}
            </Badge>
          )}

          {/* Attribute Badges */}
          {getAttributeBadges().map((badge, i) => (
            <Badge key={i} variant={badge.variant} className="text-xs">
              {badge.label}
            </Badge>
          ))}

          {/* Result Count */}
          {resultCount !== null && (
            <span className="text-xs text-muted-foreground ml-auto">
              {resultCount} produk ditemukan
            </span>
          )}
        </div>
      )}

      {/* Quick Filters */}
      {!query && (
        <div className="flex flex-wrap gap-2 px-1">
          <span className="text-xs text-muted-foreground">Coba:</span>
          {['kopi dingin', 'minuman manis', 'makanan berat', 'snack murah'].map((suggestion) => (
            <Button
              key={suggestion}
              variant="outline"
              size="sm"
              className="h-7 text-xs"
              onClick={() => setQuery(suggestion)}
            >
              {suggestion}
            </Button>
          ))}
        </div>
      )}
    </div>
  )
}

4.3 Contoh AI Search dalam Aksi

Ini contoh bagaimana AI parse berbagai query:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  CONTOH 1: "kopi yang gak terlalu manis"                    β”‚
β”‚  ─────────────────────────────────────                      β”‚
β”‚  AI Parsed:                                                  β”‚
β”‚  {                                                           β”‚
β”‚    searchTerms: ["kopi"],                                   β”‚
β”‚    category: "Kopi",                                         β”‚
β”‚    priceRange: { min: null, max: null },                    β”‚
β”‚    sortBy: null,                                             β”‚
β”‚    attributes: { isSweet: false }                           β”‚
β”‚  }                                                           β”‚
β”‚                                                              β”‚
β”‚  Results: Espresso, Americano, Cold Brew                    β”‚
β”‚  (yang di deskripsi ada "pahit", "strong", "tanpa susu")    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  CONTOH 2: "minuman dingin yang murah"                      β”‚
β”‚  ─────────────────────────────────────                      β”‚
β”‚  AI Parsed:                                                  β”‚
β”‚  {                                                           β”‚
β”‚    searchTerms: ["minuman"],                                β”‚
β”‚    category: null,                                           β”‚
β”‚    priceRange: { min: null, max: 25000 },                   β”‚
β”‚    sortBy: "price_asc",                                      β”‚
β”‚    attributes: { isCold: true }                             β”‚
β”‚  }                                                           β”‚
β”‚                                                              β”‚
β”‚  Results: Es Teh Manis (10K), Es Kopi Susu (20K), dll       β”‚
β”‚  Sorted by price ascending                                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  CONTOH 3: "ada coklat gak?"                                β”‚
β”‚  ─────────────────────────────────────                      β”‚
β”‚  AI Parsed:                                                  β”‚
β”‚  {                                                           β”‚
β”‚    searchTerms: ["coklat", "chocolate", "moca"],            β”‚
β”‚    category: null,                                           β”‚
β”‚    priceRange: { min: null, max: null },                    β”‚
β”‚    sortBy: null,                                             β”‚
β”‚    attributes: { isSweet: true }                            β”‚
β”‚  }                                                           β”‚
β”‚                                                              β”‚
β”‚  Results: Mocha, Coklat Panas, Es Coklat, Brownies          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  CONTOH 4: "makan siang yang mengenyangkan"                 β”‚
β”‚  ─────────────────────────────────────────                  β”‚
β”‚  AI Parsed:                                                  β”‚
β”‚  {                                                           β”‚
β”‚    searchTerms: ["makan", "mengenyangkan", "berat"],        β”‚
β”‚    category: "Makanan",                                      β”‚
β”‚    priceRange: { min: 25000, max: null },                   β”‚
β”‚    sortBy: null                                              β”‚
β”‚  }                                                           β”‚
β”‚                                                              β”‚
β”‚  Results: Nasi Goreng Spesial, Mie Goreng, Sandwich Tuna    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

4.4 Testing Smart Search

Jalankan dev server dan test di browser atau Postman:

# Test via curl
curl -X POST <http://localhost:3000/api/search> \\
  -H "Content-Type: application/json" \\
  -d '{"query": "kopi dingin murah"}'

Expected response:

{
  "products": [
    {
      "id": "clx...",
      "name": "Es Kopi Susu",
      "description": "Kopi susu dingin klasik. Simple tapi nagih.",
      "price": 20000,
      "stock": 100,
      "category": { "name": "Kopi" }
    },
    {
      "id": "clx...",
      "name": "Cold Brew",
      "description": "Kopi yang diseduh dingin 12 jam. Smooth, less acidic, refreshing.",
      "price": 28000,
      "stock": 50,
      "category": { "name": "Kopi" }
    }
  ],
  "aiParsed": {
    "searchTerms": ["kopi"],
    "category": "Kopi",
    "priceRange": { "min": null, "max": 25000 },
    "sortBy": "price_asc",
    "attributes": { "isCold": true }
  },
  "originalQuery": "kopi dingin murah",
  "resultCount": 2
}

4.5 Handling Edge Cases

AI itu unpredictable. Kita perlu handle berbagai edge cases:

// Tambahan di src/app/api/search/route.ts

// 1. Query terlalu pendek
if (query.length < 2 && query.trim().length > 0) {
  // Lakukan simple search tanpa AI
  const products = await prisma.product.findMany({
    where: {
      OR: [
        { name: { contains: query, mode: 'insensitive' } },
        { sku: { contains: query, mode: 'insensitive' } }
      ],
      isActive: true
    },
    include: { category: true },
    take: 20
  })

  return NextResponse.json({
    products,
    aiParsed: null,
    source: 'simple_search'
  })
}

// 2. Query yang jelas bukan search (greeting, dll)
const nonSearchPatterns = /^(halo|hai|hi|hello|thanks|terima kasih|ok|oke)/i
if (nonSearchPatterns.test(query)) {
  // Return empty, biarkan chat handler yang handle
  return NextResponse.json({
    products: [],
    aiParsed: null,
    source: 'not_a_search',
    message: 'Query ini sepertinya bukan pencarian produk'
  })
}

// 3. Rate limiting (simple implementation)
// Di production, pakai Redis atau proper rate limiter

4.6 Performance Optimization

Untuk production, ada beberapa optimasi yang bisa dilakukan:

// 1. Cache AI parsing results
// File: src/lib/ai/cache.ts

const searchCache = new Map<string, { result: any; timestamp: number }>()
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes

export function getCachedSearch(query: string) {
  const cached = searchCache.get(query.toLowerCase())
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.result
  }
  return null
}

export function setCachedSearch(query: string, result: any) {
  searchCache.set(query.toLowerCase(), {
    result,
    timestamp: Date.now()
  })

  // Clean old entries
  if (searchCache.size > 1000) {
    const entries = Array.from(searchCache.entries())
    entries
      .sort((a, b) => a[1].timestamp - b[1].timestamp)
      .slice(0, 500)
      .forEach(([key]) => searchCache.delete(key))
  }
}

// 2. Parallel processing
// Di search route, jalankan AI parsing dan fallback query secara parallel

const [aiResult, fallbackProducts] = await Promise.all([
  parseQueryWithAI(query, categoryNames),
  prisma.product.findMany({
    where: {
      OR: [
        { name: { contains: query, mode: 'insensitive' } },
        { description: { contains: query, mode: 'insensitive' } }
      ],
      isActive: true
    },
    take: 10
  })
])

// Kalau AI parsing gagal, langsung pakai fallback


πŸ’‘ Mini Tips #4

Gunakan temperature: 0 untuk parsing tasks seperti smart search. Kita butuh output yang konsisten dan predictable. Save temperature yang lebih tinggi untuk chatbot responses dimana variasi itu bagus.


Checkpoint! πŸŽ‰

Sampai sini kita sudah punya:

  • βœ… LangChain configuration dengan multiple model configs
  • βœ… System prompts yang well-structured
  • βœ… 5 custom AI tools untuk operasional kasir
  • βœ… Smart Search API yang parse natural language
  • βœ… Search component dengan AI indicator
  • βœ… Edge case handling dan caching strategy

Fitur Smart Search ini sendiri sudah sangat powerful. Kasir bisa ketik "minuman dingin murah" dan langsung dapat hasil yang relevan β€” tanpa harus scroll-scroll manual.

Di bagian selanjutnya, kita akan build AI Chat Assistant yang bisa jawab pertanyaan dan execute tools secara real-time dengan streaming response. Let's go! πŸš€


Bagian 5: AI Chat Assistant dengan Streaming

Sekarang kita masuk ke fitur yang paling "wow" β€” Chat Assistant yang bisa mikir, akses database, dan jawab secara real-time! πŸ’¬

5.1 Kenapa Streaming Penting?

Sebelum kita coding, saya mau jelasin dulu kenapa streaming itu game changer:

TANPA STREAMING vs DENGAN STREAMING

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  TANPA STREAMING                                             β”‚
β”‚  ────────────────                                            β”‚
β”‚  User: "Stok apa yang menipis?"                             β”‚
β”‚  [Loading... 3 detik... 5 detik... 8 detik...]              β”‚
β”‚  AI: "Berikut produk dengan stok menipis: ..."              β”‚
β”‚                                                              β”‚
β”‚  Problem: User nungguin, gak tau progress, bisa frustasi    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  DENGAN STREAMING                                            β”‚
β”‚  ───────────────                                             β”‚
β”‚  User: "Stok apa yang menipis?"                             β”‚
β”‚  AI: "Baik, saya cek..."                                    β”‚
β”‚  AI: "Berikut produk dengan stok menipis:\\n"                β”‚
β”‚  AI: "🟠 KRITIS Cheesecake: 5 unit\\n"                       β”‚
β”‚  AI: "🟑 MENIPIS Croissant: 8 unit\\n"                       β”‚
β”‚  AI: "..."                                                   β”‚
β”‚                                                              β”‚
β”‚  Benefit: User langsung lihat progress, feels responsive    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Streaming bikin UX jauh lebih baik karena user gak ngerasa "ditinggalin". Mereka bisa mulai baca response sambil AI masih generate sisanya.

5.2 Chat API dengan Tool Calling & Streaming

Ini bagian yang agak complex, tapi saya akan breakdown step by step:

// src/app/api/chat/route.ts

import { NextRequest } from 'next/server'
import { ChatOpenAI } from '@langchain/openai'
import { ChatPromptTemplate, MessagesPlaceholder } from '@langchain/core/prompts'
import { createToolCallingAgent, AgentExecutor } from 'langchain/agents'
import { AIMessage, HumanMessage } from '@langchain/core/messages'
import { allPosTools } from '@/lib/ai/tools'
import { POS_ASSISTANT_SYSTEM_PROMPT } from '@/lib/ai/prompts'
import { prisma } from '@/lib/db'
import { formatCurrency } from '@/lib/utils'

// Penting: pakai nodejs runtime untuk Prisma
export const runtime = 'nodejs'

export async function POST(req: NextRequest) {
  try {
    const { messages } = await req.json()

    if (!messages || !Array.isArray(messages) || messages.length === 0) {
      return new Response(
        JSON.stringify({ error: 'Messages array is required' }),
        { status: 400 }
      )
    }

    // ============================================
    // 1. GATHER CONTEXT DATA
    // ============================================
    const [
      productStats,
      lowStockProducts,
      todaySales,
      recentTransactions
    ] = await Promise.all([
      // Total produk aktif
      prisma.product.count({ where: { isActive: true } }),

      // Produk stok menipis
      prisma.product.findMany({
        where: {
          isActive: true,
          stock: { lte: 10 }
        },
        select: { name: true, stock: true },
        orderBy: { stock: 'asc' },
        take: 5
      }),

      // Penjualan hari ini
      prisma.transaction.aggregate({
        where: {
          createdAt: {
            gte: new Date(new Date().setHours(0, 0, 0, 0))
          },
          status: 'completed'
        },
        _sum: { total: true },
        _count: true
      }),

      // Transaksi terakhir
      prisma.transaction.findMany({
        where: { status: 'completed' },
        orderBy: { createdAt: 'desc' },
        take: 3,
        select: {
          invoiceNumber: true,
          total: true,
          paymentMethod: true,
          createdAt: true
        }
      })
    ])

    // Format context untuk AI
    const contextData = `
πŸ“Š STATUS TOKO SAAT INI:
β€’ Total produk aktif: ${productStats}
β€’ Penjualan hari ini: ${formatCurrency(todaySales._sum.total || 0)} (${todaySales._count} transaksi)

⚠️ STOK MENIPIS:
${lowStockProducts.length > 0
  ? lowStockProducts.map(p => `β€’ ${p.name}: ${p.stock} unit`).join('\\n')
  : 'β€’ Semua stok aman!'
}

🧾 TRANSAKSI TERAKHIR:
${recentTransactions.map(t =>
  `β€’ ${t.invoiceNumber}: ${formatCurrency(t.total)} (${t.paymentMethod})`
).join('\\n')}
    `.trim()

    // ============================================
    // 2. SETUP LLM WITH STREAMING
    // ============================================
    const llm = new ChatOpenAI({
      modelName: 'gpt-4o-mini',
      temperature: 0.4,
      streaming: true,
    })

    // ============================================
    // 3. CREATE PROMPT TEMPLATE
    // ============================================
    const prompt = ChatPromptTemplate.fromMessages([
      ['system', POS_ASSISTANT_SYSTEM_PROMPT],
      new MessagesPlaceholder('chat_history'),
      ['human', '{input}'],
      new MessagesPlaceholder('agent_scratchpad'),
    ])

    // ============================================
    // 4. CREATE AGENT WITH TOOLS
    // ============================================
    const agent = createToolCallingAgent({
      llm,
      tools: allPosTools,
      prompt
    })

    const executor = new AgentExecutor({
      agent,
      tools: allPosTools,
      verbose: process.env.NODE_ENV === 'development',
      maxIterations: 5, // Prevent infinite loops
      returnIntermediateSteps: false,
    })

    // ============================================
    // 5. CONVERT MESSAGES TO LANGCHAIN FORMAT
    // ============================================
    const chatHistory = messages.slice(0, -1).map((msg: any) =>
      msg.role === 'user'
        ? new HumanMessage(msg.content)
        : new AIMessage(msg.content)
    )

    const lastMessage = messages[messages.length - 1].content

    // ============================================
    // 6. CREATE STREAMING RESPONSE
    // ============================================
    const encoder = new TextEncoder()

    const stream = new ReadableStream({
      async start(controller) {
        try {
          // Get current date formatted
          const currentDate = new Date().toLocaleDateString('id-ID', {
            weekday: 'long',
            year: 'numeric',
            month: 'long',
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit'
          })

          // Invoke agent
          const response = await executor.invoke({
            input: lastMessage,
            chat_history: chatHistory,
            context: contextData,
            currentDate
          })

          const output = response.output

          // Stream character by character untuk typing effect
          // Dalam production, bisa adjust chunk size untuk performance
          const chunkSize = 3 // 3 characters at a time

          for (let i = 0; i < output.length; i += chunkSize) {
            const chunk = output.slice(i, i + chunkSize)
            const data = JSON.stringify({
              type: 'content',
              content: chunk
            })
            controller.enqueue(encoder.encode(`data: ${data}\\n\\n`))

            // Small delay untuk natural typing effect
            await new Promise(resolve => setTimeout(resolve, 15))
          }

          // Send completion signal
          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\\n\\n`))

        } catch (error) {
          console.error('Agent execution error:', error)

          const errorMessage = error instanceof Error
            ? error.message
            : 'Terjadi kesalahan'

          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({
              type: 'error',
              error: errorMessage
            })}\\n\\n`)
          )
        } finally {
          controller.close()
        }
      }
    })

    return new Response(stream, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache, no-transform',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no', // Disable nginx buffering
      },
    })

  } catch (error) {
    console.error('Chat API error:', error)
    return new Response(
      JSON.stringify({ error: 'Chat failed' }),
      { status: 500 }
    )
  }
}

Breakdown kode di atas:

  1. Gather Context β€” Kita kumpulkan data real-time dari database (stok, penjualan, dll) supaya AI punya informasi terkini
  2. Setup LLM β€” Pakai streaming mode supaya response keluar bertahap
  3. Create Agent β€” Agent ini punya kemampuan untuk panggil tools
  4. Convert Messages β€” Format conversation history ke format LangChain
  5. Stream Response β€” Kirim response character by character untuk typing effect

5.3 Chat Component dengan Streaming UI

// src/components/pos/AIAssistant.tsx

'use client'

import { useState, useRef, useEffect, useCallback } from 'react'
import {
  Send,
  Bot,
  User,
  Loader2,
  Sparkles,
  Trash2,
  ChevronDown,
  AlertCircle
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'

interface Message {
  id: string
  role: 'user' | 'assistant'
  content: string
  timestamp: Date
  isStreaming?: boolean
  isError?: boolean
}

interface AIAssistantProps {
  className?: string
  onProductSelect?: (productId: string) => void
}

export function AIAssistant({ className, onProductSelect }: AIAssistantProps) {
  const [messages, setMessages] = useState<Message[]>([
    {
      id: 'welcome',
      role: 'assistant',
      content: `Halo! πŸ‘‹ Saya **Kasir AI**, siap membantu kamu.

Beberapa hal yang bisa saya bantu:
β€’ Cari produk ("cari kopi dingin")
β€’ Cek stok ("stok apa yang menipis?")
β€’ Hitung diskon ("hitung diskon 15% dari 100rb")
β€’ Laporan penjualan ("penjualan hari ini gimana?")

Silakan tanya apa saja! 😊`,
      timestamp: new Date()
    }
  ])
  const [input, setInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [streamingContent, setStreamingContent] = useState('')
  const [showScrollButton, setShowScrollButton] = useState(false)

  const scrollAreaRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const abortControllerRef = useRef<AbortController | null>(null)

  // Auto-scroll to bottom
  const scrollToBottom = useCallback(() => {
    if (scrollAreaRef.current) {
      const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]')
      if (scrollContainer) {
        scrollContainer.scrollTop = scrollContainer.scrollHeight
      }
    }
  }, [])

  useEffect(() => {
    scrollToBottom()
  }, [messages, streamingContent, scrollToBottom])

  // Handle scroll position untuk show/hide scroll button
  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    const target = e.target as HTMLDivElement
    const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100
    setShowScrollButton(!isNearBottom)
  }, [])

  // Generate unique ID
  const generateId = () => `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`

  // Submit handler
  const handleSubmit = async (e?: React.FormEvent) => {
    e?.preventDefault()

    const trimmedInput = input.trim()
    if (!trimmedInput || isLoading) return

    // Clear input immediately
    setInput('')

    // Add user message
    const userMessage: Message = {
      id: generateId(),
      role: 'user',
      content: trimmedInput,
      timestamp: new Date()
    }

    setMessages(prev => [...prev, userMessage])
    setIsLoading(true)
    setStreamingContent('')

    // Create abort controller for cancellation
    abortControllerRef.current = new AbortController()

    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          messages: [...messages, userMessage].map(m => ({
            role: m.role,
            content: m.content
          }))
        }),
        signal: abortControllerRef.current.signal
      })

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      if (!response.body) {
        throw new Error('No response body')
      }

      // Process streaming response
      const reader = response.body.getReader()
      const decoder = new TextDecoder()
      let fullContent = ''

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        const chunk = decoder.decode(value, { stream: true })
        const lines = chunk.split('\\n')

        for (const line of lines) {
          if (!line.startsWith('data: ')) continue

          const data = line.slice(6) // Remove 'data: ' prefix
          if (!data) continue

          try {
            const parsed = JSON.parse(data)

            if (parsed.type === 'content') {
              fullContent += parsed.content
              setStreamingContent(fullContent)
            } else if (parsed.type === 'done') {
              // Streaming complete - add as final message
              setMessages(prev => [...prev, {
                id: generateId(),
                role: 'assistant',
                content: fullContent,
                timestamp: new Date()
              }])
              setStreamingContent('')
            } else if (parsed.type === 'error') {
              throw new Error(parsed.error)
            }
          } catch (parseError) {
            // Skip invalid JSON
          }
        }
      }

    } catch (error) {
      if (error instanceof Error && error.name === 'AbortError') {
        // Request was cancelled
        console.log('Request cancelled')
      } else {
        console.error('Chat error:', error)

        // Add error message
        setMessages(prev => [...prev, {
          id: generateId(),
          role: 'assistant',
          content: 'Maaf, terjadi kesalahan. Silakan coba lagi.',
          timestamp: new Date(),
          isError: true
        }])
      }
      setStreamingContent('')
    } finally {
      setIsLoading(false)
      abortControllerRef.current = null
      inputRef.current?.focus()
    }
  }

  // Cancel ongoing request
  const cancelRequest = () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort()
    }
  }

  // Clear chat history
  const clearChat = () => {
    setMessages([messages[0]]) // Keep welcome message
    setStreamingContent('')
  }

  // Quick action buttons
  const quickActions = [
    { label: 'πŸ“¦ Stok menipis', query: 'Stok apa yang menipis?' },
    { label: 'πŸ’° Penjualan hari ini', query: 'Bagaimana penjualan hari ini?' },
    { label: 'πŸ† Produk terlaris', query: 'Produk apa yang paling laris minggu ini?' },
    { label: 'πŸ’³ Metode bayar', query: 'Breakdown pembayaran hari ini' },
  ]

  // Format message content (simple markdown-like)
  const formatContent = (content: string) => {
    return content
      .replace(/\\*\\*(.*?)\\*\\*/g, '<strong>$1</strong>')
      .replace(/\\n/g, '<br/>')
  }

  return (
    <div className={cn(
      'flex flex-col h-full border rounded-lg bg-background overflow-hidden',
      className
    )}>
      {/* Header */}
      <div className="flex items-center justify-between p-3 border-b bg-muted/30">
        <div className="flex items-center gap-2">
          <div className="relative">
            <div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center">
              <Bot className="h-5 w-5 text-primary" />
            </div>
            <Sparkles className="absolute -top-1 -right-1 h-4 w-4 text-yellow-500" />
          </div>
          <div>
            <h3 className="font-semibold text-sm">Kasir AI</h3>
            <p className="text-xs text-muted-foreground">
              {isLoading ? 'Sedang mengetik...' : 'Online'}
            </p>
          </div>
        </div>

        <Button
          variant="ghost"
          size="icon"
          className="h-8 w-8"
          onClick={clearChat}
          title="Hapus percakapan"
        >
          <Trash2 className="h-4 w-4" />
        </Button>
      </div>

      {/* Messages */}
      <ScrollArea
        ref={scrollAreaRef}
        className="flex-1 p-4"
        onScrollCapture={handleScroll as any}
      >
        <div className="space-y-4">
          {messages.map((message) => (
            <div
              key={message.id}
              className={cn(
                'flex gap-3',
                message.role === 'user' ? 'justify-end' : 'justify-start'
              )}
            >
              {message.role === 'assistant' && (
                <div className={cn(
                  'h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0',
                  message.isError ? 'bg-destructive/10' : 'bg-primary/10'
                )}>
                  {message.isError ? (
                    <AlertCircle className="h-4 w-4 text-destructive" />
                  ) : (
                    <Bot className="h-4 w-4 text-primary" />
                  )}
                </div>
              )}

              <div
                className={cn(
                  'max-w-[85%] rounded-2xl px-4 py-2.5',
                  message.role === 'user'
                    ? 'bg-primary text-primary-foreground rounded-br-md'
                    : message.isError
                      ? 'bg-destructive/10 text-destructive rounded-bl-md'
                      : 'bg-muted rounded-bl-md'
                )}
              >
                <div
                  className="text-sm whitespace-pre-wrap"
                  dangerouslySetInnerHTML={{
                    __html: formatContent(message.content)
                  }}
                />
                <span className="text-[10px] opacity-60 mt-1 block">
                  {message.timestamp.toLocaleTimeString('id-ID', {
                    hour: '2-digit',
                    minute: '2-digit'
                  })}
                </span>
              </div>

              {message.role === 'user' && (
                <div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
                  <User className="h-4 w-4 text-primary-foreground" />
                </div>
              )}
            </div>
          ))}

          {/* Streaming content */}
          {streamingContent && (
            <div className="flex gap-3 justify-start">
              <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
                <Bot className="h-4 w-4 text-primary" />
              </div>
              <div className="max-w-[85%] rounded-2xl rounded-bl-md px-4 py-2.5 bg-muted">
                <div
                  className="text-sm whitespace-pre-wrap"
                  dangerouslySetInnerHTML={{
                    __html: formatContent(streamingContent)
                  }}
                />
                <span className="inline-block w-2 h-4 bg-primary animate-pulse ml-0.5 rounded-sm" />
              </div>
            </div>
          )}

          {/* Loading indicator (before streaming starts) */}
          {isLoading && !streamingContent && (
            <div className="flex gap-3 justify-start">
              <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
                <Loader2 className="h-4 w-4 text-primary animate-spin" />
              </div>
              <div className="bg-muted rounded-2xl rounded-bl-md px-4 py-3">
                <div className="flex gap-1">
                  <div className="h-2 w-2 rounded-full bg-primary/40 animate-bounce" style={{ animationDelay: '0ms' }} />
                  <div className="h-2 w-2 rounded-full bg-primary/40 animate-bounce" style={{ animationDelay: '150ms' }} />
                  <div className="h-2 w-2 rounded-full bg-primary/40 animate-bounce" style={{ animationDelay: '300ms' }} />
                </div>
              </div>
            </div>
          )}
        </div>
      </ScrollArea>

      {/* Scroll to bottom button */}
      {showScrollButton && (
        <div className="absolute bottom-24 left-1/2 -translate-x-1/2">
          <Button
            variant="secondary"
            size="sm"
            className="rounded-full shadow-lg"
            onClick={scrollToBottom}
          >
            <ChevronDown className="h-4 w-4" />
          </Button>
        </div>
      )}

      {/* Quick Actions */}
      {messages.length <= 2 && !isLoading && (
        <div className="px-4 py-2 border-t bg-muted/20">
          <p className="text-xs text-muted-foreground mb-2">Coba tanya:</p>
          <div className="flex flex-wrap gap-2">
            {quickActions.map((action) => (
              <Button
                key={action.label}
                variant="outline"
                size="sm"
                className="text-xs h-7"
                onClick={() => {
                  setInput(action.query)
                  setTimeout(() => handleSubmit(), 100)
                }}
              >
                {action.label}
              </Button>
            ))}
          </div>
        </div>
      )}

      {/* Input */}
      <form onSubmit={handleSubmit} className="p-3 border-t bg-background">
        <div className="flex gap-2">
          <Input
            ref={inputRef}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Ketik pertanyaan..."
            disabled={isLoading}
            className="flex-1"
            autoComplete="off"
          />
          {isLoading ? (
            <Button
              type="button"
              variant="destructive"
              onClick={cancelRequest}
            >
              Stop
            </Button>
          ) : (
            <Button type="submit" disabled={!input.trim()}>
              <Send className="h-4 w-4" />
            </Button>
          )}
        </div>
      </form>
    </div>
  )
}

5.4 Contoh Interaksi AI Assistant

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  πŸ—£οΈ USER: "Stok apa yang menipis?"                          β”‚
β”‚                                                              β”‚
β”‚  πŸ€– KASIR AI:                                                β”‚
β”‚  ⚠️ Produk dengan stok menipis:                             β”‚
β”‚                                                              β”‚
β”‚  πŸ”΄ HABIS                                                    β”‚
β”‚  (tidak ada)                                                 β”‚
β”‚                                                              β”‚
β”‚  🟠 KRITIS (≀5 unit)                                        β”‚
β”‚  β€’ Cheesecake: 5 unit                                       β”‚
β”‚                                                              β”‚
β”‚  🟑 MENIPIS (≀10 unit)                                      β”‚
β”‚  β€’ Croissant: 8 unit                                        β”‚
β”‚  β€’ Mango Smoothie: 8 unit                                   β”‚
β”‚                                                              β”‚
β”‚  πŸ’‘ Rekomendasi: Segera restock Cheesecake karena           β”‚
β”‚  statusnya KRITIS.                                           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  πŸ—£οΈ USER: "Hitung kalau customer belanja 150rb terus        β”‚
β”‚           minta diskon 20%"                                  β”‚
β”‚                                                              β”‚
β”‚  πŸ€– KASIR AI:                                                β”‚
β”‚  πŸ“ Kalkulasi:                                               β”‚
β”‚                                                              β”‚
β”‚  Subtotal: Rp 150.000                                       β”‚
β”‚  Diskon (20%): -Rp 30.000                                   β”‚
β”‚  ─────────────────                                          β”‚
β”‚  Setelah Diskon: Rp 120.000                                 β”‚
β”‚  PPN 10%: +Rp 12.000                                        β”‚
β”‚  ─────────────────                                          β”‚
β”‚  TOTAL: Rp 132.000                                          β”‚
β”‚                                                              β”‚
β”‚  πŸ’‘ Customer hemat Rp 30.000 dengan diskon ini!             β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  πŸ—£οΈ USER: "Cari minuman yang cocok buat yang gak suka       β”‚
β”‚           kopi"                                              β”‚
β”‚                                                              β”‚
β”‚  πŸ€– KASIR AI:                                                β”‚
β”‚  Ditemukan 10 produk minuman non-kopi:                      β”‚
β”‚                                                              β”‚
β”‚  β€’ Matcha Latte (Non-Kopi) - Rp 30.000 - Stok: 60          β”‚
β”‚  β€’ Taro Latte (Non-Kopi) - Rp 28.000 - Stok: 55            β”‚
β”‚  β€’ Thai Tea (Non-Kopi) - Rp 22.000 - Stok: 65              β”‚
β”‚  β€’ Es Coklat (Non-Kopi) - Rp 25.000 - Stok: 75             β”‚
β”‚  β€’ Strawberry Smoothie (Non-Kopi) - Rp 30.000 - Stok: 40   β”‚
β”‚  ...                                                         β”‚
β”‚                                                              β”‚
β”‚  πŸ’‘ Rekomendasi: Thai Tea paling populer untuk yang gak     β”‚
β”‚  suka kopi! Rasanya manis dan creamy.                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

5.5 Error Handling & Edge Cases

// Tambahan error handling di chat route

// 1. Rate limiting sederhana
const rateLimitMap = new Map<string, number[]>()
const RATE_LIMIT = 20 // requests per minute
const RATE_WINDOW = 60 * 1000 // 1 minute

function checkRateLimit(ip: string): boolean {
  const now = Date.now()
  const requests = rateLimitMap.get(ip) || []

  // Filter requests dalam window
  const recentRequests = requests.filter(time => now - time < RATE_WINDOW)

  if (recentRequests.length >= RATE_LIMIT) {
    return false // Rate limited
  }

  recentRequests.push(now)
  rateLimitMap.set(ip, recentRequests)
  return true
}

// Di awal POST handler:
const ip = req.headers.get('x-forwarded-for') || 'unknown'
if (!checkRateLimit(ip)) {
  return new Response(
    JSON.stringify({ error: 'Terlalu banyak request. Tunggu sebentar ya.' }),
    { status: 429 }
  )
}

// 2. Input validation
const MAX_MESSAGE_LENGTH = 1000
const lastMessage = messages[messages.length - 1].content

if (lastMessage.length > MAX_MESSAGE_LENGTH) {
  return new Response(
    JSON.stringify({ error: 'Pesan terlalu panjang. Maksimal 1000 karakter.' }),
    { status: 400 }
  )
}

// 3. Timeout handling
const TIMEOUT = 30000 // 30 seconds

const timeoutPromise = new Promise((_, reject) => {
  setTimeout(() => reject(new Error('Request timeout')), TIMEOUT)
})

try {
  const response = await Promise.race([
    executor.invoke({ /* ... */ }),
    timeoutPromise
  ])
  // ...
} catch (error) {
  if (error.message === 'Request timeout') {
    // Send timeout response
    controller.enqueue(
      encoder.encode(`data: ${JSON.stringify({
        type: 'error',
        error: 'Maaf, permintaan terlalu lama. Coba lagi dengan pertanyaan yang lebih sederhana.'
      })}\\n\\n`)
    )
  }
}


πŸ’‘ Mini Tips #5

Streaming response itu bukan cuma soal UX β€” ini juga soal perceived performance. User yang lihat text muncul bertahap akan merasa response "cepat" meskipun total waktu sama dengan non-streaming. Psychology matters!


Bagian 6: Natural Language Analytics

Fitur terakhir yang kita build adalah analytics yang bisa ditanya pakai bahasa natural. Owner gak perlu buka dashboard ribet β€” tinggal tanya! πŸ“Š

6.1 Analytics API Endpoint

// src/app/api/analytics/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { ChatOpenAI } from '@langchain/openai'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'
import { prisma } from '@/lib/db'
import { ANALYTICS_PROMPT } from '@/lib/ai/prompts'
import { formatCurrency } from '@/lib/utils'

export const runtime = 'nodejs'

interface AnalyticsRequest {
  question: string
  dateRange?: 'today' | 'yesterday' | 'week' | 'month' | 'custom'
  startDate?: string
  endDate?: string
}

export async function POST(req: NextRequest) {
  try {
    const body: AnalyticsRequest = await req.json()
    const { question, dateRange = 'week' } = body

    if (!question || question.trim().length === 0) {
      return NextResponse.json(
        { error: 'Pertanyaan tidak boleh kosong' },
        { status: 400 }
      )
    }

    // ============================================
    // 1. DETERMINE DATE RANGE
    // ============================================
    const now = new Date()
    let startDate: Date
    let endDate = now
    let periodLabel: string

    switch (dateRange) {
      case 'today':
        startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
        periodLabel = 'Hari ini'
        break
      case 'yesterday':
        startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
        endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
        periodLabel = 'Kemarin'
        break
      case 'week':
        startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
        periodLabel = '7 hari terakhir'
        break
      case 'month':
        startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate())
        periodLabel = '30 hari terakhir'
        break
      case 'custom':
        startDate = body.startDate ? new Date(body.startDate) : new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
        endDate = body.endDate ? new Date(body.endDate) : now
        periodLabel = 'Custom range'
        break
      default:
        startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
        periodLabel = '7 hari terakhir'
    }

    // ============================================
    // 2. FETCH COMPREHENSIVE DATA
    // ============================================
    const [
      // Overall stats
      overallStats,

      // Transaction count by day
      dailyTransactions,

      // Top products
      topProducts,

      // Category breakdown
      categoryStats,

      // Payment methods
      paymentStats,

      // Hourly distribution
      hourlyStats,

      // Cashier performance (jika ada)
      cashierStats
    ] = await Promise.all([
      // 1. Overall aggregate
      prisma.transaction.aggregate({
        where: {
          createdAt: { gte: startDate, lte: endDate },
          status: 'completed'
        },
        _sum: { total: true, discountAmount: true, taxAmount: true },
        _avg: { total: true },
        _count: true,
        _min: { total: true },
        _max: { total: true }
      }),

      // 2. Daily breakdown - using raw query for grouping
      prisma.$queryRaw`
        SELECT
          date(createdAt) as date,
          COUNT(*) as transactions,
          SUM(total) as revenue
        FROM Transaction
        WHERE createdAt >= ${startDate}
          AND createdAt <= ${endDate}
          AND status = 'completed'
        GROUP BY date(createdAt)
        ORDER BY date DESC
      ` as Promise<Array<{ date: string; transactions: number; revenue: number }>>,

      // 3. Top products
      prisma.transactionItem.groupBy({
        by: ['productId', 'productName'],
        where: {
          transaction: {
            createdAt: { gte: startDate, lte: endDate },
            status: 'completed'
          }
        },
        _sum: { quantity: true, subtotal: true },
        orderBy: { _sum: { subtotal: 'desc' } },
        take: 10
      }),

      // 4. Category stats
      prisma.$queryRaw`
        SELECT
          p.categoryId,
          c.name as categoryName,
          COUNT(ti.id) as itemsSold,
          SUM(ti.subtotal) as revenue
        FROM TransactionItem ti
        JOIN Product p ON ti.productId = p.id
        JOIN Category c ON p.categoryId = c.id
        JOIN Transaction t ON ti.transactionId = t.id
        WHERE t.createdAt >= ${startDate}
          AND t.createdAt <= ${endDate}
          AND t.status = 'completed'
        GROUP BY p.categoryId, c.name
        ORDER BY revenue DESC
      ` as Promise<Array<{ categoryName: string; itemsSold: number; revenue: number }>>,

      // 5. Payment methods
      prisma.transaction.groupBy({
        by: ['paymentMethod'],
        where: {
          createdAt: { gte: startDate, lte: endDate },
          status: 'completed'
        },
        _count: true,
        _sum: { total: true }
      }),

      // 6. Hourly distribution
      prisma.$queryRaw`
        SELECT
          strftime('%H', createdAt) as hour,
          COUNT(*) as transactions,
          SUM(total) as revenue
        FROM Transaction
        WHERE createdAt >= ${startDate}
          AND createdAt <= ${endDate}
          AND status = 'completed'
        GROUP BY strftime('%H', createdAt)
        ORDER BY hour
      ` as Promise<Array<{ hour: string; transactions: number; revenue: number }>>,

      // 7. Cashier performance
      prisma.transaction.groupBy({
        by: ['cashierName'],
        where: {
          createdAt: { gte: startDate, lte: endDate },
          status: 'completed',
          cashierName: { not: null }
        },
        _count: true,
        _sum: { total: true },
        _avg: { total: true }
      })
    ])

    // ============================================
    // 3. FORMAT DATA FOR AI
    // ============================================
    const analyticsData = {
      period: {
        label: periodLabel,
        start: startDate.toISOString(),
        end: endDate.toISOString()
      },

      summary: {
        totalRevenue: overallStats._sum.total || 0,
        totalTransactions: overallStats._count || 0,
        averageTransaction: overallStats._avg.total || 0,
        totalDiscount: overallStats._sum.discountAmount || 0,
        totalTax: overallStats._sum.taxAmount || 0,
        minTransaction: overallStats._min.total || 0,
        maxTransaction: overallStats._max.total || 0
      },

      dailyBreakdown: dailyTransactions,

      topProducts: topProducts.map((p, i) => ({
        rank: i + 1,
        name: p.productName,
        quantitySold: p._sum.quantity || 0,
        revenue: p._sum.subtotal || 0
      })),

      categoryBreakdown: categoryStats,

      paymentMethods: paymentStats.map(pm => ({
        method: pm.paymentMethod,
        transactions: pm._count,
        total: pm._sum.total || 0,
        percentage: overallStats._count > 0
          ? ((pm._count / overallStats._count) * 100).toFixed(1)
          : 0
      })),

      hourlyDistribution: hourlyStats,

      cashierPerformance: cashierStats.map(c => ({
        name: c.cashierName,
        transactions: c._count,
        totalSales: c._sum.total || 0,
        averageTransaction: c._avg.total || 0
      }))
    }

    // ============================================
    // 4. GENERATE AI ANALYSIS
    // ============================================
    const llm = new ChatOpenAI({
      modelName: 'gpt-4o-mini',
      temperature: 0.3,
      maxTokens: 1500
    })

    const prompt = ChatPromptTemplate.fromTemplate(ANALYTICS_PROMPT)
    const chain = prompt.pipe(llm).pipe(new StringOutputParser())

    const analysis = await chain.invoke({
      data: JSON.stringify(analyticsData, null, 2),
      question
    })

    // ============================================
    // 5. RETURN RESPONSE
    // ============================================
    return NextResponse.json({
      analysis,
      data: analyticsData,
      metadata: {
        question,
        dateRange,
        generatedAt: new Date().toISOString()
      }
    })

  } catch (error) {
    console.error('Analytics error:', error)
    return NextResponse.json(
      { error: 'Gagal generate analytics', details: String(error) },
      { status: 500 }
    )
  }
}

6.2 Analytics Dashboard Page

// src/app/analytics/page.tsx

'use client'

import { useState } from 'react'
import {
  BarChart3,
  TrendingUp,
  TrendingDown,
  MessageSquare,
  Loader2,
  Calendar,
  DollarSign,
  ShoppingCart,
  CreditCard,
  Clock
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'

interface AnalyticsData {
  summary: {
    totalRevenue: number
    totalTransactions: number
    averageTransaction: number
    totalDiscount: number
  }
  topProducts: Array<{
    rank: number
    name: string
    quantitySold: number
    revenue: number
  }>
  categoryBreakdown: Array<{
    categoryName: string
    itemsSold: number
    revenue: number
  }>
  paymentMethods: Array<{
    method: string
    transactions: number
    total: number
    percentage: string
  }>
}

export default function AnalyticsPage() {
  const [question, setQuestion] = useState('')
  const [dateRange, setDateRange] = useState<string>('week')
  const [analysis, setAnalysis] = useState<string>('')
  const [data, setData] = useState<AnalyticsData | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleAnalyze = async () => {
    if (!question.trim()) return

    setIsLoading(true)
    setError(null)

    try {
      const res = await fetch('/api/analytics', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ question, dateRange })
      })

      if (!res.ok) {
        throw new Error('Gagal mendapatkan data analytics')
      }

      const result = await res.json()
      setAnalysis(result.analysis)
      setData(result.data)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Terjadi kesalahan')
    } finally {
      setIsLoading(false)
    }
  }

  // Preset questions
  const presetQuestions = [
    { icon: 'πŸ’°', label: 'Total penjualan', query: 'Berapa total penjualan dan rata-rata per transaksi?' },
    { icon: 'πŸ†', label: 'Produk terlaris', query: 'Apa saja 5 produk terlaris dan berapa kontribusinya?' },
    { icon: 'πŸ“Š', label: 'Kategori terbaik', query: 'Kategori mana yang paling menguntungkan?' },
    { icon: 'πŸ’³', label: 'Metode bayar', query: 'Bagaimana breakdown metode pembayaran?' },
    { icon: '⏰', label: 'Jam sibuk', query: 'Jam berapa penjualan paling ramai?' },
    { icon: 'πŸ“ˆ', label: 'Trend harian', query: 'Bagaimana trend penjualan harian?' },
    { icon: 'πŸ‘€', label: 'Performa kasir', query: 'Siapa kasir dengan penjualan tertinggi?' },
    { icon: '🎯', label: 'Ringkasan lengkap', query: 'Berikan ringkasan lengkap performa penjualan' },
  ]

  // Payment method labels
  const paymentLabels: Record<string, { label: string; icon: string }> = {
    cash: { label: 'Tunai', icon: 'πŸ’΅' },
    qris: { label: 'QRIS', icon: 'πŸ“±' },
    card: { label: 'Kartu', icon: 'πŸ’³' },
    transfer: { label: 'Transfer', icon: '🏦' }
  }

  return (
    <div className="min-h-screen bg-muted/30">
      {/* Header */}
      <div className="bg-background border-b">
        <div className="container mx-auto px-4 py-6">
          <div className="flex items-center gap-3">
            <div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center">
              <BarChart3 className="h-6 w-6 text-primary" />
            </div>
            <div>
              <h1 className="text-2xl font-bold">AI Analytics</h1>
              <p className="text-muted-foreground">
                Tanya apapun tentang data penjualan dengan bahasa natural
              </p>
            </div>
          </div>
        </div>
      </div>

      <div className="container mx-auto px-4 py-6 space-y-6">
        {/* Query Section */}
        <Card>
          <CardHeader>
            <CardTitle className="flex items-center gap-2">
              <MessageSquare className="h-5 w-5" />
              Tanya AI Analyst
            </CardTitle>
            <CardDescription>
              Ketik pertanyaan dalam bahasa Indonesia, AI akan menganalisis data dan memberikan insight
            </CardDescription>
          </CardHeader>
          <CardContent className="space-y-4">
            {/* Date Range & Input */}
            <div className="flex flex-col sm:flex-row gap-3">
              <Select value={dateRange} onValueChange={setDateRange}>
                <SelectTrigger className="w-full sm:w-[180px]">
                  <Calendar className="h-4 w-4 mr-2" />
                  <SelectValue placeholder="Pilih periode" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="today">Hari ini</SelectItem>
                  <SelectItem value="yesterday">Kemarin</SelectItem>
                  <SelectItem value="week">7 hari terakhir</SelectItem>
                  <SelectItem value="month">30 hari terakhir</SelectItem>
                </SelectContent>
              </Select>

              <div className="flex-1 flex gap-2">
                <Input
                  value={question}
                  onChange={(e) => setQuestion(e.target.value)}
                  placeholder="Contoh: Produk apa yang paling laris minggu ini?"
                  onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
                  className="flex-1"
                />
                <Button onClick={handleAnalyze} disabled={isLoading || !question.trim()}>
                  {isLoading ? (
                    <>
                      <Loader2 className="h-4 w-4 mr-2 animate-spin" />
                      Analyzing...
                    </>
                  ) : (
                    'Analyze'
                  )}
                </Button>
              </div>
            </div>

            {/* Preset Questions */}
            <div className="space-y-2">
              <p className="text-sm text-muted-foreground">Pertanyaan populer:</p>
              <div className="flex flex-wrap gap-2">
                {presetQuestions.map((preset) => (
                  <Button
                    key={preset.label}
                    variant="outline"
                    size="sm"
                    className="text-xs"
                    onClick={() => {
                      setQuestion(preset.query)
                      // Auto submit after short delay
                      setTimeout(() => {
                        const btn = document.querySelector('button[type="submit"]') as HTMLButtonElement
                        btn?.click()
                      }, 100)
                    }}
                  >
                    <span className="mr-1">{preset.icon}</span>
                    {preset.label}
                  </Button>
                ))}
              </div>
            </div>
          </CardContent>
        </Card>

        {/* Error Display */}
        {error && (
          <Card className="border-destructive">
            <CardContent className="pt-6">
              <p className="text-destructive">{error}</p>
            </CardContent>
          </Card>
        )}

        {/* Analysis Result */}
        {analysis && (
          <Card>
            <CardHeader>
              <CardTitle className="flex items-center gap-2">
                <TrendingUp className="h-5 w-5 text-green-500" />
                Hasil Analisis AI
              </CardTitle>
            </CardHeader>
            <CardContent>
              <div className="prose prose-sm max-w-none">
                <div className="whitespace-pre-wrap text-sm leading-relaxed">
                  {analysis}
                </div>
              </div>
            </CardContent>
          </Card>
        )}

        {/* Data Cards */}
        {data && (
          <>
            {/* Summary Cards */}
            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
              <Card>
                <CardContent className="pt-6">
                  <div className="flex items-center justify-between">
                    <div>
                      <p className="text-sm text-muted-foreground">Total Revenue</p>
                      <p className="text-2xl font-bold">
                        {formatCurrency(data.summary.totalRevenue)}
                      </p>
                    </div>
                    <div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center">
                      <DollarSign className="h-6 w-6 text-green-600" />
                    </div>
                  </div>
                </CardContent>
              </Card>

              <Card>
                <CardContent className="pt-6">
                  <div className="flex items-center justify-between">
                    <div>
                      <p className="text-sm text-muted-foreground">Total Transaksi</p>
                      <p className="text-2xl font-bold">
                        {data.summary.totalTransactions}
                      </p>
                    </div>
                    <div className="h-12 w-12 rounded-full bg-blue-100 flex items-center justify-center">
                      <ShoppingCart className="h-6 w-6 text-blue-600" />
                    </div>
                  </div>
                </CardContent>
              </Card>

              <Card>
                <CardContent className="pt-6">
                  <div className="flex items-center justify-between">
                    <div>
                      <p className="text-sm text-muted-foreground">Rata-rata / Transaksi</p>
                      <p className="text-2xl font-bold">
                        {formatCurrency(data.summary.averageTransaction)}
                      </p>
                    </div>
                    <div className="h-12 w-12 rounded-full bg-purple-100 flex items-center justify-center">
                      <BarChart3 className="h-6 w-6 text-purple-600" />
                    </div>
                  </div>
                </CardContent>
              </Card>

              <Card>
                <CardContent className="pt-6">
                  <div className="flex items-center justify-between">
                    <div>
                      <p className="text-sm text-muted-foreground">Total Diskon</p>
                      <p className="text-2xl font-bold">
                        {formatCurrency(data.summary.totalDiscount)}
                      </p>
                    </div>
                    <div className="h-12 w-12 rounded-full bg-orange-100 flex items-center justify-center">
                      <TrendingDown className="h-6 w-6 text-orange-600" />
                    </div>
                  </div>
                </CardContent>
              </Card>
            </div>

            {/* Detailed Data */}
            <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
              {/* Top Products */}
              <Card>
                <CardHeader>
                  <CardTitle className="text-lg">πŸ† Top 5 Produk</CardTitle>
                </CardHeader>
                <CardContent>
                  <div className="space-y-3">
                    {data.topProducts.slice(0, 5).map((product, index) => (
                      <div
                        key={product.name}
                        className="flex items-center justify-between p-3 rounded-lg bg-muted/50"
                      >
                        <div className="flex items-center gap-3">
                          <div className={cn(
                            'h-8 w-8 rounded-full flex items-center justify-center text-sm font-bold',
                            index === 0 ? 'bg-yellow-100 text-yellow-700' :
                            index === 1 ? 'bg-gray-100 text-gray-700' :
                            index === 2 ? 'bg-orange-100 text-orange-700' :
                            'bg-muted text-muted-foreground'
                          )}>
                            {index + 1}
                          </div>
                          <div>
                            <p className="font-medium">{product.name}</p>
                            <p className="text-sm text-muted-foreground">
                              {product.quantitySold} terjual
                            </p>
                          </div>
                        </div>
                        <p className="font-semibold">
                          {formatCurrency(product.revenue)}
                        </p>
                      </div>
                    ))}
                  </div>
                </CardContent>
              </Card>

              {/* Payment Methods */}
              <Card>
                <CardHeader>
                  <CardTitle className="text-lg">πŸ’³ Metode Pembayaran</CardTitle>
                </CardHeader>
                <CardContent>
                  <div className="space-y-3">
                    {data.paymentMethods.map((method) => {
                      const info = paymentLabels[method.method] || {
                        label: method.method,
                        icon: 'πŸ’°'
                      }
                      return (
                        <div
                          key={method.method}
                          className="flex items-center justify-between p-3 rounded-lg bg-muted/50"
                        >
                          <div className="flex items-center gap-3">
                            <span className="text-2xl">{info.icon}</span>
                            <div>
                              <p className="font-medium">{info.label}</p>
                              <p className="text-sm text-muted-foreground">
                                {method.transactions} transaksi
                              </p>
                            </div>
                          </div>
                          <div className="text-right">
                            <p className="font-semibold">
                              {formatCurrency(method.total)}
                            </p>
                            <Badge variant="secondary" className="text-xs">
                              {method.percentage}%
                            </Badge>
                          </div>
                        </div>
                      )
                    })}
                  </div>
                </CardContent>
              </Card>

              {/* Category Breakdown */}
              <Card className="lg:col-span-2">
                <CardHeader>
                  <CardTitle className="text-lg">πŸ“Š Breakdown per Kategori</CardTitle>
                </CardHeader>
                <CardContent>
                  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
                    {data.categoryBreakdown.map((category) => (
                      <div
                        key={category.categoryName}
                        className="p-4 rounded-lg bg-muted/50 text-center"
                      >
                        <p className="text-sm text-muted-foreground mb-1">
                          {category.categoryName}
                        </p>
                        <p className="text-xl font-bold">
                          {formatCurrency(category.revenue)}
                        </p>
                        <p className="text-sm text-muted-foreground">
                          {category.itemsSold} item terjual
                        </p>
                      </div>
                    ))}
                  </div>
                </CardContent>
              </Card>
            </div>
          </>
        )}
      </div>
    </div>
  )
}

6.3 Contoh AI Analytics dalam Aksi

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  πŸ—£οΈ USER: "Berikan ringkasan penjualan minggu ini,          β”‚
β”‚           fokus ke produk dan waktu terbaik"                β”‚
β”‚                                                              β”‚
β”‚  πŸ“Š AI ANALYST:                                              β”‚
β”‚                                                              β”‚
β”‚  πŸ“ˆ RINGKASAN PENJUALAN 7 HARI TERAKHIR                     β”‚
β”‚                                                              β”‚
β”‚  Total revenue mencapai Rp 12.450.000 dari 287 transaksi    β”‚
β”‚  dengan rata-rata Rp 43.380 per transaksi.                  β”‚
β”‚                                                              β”‚
β”‚  πŸ† TOP 3 PRODUK TERLARIS:                                  β”‚
β”‚  1. Kopi Susu Gula Aren - 89 cup (Rp 2.136.000)            β”‚
β”‚  2. Es Teh Manis - 76 cup (Rp 760.000)                     β”‚
β”‚  3. Nasi Goreng Spesial - 45 porsi (Rp 1.575.000)          β”‚
β”‚                                                              β”‚
β”‚  ⏰ WAKTU TERBAIK:                                          β”‚
β”‚  β€’ Peak hour: 12:00-14:00 (lunch time) dengan 34% dari      β”‚
β”‚    total transaksi                                          β”‚
β”‚  β€’ Second peak: 17:00-19:00 (after work) dengan 22%        β”‚
β”‚                                                              β”‚
β”‚  πŸ’‘ INSIGHT & REKOMENDASI:                                  β”‚
β”‚  1. Kopi Susu mendominasi - pertimbangkan bundle promo      β”‚
β”‚     dengan snack untuk meningkatkan basket size             β”‚
β”‚  2. Weekend (Sabtu-Minggu) mencatat 40% lebih tinggi dari  β”‚
β”‚     weekday - optimalkan staffing                          β”‚
β”‚  3. Pembayaran QRIS paling populer (58%) - pastikan QR     β”‚
β”‚     code selalu ready                                       β”‚
β”‚                                                              β”‚
β”‚  πŸ“Š TREND:                                                   β”‚
β”‚  Revenue naik 12% dibanding minggu sebelumnya. Momentum    β”‚
β”‚  positif! Keep up the good work! πŸš€                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

6.4 Tambahan: Quick Stats Component

Untuk tampilan cepat di halaman utama:

// src/components/analytics/QuickStats.tsx

'use client'

import { useEffect, useState } from 'react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'

interface QuickStatsProps {
  className?: string
}

interface Stats {
  todayRevenue: number
  todayTransactions: number
  yesterdayRevenue: number
  trend: 'up' | 'down' | 'neutral'
  trendPercent: number
}

export function QuickStats({ className }: QuickStatsProps) {
  const [stats, setStats] = useState<Stats | null>(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    async function fetchStats() {
      try {
        const res = await fetch('/api/analytics/quick')
        if (res.ok) {
          const data = await res.json()
          setStats(data)
        }
      } catch (error) {
        console.error('Failed to fetch quick stats:', error)
      } finally {
        setIsLoading(false)
      }
    }

    fetchStats()

    // Refresh setiap 5 menit
    const interval = setInterval(fetchStats, 5 * 60 * 1000)
    return () => clearInterval(interval)
  }, [])

  if (isLoading) {
    return (
      <Card className={className}>
        <CardContent className="pt-6">
          <div className="flex items-center justify-between">
            <div className="space-y-2">
              <Skeleton className="h-4 w-24" />
              <Skeleton className="h-8 w-32" />
            </div>
            <Skeleton className="h-12 w-12 rounded-full" />
          </div>
        </CardContent>
      </Card>
    )
  }

  if (!stats) return null

  const TrendIcon = stats.trend === 'up' ? TrendingUp :
                    stats.trend === 'down' ? TrendingDown : Minus

  return (
    <Card className={className}>
      <CardContent className="pt-6">
        <div className="flex items-center justify-between">
          <div>
            <p className="text-sm text-muted-foreground">Penjualan Hari Ini</p>
            <p className="text-2xl font-bold">
              {formatCurrency(stats.todayRevenue)}
            </p>
            <div className="flex items-center gap-1 mt-1">
              <TrendIcon className={cn(
                'h-4 w-4',
                stats.trend === 'up' ? 'text-green-500' :
                stats.trend === 'down' ? 'text-red-500' :
                'text-muted-foreground'
              )} />
              <span className={cn(
                'text-sm',
                stats.trend === 'up' ? 'text-green-500' :
                stats.trend === 'down' ? 'text-red-500' :
                'text-muted-foreground'
              )}>
                {stats.trend === 'neutral' ? 'Sama dengan' :
                 `${stats.trendPercent}% ${stats.trend === 'up' ? 'lebih tinggi' : 'lebih rendah'}`}
                {' '}dari kemarin
              </span>
            </div>
          </div>
          <div className="text-right">
            <p className="text-sm text-muted-foreground">Transaksi</p>
            <p className="text-xl font-semibold">{stats.todayTransactions}</p>
          </div>
        </div>
      </CardContent>
    </Card>
  )
}

6.5 Quick Stats API

// src/app/api/analytics/quick/route.ts

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'

export const runtime = 'nodejs'

export async function GET() {
  try {
    const now = new Date()
    const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
    const yesterdayStart = new Date(todayStart)
    yesterdayStart.setDate(yesterdayStart.getDate() - 1)

    const [todayStats, yesterdayStats] = await Promise.all([
      prisma.transaction.aggregate({
        where: {
          createdAt: { gte: todayStart },
          status: 'completed'
        },
        _sum: { total: true },
        _count: true
      }),
      prisma.transaction.aggregate({
        where: {
          createdAt: { gte: yesterdayStart, lt: todayStart },
          status: 'completed'
        },
        _sum: { total: true },
        _count: true
      })
    ])

    const todayRevenue = todayStats._sum.total || 0
    const yesterdayRevenue = yesterdayStats._sum.total || 0

    let trend: 'up' | 'down' | 'neutral' = 'neutral'
    let trendPercent = 0

    if (yesterdayRevenue > 0) {
      const diff = todayRevenue - yesterdayRevenue
      trendPercent = Math.abs(Math.round((diff / yesterdayRevenue) * 100))

      if (diff > 0) trend = 'up'
      else if (diff < 0) trend = 'down'
    }

    return NextResponse.json({
      todayRevenue,
      todayTransactions: todayStats._count,
      yesterdayRevenue,
      trend,
      trendPercent
    })
  } catch (error) {
    console.error('Quick stats error:', error)
    return NextResponse.json(
      { error: 'Failed to fetch stats' },
      { status: 500 }
    )
  }
}


πŸ’‘ Mini Tips #6

Untuk analytics, selalu sertakan raw data bersamaan dengan AI analysis. Kenapa? Karena user tetap perlu verify apakah AI interpretation-nya benar. Trust but verify! AI bisa salah interpret data, jadi kasih option untuk user lihat angka aslinya.


Checkpoint! πŸŽ‰

Sampai sini kita sudah punya:

  • βœ… AI Chat Assistant dengan streaming responses
  • βœ… Tool calling untuk akses database real-time
  • βœ… Natural language analytics dashboard
  • βœ… Quick stats component untuk overview cepat
  • βœ… Preset questions untuk user yang bingung mau tanya apa

Dua fitur AI utama sudah selesai. Di bagian selanjutnya, kita akan build main POS interface yang integrate semua fitur ini, plus deployment dan optimization tips!

Let's bring it all together! πŸš€


Bagian 7: Main POS Interface β€” Menyatukan Semuanya

Oke, ini bagian yang paling seru β€” kita akan gabungkan semua fitur AI yang sudah kita buat ke dalam satu interface kasir yang powerful! 🎯

7.1 Layout & State Management

Pertama, kita buat layout utama untuk halaman kasir:

// src/app/kasir/page.tsx

'use client'

import { useState, useEffect, useCallback } from 'react'
import {
  ShoppingCart,
  MessageSquare,
  RefreshCcw,
  Package,
  Receipt,
  Loader2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SearchBar } from '@/components/pos/SearchBar'
import { ProductGrid } from '@/components/pos/ProductGrid'
import { Cart } from '@/components/pos/Cart'
import { AIAssistant } from '@/components/pos/AIAssistant'
import { PaymentDialog } from '@/components/pos/PaymentDialog'
import { QuickStats } from '@/components/analytics/QuickStats'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'

interface Product {
  id: string
  name: string
  description: string | null
  price: number
  stock: number
  sku: string
  category: {
    id: string
    name: string
  }
}

interface CartItem extends Product {
  quantity: number
}

export default function KasirPage() {
  // Products state
  const [products, setProducts] = useState<Product[]>([])
  const [isLoadingProducts, setIsLoadingProducts] = useState(true)
  const [searchLoading, setSearchLoading] = useState(false)

  // Cart state
  const [cart, setCart] = useState<CartItem[]>([])

  // UI state
  const [activeTab, setActiveTab] = useState<'cart' | 'ai'>('cart')
  const [showPayment, setShowPayment] = useState(false)

  // ============================================
  // LOAD INITIAL PRODUCTS
  // ============================================
  const loadProducts = useCallback(async () => {
    setIsLoadingProducts(true)
    try {
      const res = await fetch('/api/products')
      if (res.ok) {
        const data = await res.json()
        setProducts(data.products)
      }
    } catch (error) {
      console.error('Failed to load products:', error)
    } finally {
      setIsLoadingProducts(false)
    }
  }, [])

  useEffect(() => {
    loadProducts()
  }, [loadProducts])

  // ============================================
  // CART OPERATIONS
  // ============================================
  const addToCart = useCallback((product: Product) => {
    setCart(prev => {
      const existing = prev.find(item => item.id === product.id)

      if (existing) {
        // Check stock
        if (existing.quantity >= product.stock) {
          alert(`Stok ${product.name} tidak cukup!`)
          return prev
        }

        return prev.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      }

      // New item
      return [...prev, { ...product, quantity: 1 }]
    })
  }, [])

  const updateQuantity = useCallback((productId: string, quantity: number) => {
    if (quantity <= 0) {
      setCart(prev => prev.filter(item => item.id !== productId))
      return
    }

    setCart(prev => prev.map(item => {
      if (item.id !== productId) return item

      // Check stock
      if (quantity > item.stock) {
        alert(`Stok ${item.name} tidak cukup!`)
        return item
      }

      return { ...item, quantity }
    }))
  }, [])

  const removeFromCart = useCallback((productId: string) => {
    setCart(prev => prev.filter(item => item.id !== productId))
  }, [])

  const clearCart = useCallback(() => {
    setCart([])
  }, [])

  // ============================================
  // CART CALCULATIONS
  // ============================================
  const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0)
  const tax = subtotal * 0.1 // PPN 10%
  const total = subtotal + tax
  const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0)

  // ============================================
  // PAYMENT SUCCESS HANDLER
  // ============================================
  const handlePaymentSuccess = useCallback(async (transactionData: any) => {
    // Clear cart
    clearCart()

    // Refresh products (untuk update stok)
    await loadProducts()

    // Close payment dialog
    setShowPayment(false)

    // Show success message (bisa pakai toast)
    alert(`βœ… Transaksi berhasil!\\nNo. Invoice: ${transactionData.invoiceNumber}`)
  }, [clearCart, loadProducts])

  // ============================================
  // SEARCH RESULTS HANDLER
  // ============================================
  const handleSearchResults = useCallback((results: Product[]) => {
    setProducts(results)
  }, [])

  return (
    <div className="min-h-screen bg-muted/30">
      {/* Header */}
      <header className="bg-background border-b sticky top-0 z-50">
        <div className="container mx-auto px-4 py-3">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <div className="h-10 w-10 rounded-lg bg-primary flex items-center justify-center">
                <Receipt className="h-5 w-5 text-primary-foreground" />
              </div>
              <div>
                <h1 className="font-bold text-lg">Kasir AI</h1>
                <p className="text-xs text-muted-foreground">Smart Point of Sale</p>
              </div>
            </div>

            <div className="flex items-center gap-2">
              <Button
                variant="outline"
                size="sm"
                onClick={loadProducts}
                disabled={isLoadingProducts}
              >
                <RefreshCcw className={cn(
                  "h-4 w-4 mr-1",
                  isLoadingProducts && "animate-spin"
                )} />
                Refresh
              </Button>
            </div>
          </div>
        </div>
      </header>

      {/* Main Content */}
      <div className="container mx-auto px-4 py-4">
        {/* Quick Stats */}
        <div className="mb-4">
          <QuickStats />
        </div>

        <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
          {/* LEFT: Products Section (2 cols) */}
          <div className="lg:col-span-2 space-y-4">
            {/* Search */}
            <Card>
              <CardContent className="pt-4">
                <SearchBar
                  onResults={handleSearchResults}
                  onLoading={setSearchLoading}
                />
              </CardContent>
            </Card>

            {/* Products Grid */}
            <Card className="min-h-[500px]">
              <CardHeader className="pb-2">
                <div className="flex items-center justify-between">
                  <CardTitle className="text-lg flex items-center gap-2">
                    <Package className="h-5 w-5" />
                    Produk
                    <Badge variant="secondary">{products.length}</Badge>
                  </CardTitle>

                  {searchLoading && (
                    <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
                  )}
                </div>
              </CardHeader>
              <CardContent>
                <ProductGrid
                  products={products}
                  onAddToCart={addToCart}
                  isLoading={isLoadingProducts}
                  cartItems={cart}
                />
              </CardContent>
            </Card>
          </div>

          {/* RIGHT: Cart & AI (1 col) */}
          <div className="space-y-4">
            {/* Tabs: Cart / AI Assistant */}
            <Tabs
              value={activeTab}
              onValueChange={(v) => setActiveTab(v as 'cart' | 'ai')}
              className="h-full"
            >
              <TabsList className="w-full">
                <TabsTrigger value="cart" className="flex-1">
                  <ShoppingCart className="h-4 w-4 mr-2" />
                  Keranjang
                  {totalItems > 0 && (
                    <Badge className="ml-2" variant="default">
                      {totalItems}
                    </Badge>
                  )}
                </TabsTrigger>
                <TabsTrigger value="ai" className="flex-1">
                  <MessageSquare className="h-4 w-4 mr-2" />
                  AI Assistant
                </TabsTrigger>
              </TabsList>

              <TabsContent value="cart" className="mt-4">
                <Card className="h-[calc(100vh-320px)]">
                  <CardContent className="p-0 h-full flex flex-col">
                    {/* Cart Items */}
                    <div className="flex-1 overflow-auto p-4">
                      <Cart
                        items={cart}
                        onUpdateQuantity={updateQuantity}
                        onRemove={removeFromCart}
                        onClear={clearCart}
                      />
                    </div>

                    {/* Cart Summary & Checkout */}
                    {cart.length > 0 && (
                      <div className="border-t p-4 bg-muted/30">
                        <div className="space-y-2 text-sm mb-4">
                          <div className="flex justify-between">
                            <span className="text-muted-foreground">Subtotal</span>
                            <span>{formatCurrency(subtotal)}</span>
                          </div>
                          <div className="flex justify-between">
                            <span className="text-muted-foreground">PPN (10%)</span>
                            <span>{formatCurrency(tax)}</span>
                          </div>
                          <div className="flex justify-between font-bold text-base pt-2 border-t">
                            <span>Total</span>
                            <span className="text-primary">{formatCurrency(total)}</span>
                          </div>
                        </div>

                        <Button
                          className="w-full"
                          size="lg"
                          onClick={() => setShowPayment(true)}
                        >
                          Bayar ({totalItems} item)
                        </Button>
                      </div>
                    )}
                  </CardContent>
                </Card>
              </TabsContent>

              <TabsContent value="ai" className="mt-4">
                <AIAssistant
                  className="h-[calc(100vh-320px)]"
                  onProductSelect={(id) => {
                    const product = products.find(p => p.id === id)
                    if (product) {
                      addToCart(product)
                      setActiveTab('cart')
                    }
                  }}
                />
              </TabsContent>
            </Tabs>
          </div>
        </div>
      </div>

      {/* Payment Dialog */}
      <PaymentDialog
        open={showPayment}
        onClose={() => setShowPayment(false)}
        items={cart}
        subtotal={subtotal}
        tax={tax}
        total={total}
        onSuccess={handlePaymentSuccess}
      />
    </div>
  )
}

7.2 Product Grid Component

// src/components/pos/ProductGrid.tsx

'use client'

import { memo } from 'react'
import { Plus, Package, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'

interface Product {
  id: string
  name: string
  description: string | null
  price: number
  stock: number
  sku: string
  category: {
    id: string
    name: string
  }
}

interface CartItem extends Product {
  quantity: number
}

interface ProductGridProps {
  products: Product[]
  onAddToCart: (product: Product) => void
  isLoading?: boolean
  cartItems?: CartItem[]
}

// Memoize product card untuk performa
const ProductCard = memo(function ProductCard({
  product,
  onAdd,
  inCartQuantity
}: {
  product: Product
  onAdd: () => void
  inCartQuantity: number
}) {
  const isLowStock = product.stock <= 10
  const isOutOfStock = product.stock === 0
  const remainingStock = product.stock - inCartQuantity

  return (
    <div
      className={cn(
        "group relative p-4 rounded-xl border bg-card transition-all",
        "hover:shadow-md hover:border-primary/30",
        isOutOfStock && "opacity-60"
      )}
    >
      {/* Category Badge */}
      <Badge
        variant="secondary"
        className="absolute top-2 right-2 text-[10px]"
      >
        {product.category.name}
      </Badge>

      {/* Product Icon/Image Placeholder */}
      <div className="h-16 w-16 mx-auto mb-3 rounded-lg bg-muted flex items-center justify-center">
        <Package className="h-8 w-8 text-muted-foreground" />
      </div>

      {/* Product Info */}
      <div className="text-center mb-3">
        <h3 className="font-medium text-sm line-clamp-2 min-h-[2.5rem]">
          {product.name}
        </h3>
        <p className="text-lg font-bold text-primary mt-1">
          {formatCurrency(product.price)}
        </p>
      </div>

      {/* Stock Info */}
      <div className="flex items-center justify-center gap-1 mb-3">
        {isOutOfStock ? (
          <Badge variant="destructive" className="text-[10px]">
            Habis
          </Badge>
        ) : isLowStock ? (
          <Badge variant="outline" className="text-[10px] border-orange-300 text-orange-600">
            <AlertTriangle className="h-3 w-3 mr-1" />
            Sisa {remainingStock}
          </Badge>
        ) : (
          <span className="text-xs text-muted-foreground">
            Stok: {remainingStock}
          </span>
        )}

        {inCartQuantity > 0 && (
          <Badge className="text-[10px] ml-1">
            {inCartQuantity} di keranjang
          </Badge>
        )}
      </div>

      {/* Add Button */}
      <Button
        size="sm"
        className="w-full"
        onClick={onAdd}
        disabled={remainingStock <= 0}
      >
        <Plus className="h-4 w-4 mr-1" />
        Tambah
      </Button>
    </div>
  )
})

export function ProductGrid({
  products,
  onAddToCart,
  isLoading = false,
  cartItems = []
}: ProductGridProps) {
  // Loading skeleton
  if (isLoading) {
    return (
      <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
        {Array.from({ length: 8 }).map((_, i) => (
          <div key={i} className="p-4 rounded-xl border">
            <Skeleton className="h-16 w-16 mx-auto mb-3 rounded-lg" />
            <Skeleton className="h-4 w-3/4 mx-auto mb-2" />
            <Skeleton className="h-6 w-1/2 mx-auto mb-3" />
            <Skeleton className="h-9 w-full" />
          </div>
        ))}
      </div>
    )
  }

  // Empty state
  if (products.length === 0) {
    return (
      <div className="text-center py-12">
        <Package className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
        <p className="text-muted-foreground">
          Tidak ada produk ditemukan
        </p>
        <p className="text-sm text-muted-foreground mt-1">
          Coba kata kunci pencarian lain
        </p>
      </div>
    )
  }

  // Get quantity in cart for each product
  const getInCartQuantity = (productId: string) => {
    const item = cartItems.find(i => i.id === productId)
    return item?.quantity || 0
  }

  return (
    <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAdd={() => onAddToCart(product)}
          inCartQuantity={getInCartQuantity(product.id)}
        />
      ))}
    </div>
  )
}

7.3 Cart Component

// src/components/pos/Cart.tsx

'use client'

import { Minus, Plus, Trash2, ShoppingBag } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatCurrency } from '@/lib/utils'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
  stock: number
}

interface CartProps {
  items: CartItem[]
  onUpdateQuantity: (productId: string, quantity: number) => void
  onRemove: (productId: string) => void
  onClear: () => void
}

export function Cart({ items, onUpdateQuantity, onRemove, onClear }: CartProps) {
  if (items.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center h-full py-12">
        <ShoppingBag className="h-16 w-16 text-muted-foreground/30 mb-4" />
        <p className="text-muted-foreground text-center">
          Keranjang kosong
        </p>
        <p className="text-sm text-muted-foreground text-center mt-1">
          Klik produk untuk menambahkan
        </p>
      </div>
    )
  }

  return (
    <div className="space-y-3">
      {/* Clear Button */}
      <div className="flex justify-end">
        <Button
          variant="ghost"
          size="sm"
          onClick={onClear}
          className="text-destructive hover:text-destructive"
        >
          <Trash2 className="h-4 w-4 mr-1" />
          Kosongkan
        </Button>
      </div>

      {/* Cart Items */}
      {items.map(item => (
        <div
          key={item.id}
          className="flex items-center gap-3 p-3 rounded-lg bg-muted/50"
        >
          {/* Item Info */}
          <div className="flex-1 min-w-0">
            <p className="font-medium text-sm truncate">{item.name}</p>
            <p className="text-sm text-muted-foreground">
              {formatCurrency(item.price)}
            </p>
          </div>

          {/* Quantity Controls */}
          <div className="flex items-center gap-2">
            <Button
              variant="outline"
              size="icon"
              className="h-8 w-8"
              onClick={() => onUpdateQuantity(item.id, item.quantity - 1)}
            >
              <Minus className="h-3 w-3" />
            </Button>

            <span className="w-8 text-center font-medium">
              {item.quantity}
            </span>

            <Button
              variant="outline"
              size="icon"
              className="h-8 w-8"
              onClick={() => onUpdateQuantity(item.id, item.quantity + 1)}
              disabled={item.quantity >= item.stock}
            >
              <Plus className="h-3 w-3" />
            </Button>
          </div>

          {/* Subtotal */}
          <div className="text-right min-w-[80px]">
            <p className="font-semibold text-sm">
              {formatCurrency(item.price * item.quantity)}
            </p>
          </div>

          {/* Remove Button */}
          <Button
            variant="ghost"
            size="icon"
            className="h-8 w-8 text-destructive hover:text-destructive"
            onClick={() => onRemove(item.id)}
          >
            <Trash2 className="h-4 w-4" />
          </Button>
        </div>
      ))}
    </div>
  )
}

7.4 Payment Dialog

// src/components/pos/PaymentDialog.tsx

'use client'

import { useState } from 'react'
import {
  Banknote,
  CreditCard,
  QrCode,
  Building2,
  Loader2,
  CheckCircle2,
  Calculator
} from 'lucide-react'
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
  sku: string
}

interface PaymentDialogProps {
  open: boolean
  onClose: () => void
  items: CartItem[]
  subtotal: number
  tax: number
  total: number
  onSuccess: (transactionData: any) => void
}

type PaymentMethod = 'cash' | 'qris' | 'card' | 'transfer'

const paymentMethods: { id: PaymentMethod; label: string; icon: any }[] = [
  { id: 'cash', label: 'Tunai', icon: Banknote },
  { id: 'qris', label: 'QRIS', icon: QrCode },
  { id: 'card', label: 'Kartu', icon: CreditCard },
  { id: 'transfer', label: 'Transfer', icon: Building2 },
]

export function PaymentDialog({
  open,
  onClose,
  items,
  subtotal,
  tax,
  total,
  onSuccess
}: PaymentDialogProps) {
  const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('cash')
  const [amountPaid, setAmountPaid] = useState<string>('')
  const [isProcessing, setIsProcessing] = useState(false)
  const [isSuccess, setIsSuccess] = useState(false)

  const amountPaidNum = parseFloat(amountPaid) || 0
  const change = amountPaidNum - total
  const canPay = paymentMethod !== 'cash' || amountPaidNum >= total

  // Quick amount buttons untuk cash
  const quickAmounts = [
    { label: 'Pas', value: total },
    { label: formatCurrency(50000), value: 50000 },
    { label: formatCurrency(100000), value: 100000 },
    { label: formatCurrency(150000), value: 150000 },
  ].filter(q => q.value >= total || q.label === 'Pas')

  const handlePayment = async () => {
    if (!canPay) return

    setIsProcessing(true)

    try {
      const res = await fetch('/api/transactions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          items: items.map(item => ({
            productId: item.id,
            productName: item.name,
            productSku: item.sku,
            quantity: item.quantity,
            unitPrice: item.price,
            subtotal: item.price * item.quantity
          })),
          subtotal,
          taxAmount: tax,
          taxPercent: 10,
          total,
          paymentMethod,
          amountPaid: paymentMethod === 'cash' ? amountPaidNum : total,
          changeAmount: paymentMethod === 'cash' ? Math.max(0, change) : 0
        })
      })

      if (!res.ok) {
        throw new Error('Transaction failed')
      }

      const data = await res.json()

      setIsSuccess(true)

      // Wait a moment to show success state
      setTimeout(() => {
        onSuccess(data.transaction)
        resetState()
      }, 1500)

    } catch (error) {
      console.error('Payment error:', error)
      alert('Terjadi kesalahan saat memproses pembayaran')
    } finally {
      setIsProcessing(false)
    }
  }

  const resetState = () => {
    setPaymentMethod('cash')
    setAmountPaid('')
    setIsSuccess(false)
  }

  const handleClose = () => {
    if (!isProcessing) {
      resetState()
      onClose()
    }
  }

  return (
    <Dialog open={open} onOpenChange={handleClose}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Pembayaran</DialogTitle>
        </DialogHeader>

        {isSuccess ? (
          // Success State
          <div className="py-8 text-center">
            <CheckCircle2 className="h-16 w-16 text-green-500 mx-auto mb-4" />
            <h3 className="text-xl font-semibold mb-2">Pembayaran Berhasil!</h3>
            <p className="text-muted-foreground">Transaksi sedang diproses...</p>
          </div>
        ) : (
          <div className="space-y-6">
            {/* Order Summary */}
            <div className="p-4 rounded-lg bg-muted/50">
              <div className="space-y-2 text-sm">
                <div className="flex justify-between">
                  <span className="text-muted-foreground">Subtotal ({items.length} item)</span>
                  <span>{formatCurrency(subtotal)}</span>
                </div>
                <div className="flex justify-between">
                  <span className="text-muted-foreground">PPN 10%</span>
                  <span>{formatCurrency(tax)}</span>
                </div>
                <div className="flex justify-between font-bold text-lg pt-2 border-t">
                  <span>Total</span>
                  <span className="text-primary">{formatCurrency(total)}</span>
                </div>
              </div>
            </div>

            {/* Payment Method Selection */}
            <div>
              <Label className="text-sm font-medium mb-3 block">
                Metode Pembayaran
              </Label>
              <div className="grid grid-cols-4 gap-2">
                {paymentMethods.map(method => {
                  const Icon = method.icon
                  return (
                    <Button
                      key={method.id}
                      variant={paymentMethod === method.id ? 'default' : 'outline'}
                      className={cn(
                        "flex-col h-auto py-3",
                        paymentMethod === method.id && "ring-2 ring-primary"
                      )}
                      onClick={() => setPaymentMethod(method.id)}
                    >
                      <Icon className="h-5 w-5 mb-1" />
                      <span className="text-xs">{method.label}</span>
                    </Button>
                  )
                })}
              </div>
            </div>

            {/* Cash Payment Input */}
            {paymentMethod === 'cash' && (
              <div className="space-y-3">
                <div>
                  <Label htmlFor="amount" className="text-sm font-medium">
                    Jumlah Dibayar
                  </Label>
                  <div className="relative mt-1">
                    <span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
                      Rp
                    </span>
                    <Input
                      id="amount"
                      type="number"
                      placeholder="0"
                      value={amountPaid}
                      onChange={(e) => setAmountPaid(e.target.value)}
                      className="pl-10 text-lg font-mono"
                    />
                  </div>
                </div>

                {/* Quick Amounts */}
                <div className="flex flex-wrap gap-2">
                  {quickAmounts.map(q => (
                    <Button
                      key={q.label}
                      variant="outline"
                      size="sm"
                      onClick={() => setAmountPaid(q.value.toString())}
                    >
                      {q.label}
                    </Button>
                  ))}
                </div>

                {/* Change Display */}
                {amountPaidNum > 0 && (
                  <div className={cn(
                    "p-3 rounded-lg text-center",
                    change >= 0 ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"
                  )}>
                    <div className="flex items-center justify-center gap-2">
                      <Calculator className="h-4 w-4" />
                      <span className="font-medium">
                        {change >= 0
                          ? `Kembalian: ${formatCurrency(change)}`
                          : `Kurang: ${formatCurrency(Math.abs(change))}`
                        }
                      </span>
                    </div>
                  </div>
                )}
              </div>
            )}

            {/* Non-cash Payment Info */}
            {paymentMethod !== 'cash' && (
              <div className="p-4 rounded-lg bg-blue-50 text-blue-700 text-sm text-center">
                {paymentMethod === 'qris' && 'Scan QR code untuk pembayaran'}
                {paymentMethod === 'card' && 'Tap atau insert kartu pada mesin EDC'}
                {paymentMethod === 'transfer' && 'Transfer ke rekening toko'}
              </div>
            )}

            {/* Pay Button */}
            <Button
              className="w-full"
              size="lg"
              onClick={handlePayment}
              disabled={!canPay || isProcessing}
            >
              {isProcessing ? (
                <>
                  <Loader2 className="h-4 w-4 mr-2 animate-spin" />
                  Memproses...
                </>
              ) : (
                `Bayar ${formatCurrency(total)}`
              )}
            </Button>
          </div>
        )}
      </DialogContent>
    </Dialog>
  )
}

7.5 Transaction API

// src/app/api/transactions/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { generateInvoiceNumber } from '@/lib/utils'

export async function POST(req: NextRequest) {
  try {
    const body = await req.json()

    const {
      items,
      subtotal,
      discountAmount = 0,
      discountPercent = 0,
      taxAmount,
      taxPercent,
      total,
      paymentMethod,
      amountPaid,
      changeAmount = 0,
      cashierName = 'Kasir',
      notes
    } = body

    // Validate items
    if (!items || !Array.isArray(items) || items.length === 0) {
      return NextResponse.json(
        { error: 'Items are required' },
        { status: 400 }
      )
    }

    // Generate invoice number
    const invoiceNumber = generateInvoiceNumber()

    // Create transaction with items in a single transaction
    const transaction = await prisma.$transaction(async (tx) => {
      // 1. Create transaction
      const newTransaction = await tx.transaction.create({
        data: {
          invoiceNumber,
          subtotal,
          discountAmount,
          discountPercent,
          taxAmount,
          taxPercent,
          total,
          paymentMethod,
          amountPaid,
          changeAmount,
          status: 'completed',
          cashierName,
          notes,
          items: {
            create: items.map((item: any) => ({
              productId: item.productId,
              productName: item.productName,
              productSku: item.productSku,
              quantity: item.quantity,
              unitPrice: item.unitPrice,
              subtotal: item.subtotal,
              discountAmount: item.discountAmount || 0
            }))
          }
        },
        include: {
          items: true
        }
      })

      // 2. Update stock for each product
      for (const item of items) {
        await tx.product.update({
          where: { id: item.productId },
          data: {
            stock: {
              decrement: item.quantity
            }
          }
        })
      }

      return newTransaction
    })

    return NextResponse.json({
      success: true,
      transaction
    })

  } catch (error) {
    console.error('Transaction error:', error)
    return NextResponse.json(
      { error: 'Failed to create transaction' },
      { status: 500 }
    )
  }
}

// GET - List transactions
export async function GET(req: NextRequest) {
  try {
    const { searchParams } = new URL(req.url)
    const limit = parseInt(searchParams.get('limit') || '20')
    const offset = parseInt(searchParams.get('offset') || '0')

    const transactions = await prisma.transaction.findMany({
      include: {
        items: true
      },
      orderBy: { createdAt: 'desc' },
      take: limit,
      skip: offset
    })

    const total = await prisma.transaction.count()

    return NextResponse.json({
      transactions,
      total,
      limit,
      offset
    })

  } catch (error) {
    console.error('Get transactions error:', error)
    return NextResponse.json(
      { error: 'Failed to get transactions' },
      { status: 500 }
    )
  }
}

7.6 Products API

// src/app/api/products/route.ts

import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'

export async function GET(req: NextRequest) {
  try {
    const { searchParams } = new URL(req.url)
    const category = searchParams.get('category')
    const activeOnly = searchParams.get('active') !== 'false'

    const products = await prisma.product.findMany({
      where: {
        isActive: activeOnly,
        ...(category && {
          category: { name: { equals: category, mode: 'insensitive' } }
        })
      },
      include: {
        category: true
      },
      orderBy: [
        { category: { name: 'asc' } },
        { name: 'asc' }
      ]
    })

    return NextResponse.json({ products })

  } catch (error) {
    console.error('Get products error:', error)
    return NextResponse.json(
      { error: 'Failed to get products' },
      { status: 500 }
    )
  }
}


πŸ’‘ Mini Tips #7

Selalu tampilkan data mentah di samping AI analysis. User perlu bisa verify apakah AI-nya bener. Plus, kadang user cuma butuh angka cepat tanpa penjelasan panjang. Balance antara "AI magic" dan "raw data access" itu penting.


Bagian 8: Deployment & Optimization

Aplikasi sudah jadi, sekarang waktunya deploy ke production! πŸš€

8.1 Persiapan Deployment

Environment Variables untuk Production:

# .env.production

# Database (PostgreSQL untuk production)
DATABASE_URL="postgresql://user:password@host:5432/kasir_ai?sslmode=require"

# OpenAI
OPENAI_API_KEY=sk-your-production-key

# Optional: LangSmith monitoring
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your-langsmith-key
LANGCHAIN_PROJECT=kasir-ai-prod

# App
NEXT_PUBLIC_APP_URL=https://your-domain.com

Update Prisma untuk PostgreSQL:

// prisma/schema.prisma

datasource db {
  provider = "postgresql"  // Ganti dari sqlite
  url      = env("DATABASE_URL")
}

8.2 Deploy ke Vercel

# 1. Install Vercel CLI
npm i -g vercel

# 2. Login
vercel login

# 3. Deploy
vercel

# 4. Set environment variables
vercel env add DATABASE_URL
vercel env add OPENAI_API_KEY

# 5. Deploy production
vercel --prod

Vercel Configuration (vercel.json):

{
  "framework": "nextjs",
  "buildCommand": "prisma generate && next build",
  "functions": {
    "src/app/api/**/*.ts": {
      "maxDuration": 30
    }
  }
}

8.3 Database Setup (PostgreSQL)

Untuk production, gunakan managed PostgreSQL seperti:

  • Vercel Postgres β€” Terintegrasi langsung dengan Vercel
  • Supabase β€” Free tier generous, bagus untuk startup
  • PlanetScale β€” MySQL-compatible, serverless
  • Neon β€” PostgreSQL serverless, free tier available
# Setelah setup database, jalankan migration
npx prisma migrate deploy

# Seed data (optional untuk production)
npm run db:seed

8.4 Cost Optimization Strategies

Ini bagian yang sering dilupakan developer β€” AI itu mahal kalau gak dioptimize!

ESTIMASI BIAYA BULANAN (1000 transaksi/hari)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  FITUR              β”‚  CALLS/DAY  β”‚  TOKENS/CALL  β”‚  COST   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  AI Search          β”‚  500        β”‚  ~500         β”‚  $5-10  β”‚
β”‚  Chat Assistant     β”‚  200        β”‚  ~1000        β”‚  $10-20 β”‚
β”‚  Analytics          β”‚  50         β”‚  ~2000        β”‚  $5-10  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  TOTAL ESTIMASI                                   β”‚  $20-40 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Note: Pakai gpt-4o-mini, bukan gpt-4o
gpt-4o = 10-20x lebih mahal!

Strategi Menghemat Biaya:

// 1. CACHING - Jangan panggil AI untuk query yang sama

// src/lib/ai/cache.ts
import { LRUCache } from 'lru-cache'

const searchCache = new LRUCache<string, any>({
  max: 500, // Max 500 cached queries
  ttl: 1000 * 60 * 5, // 5 minutes TTL
})

export function getCachedSearch(query: string) {
  return searchCache.get(query.toLowerCase().trim())
}

export function setCachedSearch(query: string, result: any) {
  searchCache.set(query.toLowerCase().trim(), result)
}

// Implementasi di search route
const cacheKey = query.toLowerCase().trim()
const cached = getCachedSearch(cacheKey)
if (cached) {
  return NextResponse.json({ ...cached, source: 'cache' })
}

// ... AI call ...

setCachedSearch(cacheKey, result)

// 2. RATE LIMITING - Batasi jumlah request per user

// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const rateLimit = new Map<string, number[]>()

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.ip || 'unknown'
    const now = Date.now()
    const windowMs = 60 * 1000 // 1 minute
    const maxRequests = 30

    const requests = rateLimit.get(ip) || []
    const recentRequests = requests.filter(time => now - time < windowMs)

    if (recentRequests.length >= maxRequests) {
      return NextResponse.json(
        { error: 'Too many requests' },
        { status: 429 }
      )
    }

    recentRequests.push(now)
    rateLimit.set(ip, recentRequests)
  }

  return NextResponse.next()
}

// 3. SMART MODEL SELECTION - Pakai model yang tepat

// Simple queries -> gpt-4o-mini dengan max_tokens rendah
const llmFast = new ChatOpenAI({
  modelName: 'gpt-4o-mini',
  temperature: 0,
  maxTokens: 200,  // Batasi output
})

// Complex analysis -> masih gpt-4o-mini tapi tokens lebih tinggi
const llmAnalysis = new ChatOpenAI({
  modelName: 'gpt-4o-mini',
  temperature: 0.2,
  maxTokens: 1000,
})

// 4. FALLBACK TO NON-AI - Untuk query sederhana

// Di search route
function isSimpleQuery(query: string): boolean {
  // Kalau query cuma 1-2 kata, gak perlu AI
  const words = query.trim().split(/\\s+/)
  if (words.length <= 2) return true

  // Kalau query persis match product name
  // Skip AI, langsung database search
  return false
}

if (isSimpleQuery(query)) {
  const products = await prisma.product.findMany({
    where: {
      OR: [
        { name: { contains: query, mode: 'insensitive' } },
        { sku: { contains: query, mode: 'insensitive' } }
      ]
    }
  })
  return NextResponse.json({ products, source: 'direct' })
}

8.5 Monitoring dengan LangSmith

LangSmith dari LangChain itu powerful banget untuk monitoring AI apps:

// Setup sudah otomatis via environment variables:
// LANGCHAIN_TRACING_V2=true
// LANGCHAIN_API_KEY=xxx
// LANGCHAIN_PROJECT=kasir-ai

// Semua LangChain calls akan otomatis di-trace!

Yang bisa di-monitor:

  • Latency per request
  • Token usage
  • Error rates
  • Chain execution flow
  • Cost per feature

8.6 Performance Optimization

// 1. PARALLEL DATA FETCHING
// Jangan sequential, pakai Promise.all

// ❌ BAD - Sequential
const products = await prisma.product.findMany()
const categories = await prisma.category.findMany()
const stats = await prisma.transaction.aggregate({...})

// βœ… GOOD - Parallel
const [products, categories, stats] = await Promise.all([
  prisma.product.findMany(),
  prisma.category.findMany(),
  prisma.transaction.aggregate({...})
])

// 2. DATABASE INDEXES - Sudah ada di schema, tapi pastikan

// prisma/schema.prisma
model Product {
  // ...
  @@index([name])
  @@index([categoryId])
  @@index([isActive])
}

model Transaction {
  // ...
  @@index([createdAt])
  @@index([status])
}

// 3. RESPONSE STREAMING - Sudah implement di chat

// 4. COMPONENT LAZY LOADING
// Untuk halaman analytics yang berat

// src/app/analytics/page.tsx
import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/charts/HeavyChart'), {
  loading: () => <Skeleton className="h-64" />,
  ssr: false
})


πŸ’‘ Mini Tips #8

Monitor biaya AI dari development! Jangan kaget di akhir bulan. Set budget alert di OpenAI dashboard, dan track usage di LangSmith. Lebih baik tau dari awal daripada surprise bill.


Bagian 9: Penutup β€” What's Next?

Selamat! πŸŽ‰ Kamu sudah berhasil build aplikasi kasir dengan 3 fitur AI yang powerful:

9.1 Recap: Apa yang Sudah Kita Bangun

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  KASIR AI - FEATURE RECAP                                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                              β”‚
β”‚  1. πŸ” SMART SEARCH                                         β”‚
β”‚     β€’ Natural language ke structured query                  β”‚
β”‚     β€’ Auto-detect kategori, harga, atribut                 β”‚
β”‚     β€’ Fallback ke simple search jika AI gagal              β”‚
β”‚                                                              β”‚
β”‚  2. πŸ’¬ AI CHAT ASSISTANT                                    β”‚
β”‚     β€’ Streaming responses untuk UX lebih baik              β”‚
β”‚     β€’ 5 custom tools (search, stock, discount, sales, pay) β”‚
β”‚     β€’ Context-aware dengan data real-time                  β”‚
β”‚                                                              β”‚
β”‚  3. πŸ“Š NATURAL LANGUAGE ANALYTICS                           β”‚
β”‚     β€’ Tanya data pakai bahasa manusia                      β”‚
β”‚     β€’ AI analysis + raw data display                       β”‚
β”‚     β€’ Preset questions untuk quick access                  β”‚
β”‚                                                              β”‚
β”‚  4. πŸ›’ FULL POS INTERFACE                                   β”‚
β”‚     β€’ Product grid dengan smart search                     β”‚
β”‚     β€’ Cart management                                       β”‚
β”‚     β€’ Multi-payment method                                  β”‚
β”‚     β€’ Real-time stock update                               β”‚
β”‚                                                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

9.2 Ideas untuk Pengembangan Lanjutan

Ini beberapa ide yang bisa kamu explore untuk level up aplikasi ini:

🎀 Voice Commands

// Integrasi dengan Web Speech API
const recognition = new webkitSpeechRecognition()
recognition.onresult = (event) => {
  const voiceQuery = event.results[0][0].transcript
  // Kirim ke AI assistant
  sendToChat(voiceQuery)
}

πŸ“ˆ Predictive Inventory

// Prediksi stok yang akan habis
// Based on historical sales patterns
const predictStockout = async (productId: string) => {
  const salesHistory = await getSalesHistory(productId, 30) // 30 days
  const avgDailySales = salesHistory.reduce((a, b) => a + b, 0) / 30
  const currentStock = await getStock(productId)
  const daysUntilStockout = currentStock / avgDailySales

  return { daysUntilStockout, recommendedReorder: avgDailySales * 7 }
}

πŸ‘₯ Customer Insights

// Track customer preferences (dengan consent!)
// Personalized recommendations
const getCustomerRecommendations = async (customerId: string) => {
  const purchaseHistory = await getPurchaseHistory(customerId)
  const favorites = analyzeFavorites(purchaseHistory)
  const recommendations = await aiRecommend(favorites)
  return recommendations
}

πŸͺ Multi-Store Sync

// Kalau punya banyak outlet
// Real-time inventory sync
// Centralized analytics

🧾 Receipt OCR

// Scan receipt untuk return/exchange
// Pakai vision model
const analyzeReceipt = async (imageBase64: string) => {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{
      role: 'user',
      content: [
        { type: 'text', text: 'Extract transaction details from this receipt' },
        { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${imageBase64}` } }
      ]
    }]
  })
  return parseReceiptData(response)
}

9.3 Learning Resources

Mau belajar lebih dalam? Ini beberapa resources yang recommended:

LangChain:

Next.js & React:

AI Development:

9.4 Closing Thoughts

Yang paling penting dari tutorial ini bukan cuma code-nya β€” tapi mindset bagaimana integrate AI ke dalam aplikasi nyata.

Beberapa key takeaways:

  1. AI itu augmentation, bukan replacement β€” AI bikin kasir lebih produktif, bukan menggantikan kasir
  2. Start simple, iterate β€” Mulai dari 1 fitur (Smart Search), validasi, baru expand
  3. User experience first β€” Streaming, loading states, error handling β€” semua ini penting
  4. Monitor and optimize β€” AI bisa mahal, jadi track usage dari awal
  5. Always have fallbacks β€” Kalau AI gagal, app harus tetap jalan

Semoga tutorial ini bermanfaat! Kalau ada pertanyaan atau mau share project yang kamu buat, feel free reach out.

Happy coding! πŸš€

Appendix: Complete File Structure

kasir-ai/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app/
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   β”œβ”€β”€ chat/
β”‚   β”‚   β”‚   β”‚   └── route.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ search/
β”‚   β”‚   β”‚   β”‚   └── route.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ analytics/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ route.ts
β”‚   β”‚   β”‚   β”‚   └── quick/
β”‚   β”‚   β”‚   β”‚       └── route.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ products/
β”‚   β”‚   β”‚   β”‚   └── route.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ transactions/
β”‚   β”‚   β”‚   β”‚   └── route.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ health/
β”‚   β”‚   β”‚   β”‚   └── route.ts
β”‚   β”‚   β”‚   └── test-ai/
β”‚   β”‚   β”‚       └── route.ts
β”‚   β”‚   β”‚
β”‚   β”‚   β”œβ”€β”€ kasir/
β”‚   β”‚   β”‚   └── page.tsx
β”‚   β”‚   β”œβ”€β”€ analytics/
β”‚   β”‚   β”‚   └── page.tsx
β”‚   β”‚   β”œβ”€β”€ layout.tsx
β”‚   β”‚   β”œβ”€β”€ page.tsx
β”‚   β”‚   └── globals.css
β”‚   β”‚
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”œβ”€β”€ pos/
β”‚   β”‚   β”‚   β”œβ”€β”€ SearchBar.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ ProductGrid.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ Cart.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ AIAssistant.tsx
β”‚   β”‚   β”‚   └── PaymentDialog.tsx
β”‚   β”‚   β”œβ”€β”€ analytics/
β”‚   β”‚   β”‚   └── QuickStats.tsx
β”‚   β”‚   └── ui/
β”‚   β”‚       └── ... (shadcn components)
β”‚   β”‚
β”‚   β”œβ”€β”€ lib/
β”‚   β”‚   β”œβ”€β”€ ai/
β”‚   β”‚   β”‚   β”œβ”€β”€ langchain.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ tools.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ prompts.ts
β”‚   β”‚   β”‚   └── cache.ts
β”‚   β”‚   β”œβ”€β”€ db.ts
β”‚   β”‚   └── utils.ts
β”‚   β”‚
β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   └── useDebounce.ts
β”‚   β”‚
β”‚   └── types/
β”‚       └── index.ts
β”‚
β”œβ”€β”€ prisma/
β”‚   β”œβ”€β”€ schema.prisma
β”‚   └── seed.ts
β”‚
β”œβ”€β”€ .env.local
β”œβ”€β”€ .env.production
β”œβ”€β”€ package.json
β”œβ”€β”€ vercel.json
β”œβ”€β”€ tailwind.config.ts
β”œβ”€β”€ tsconfig.json
└── next.config.js


Artikel ini adalah bagian dari seri "AI untuk Developer Indonesia" di BuildWithAngga. Total waktu baca: ~45 menit. Waktu implementasi: ~4-6 jam untuk developer yang sudah familiar dengan Next.js.


Tentang Penulis

Tutorial ini ditulis sebagai bagian dari seri pembelajaran AI Development di BuildWithAngga. Untuk tutorial lebih lengkap tentang web development, AI integration, dan career tips untuk developer Indonesia, kunjungi buildwithangga.com.