Tutorial Vibe Coding Next js Midtrans Vercel Supabase Web Laundry Management & Point of Sales

Pelajari cara membangun aplikasi web laundry management dan point of sales menggunakan Next.js 14, Supabase, Midtrans, dan deploy ke Vercel. Tutorial lengkap dengan metode vibe coding untuk developer Indonesia yang ingin bikin sistem kasir modern.

Bagian 1: Opening & Project Setup

Kemarin saya mampir ke laundry kiloan dekat rumah. Antri lumayan panjang, dan yang bikin saya perhatikan — mbak kasirnya nulis nota pakai kertas karbon. Satu lembar buat customer, satu lembar buat arsip.

Terus ada ibu-ibu telepon: "Mbak, cucian saya yang kemarin sudah belum ya?"

Mbak kasirnya harus buka-buka tumpukan nota, cari satu-satu. Butuh waktu 2 menit cuma buat cek status satu order.

Ini tahun 2026. Masih banyak bisnis laundry yang operasionalnya begini:

  • Nota tulis tangan yang gampang hilang
  • Customer harus telepon buat tanya status
  • Kasir hitung manual, kadang salah
  • Tidak ada laporan penjualan yang rapi
  • Pembayaran cash only

Padahal solusinya sederhana: Web app modern yang bisa handle semuanya.

Yang Akan Kita Bangun

Di tutorial ini, kita akan bikin sistem lengkap dengan 2 sisi:

┌─────────────────────────────────────────────────────────┐
│           LAUNDRY MANAGEMENT + POS                      │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  📊 DASHBOARD (Admin/Kasir)                             │
│  ├── Dashboard statistik real-time                      │
│  ├── CRUD layanan laundry                               │
│  ├── CRUD data customer                                 │
│  ├── POS: buat order baru (walk-in)                     │
│  ├── Kelola order & update status                       │
│  └── Laporan penjualan                                  │
│                                                         │
│  🛒 FRONT CHECKOUT (Customer)                           │
│  ├── Landing page informatif                            │
│  ├── Order laundry online                               │
│  ├── Bayar via Midtrans                                 │
│  └── Track status order                                 │
│                                                         │
└─────────────────────────────────────────────────────────┘

Flow Aplikasi

Flow 1: Customer Walk-in (via Kasir)

Customer datang → Kasir input order di POS → Pilih layanan & berat
→ Generate nota → Customer bayar → Proses laundry → Update status
→ Customer ambil → Selesai

Flow 2: Customer Order Online

Buka website → Pilih layanan → Input data → Checkout via Midtrans
→ Dapat nomor order → Antar cucian ke outlet → Track status via web
→ Ambil saat selesai

Kenapa Tech Stack Ini?

TechnologyKenapa Dipilih
Next.js 14Full-stack React, App Router, Server Actions — satu framework untuk frontend + backend
SupabasePostgreSQL + Auth + Storage gratis, real-time updates, mudah setup
MidtransPayment gateway lokal Indonesia, support transfer bank & e-wallet
VercelDeploy gratis, auto CI/CD dari GitHub, edge network cepat
TailwindCSS + shadcn/uiStyling cepat, komponen siap pakai, konsisten
ZustandState management ringan untuk cart POS

Kombinasi ini powerful karena:

  • Gratis untuk mulai — Supabase dan Vercel punya free tier yang generous
  • Full-stack dalam satu repo — Tidak perlu maintain backend terpisah
  • Production ready — Bukan cuma tutorial, bisa langsung dipakai bisnis beneran
  • Developer experience bagus — TypeScript, hot reload, error messages jelas

Prerequisites

Sebelum mulai, pastikan sudah ada:

Kalau belum familiar dengan React atau Next.js, saya sarankan ambil dulu kelas React Fundamental atau kelas Next.js di BuildWithAngga.

Setup Project

Prompt ke Claude

Buatkan langkah-langkah setup project Next.js 14 untuk aplikasi laundry POS dengan:
- TypeScript
- TailwindCSS
- App Router (src directory)
- Dependencies: @supabase/supabase-js, @supabase/ssr, zustand, react-hook-form, zod, @hookform/resolvers, recharts, midtrans-client
- shadcn/ui components: button, input, label, card, dialog, table, select, badge, tabs, sheet, dropdown-menu, avatar, separator, skeleton, toast, form
- Folder structure untuk: components, lib, actions, stores, types

Berikan command lengkap yang bisa di-copy paste.

Review Output (30%)

Step 1: Create Next.js Project

npx create-next-app@latest laundry-pos --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

Pilih opsi:

  • 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? › Yes (@/*)
cd laundry-pos

Step 2: Install Dependencies

# Supabase
npm install @supabase/supabase-js @supabase/ssr

# State & Forms
npm install zustand react-hook-form zod @hookform/resolvers

# Charts
npm install recharts

# Midtrans (untuk server-side)
npm install midtrans-client

# Date utilities
npm install date-fns

Step 3: Setup shadcn/ui

npx shadcn@latest init

Pilih opsi:

  • Style: Default
  • Base color: Slate
  • CSS variables: Yes

Install komponen yang dibutuhkan:

npx shadcn@latest add button input label card dialog table select badge tabs sheet dropdown-menu avatar separator skeleton toast form textarea scroll-area alert

Step 4: Buat Folder Structure

# Buat folder untuk organize code
mkdir -p src/components/ui
mkdir -p src/components/dashboard
mkdir -p src/components/pos
mkdir -p src/components/frontend
mkdir -p src/lib/supabase
mkdir -p src/actions
mkdir -p src/stores
mkdir -p src/types

Step 5: Setup Environment Variables

Buat file .env.local di root project:

# Supabase
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

# Midtrans
MIDTRANS_SERVER_KEY=your_midtrans_server_key
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=your_midtrans_client_key
MIDTRANS_IS_PRODUCTION=false

# App
NEXT_PUBLIC_APP_URL=http://localhost:3000

Step 6: Buat TypeScript Types

src/types/index.ts:

export type OrderStatus = 'pending' | 'processing' | 'ready' | 'picked_up' | 'cancelled';
export type PaymentStatus = 'unpaid' | 'paid' | 'refunded';
export type PaymentMethod = 'cash' | 'transfer' | 'midtrans';
export type OrderType = 'walk_in' | 'online';

export interface Service {
  id: string;
  name: string;
  slug: string;
  description: string | null;
  price_per_kg: number;
  estimated_hours: number;
  icon: string;
  is_active: boolean;
  created_at: string;
  updated_at: string;
}

export interface Customer {
  id: string;
  name: string;
  phone: string;
  email: string | null;
  address: string | null;
  notes: string | null;
  total_orders: number;
  total_spent: number;
  created_at: string;
  updated_at: string;
}

export interface Order {
  id: string;
  order_number: string;
  customer_id: string;
  created_by: string | null;
  order_type: OrderType;
  status: OrderStatus;
  payment_status: PaymentStatus;
  payment_method: PaymentMethod | null;
  subtotal: number;
  discount: number;
  total: number;
  notes: string | null;
  estimated_done_at: string | null;
  completed_at: string | null;
  picked_up_at: string | null;
  created_at: string;
  updated_at: string;
  // Relations
  customer?: Customer;
  order_items?: OrderItem[];
}

export interface OrderItem {
  id: string;
  order_id: string;
  service_id: string;
  service_name: string;
  quantity: number;
  price_per_unit: number;
  subtotal: number;
  notes: string | null;
  created_at: string;
  // Relations
  service?: Service;
}

export interface Payment {
  id: string;
  order_id: string;
  amount: number;
  payment_method: string;
  midtrans_transaction_id: string | null;
  midtrans_snap_token: string | null;
  status: 'pending' | 'success' | 'failed' | 'expired';
  paid_at: string | null;
  raw_response: Record<string, unknown> | null;
  created_at: string;
}

// Cart types untuk POS
export interface CartItem {
  service: Service;
  quantity: number;
  subtotal: number;
  notes?: string;
}

export interface CartState {
  items: CartItem[];
  customer: Customer | null;
  discount: number;
  notes: string;
}

Step 7: Buat Utility Functions

src/lib/utils.ts:

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

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

// Format angka ke Rupiah
export function formatRupiah(amount: number): string {
  return new Intl.NumberFormat('id-ID', {
    style: 'currency',
    currency: 'IDR',
    minimumFractionDigits: 0,
    maximumFractionDigits: 0,
  }).format(amount);
}

// Format tanggal ke Indonesia
export function formatDate(date: string | Date): string {
  return new Intl.DateTimeFormat('id-ID', {
    day: 'numeric',
    month: 'long',
    year: 'numeric',
  }).format(new Date(date));
}

// Format datetime ke Indonesia
export function formatDateTime(date: string | Date): string {
  return new Intl.DateTimeFormat('id-ID', {
    day: 'numeric',
    month: 'short',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  }).format(new Date(date));
}

// Generate order number: LD-YYMMDD-XXX
export function generateOrderNumber(): string {
  const now = new Date();
  const yy = now.getFullYear().toString().slice(-2);
  const mm = (now.getMonth() + 1).toString().padStart(2, '0');
  const dd = now.getDate().toString().padStart(2, '0');
  const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
  return `LD-${yy}${mm}${dd}-${random}`;
}

// Format nomor HP Indonesia
export function formatPhone(phone: string): string {
  // Remove non-digits
  const cleaned = phone.replace(/\\D/g, '');
  // Format: 0812-3456-7890
  if (cleaned.length >= 10) {
    return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}-${cleaned.slice(8)}`;
  }
  return phone;
}

// Hitung estimasi selesai
export function calculateEstimatedDone(hours: number): Date {
  const now = new Date();
  now.setHours(now.getHours() + hours);
  return now;
}

// Status badge color
export function getStatusColor(status: string): string {
  const colors: Record<string, string> = {
    pending: 'bg-yellow-100 text-yellow-800',
    processing: 'bg-blue-100 text-blue-800',
    ready: 'bg-green-100 text-green-800',
    picked_up: 'bg-gray-100 text-gray-800',
    cancelled: 'bg-red-100 text-red-800',
    unpaid: 'bg-orange-100 text-orange-800',
    paid: 'bg-emerald-100 text-emerald-800',
    refunded: 'bg-purple-100 text-purple-800',
  };
  return colors[status] || 'bg-gray-100 text-gray-800';
}

// Status label Indonesia
export function getStatusLabel(status: string): string {
  const labels: Record<string, string> = {
    pending: 'Menunggu',
    processing: 'Diproses',
    ready: 'Selesai',
    picked_up: 'Diambil',
    cancelled: 'Dibatalkan',
    unpaid: 'Belum Bayar',
    paid: 'Lunas',
    refunded: 'Refund',
  };
  return labels[status] || status;
}

Step 8: Test Setup

Jalankan development server:

npm run dev

Buka http://localhost:3000 — harusnya muncul halaman default Next.js.

Struktur Project Saat Ini

laundry-pos/
├── src/
│   ├── app/
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── components/
│   │   └── ui/           # shadcn components
│   ├── lib/
│   │   ├── supabase/     # akan diisi nanti
│   │   └── utils.ts
│   ├── actions/          # Server Actions
│   ├── stores/           # Zustand stores
│   └── types/
│       └── index.ts
├── .env.local
├── tailwind.config.ts
├── next.config.js
└── package.json

Yang Perlu Diperhatikan:

  • ✅ Next.js 14 dengan App Router sudah ready
  • ✅ TailwindCSS + shadcn/ui components sudah terinstall
  • ✅ TypeScript types sudah didefinisikan
  • ✅ Utility functions untuk format Rupiah, tanggal, dll
  • ⚠️ Environment variables masih placeholder — akan diisi setelah setup Supabase
  • ⚠️ Supabase client belum dibuat — next bagian

Di bagian selanjutnya, kita akan setup Supabase: create project, buat database schema, dan connect dari Next.js.

Bagian 2: Setup Supabase & Database

Sekarang kita setup Supabase sebagai backend — database PostgreSQL, authentication, dan storage dalam satu platform. Supabase ini kayak Firebase tapi pakai PostgreSQL, jadi lebih powerful untuk relational data.

Create Supabase Project

  1. Buka supabase.com dan login/signup
  2. Klik New Project
  3. Isi detail:
    • Name: laundry-pos
    • Database Password: (generate strong password, simpan!)
    • Region: Southeast Asia (Singapore) — closest ke Indonesia
  4. Klik Create new project
  5. Tunggu beberapa menit sampai project ready

Copy Environment Variables

Setelah project ready:

  1. Buka Project SettingsAPI
  2. Copy nilai berikut ke .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...

Penting:

  • NEXT_PUBLIC_* = bisa diakses di client (browser)
  • SUPABASE_SERVICE_ROLE_KEY = hanya di server, jangan expose ke client

Buat Database Schema

Kita akan buat semua tables via SQL Editor di Supabase Dashboard.

Prompt ke Claude

Buatkan SQL lengkap untuk sistem laundry POS dengan tables:
1. services - layanan laundry (id UUID, name, slug unique, description, price_per_kg integer, estimated_hours, icon, is_active, timestamps)
2. customers - data pelanggan (id UUID, name, phone unique, email, address, notes, total_orders default 0, total_spent default 0, timestamps)
3. orders - order header (id UUID, order_number unique, customer_id FK, created_by FK nullable ke auth.users, order_type enum, status enum, payment_status enum, payment_method enum nullable, subtotal, discount default 0, total, notes, estimated_done_at, completed_at nullable, picked_up_at nullable, timestamps)
4. order_items - detail item (id UUID, order_id FK, service_id FK, service_name snapshot, quantity decimal, price_per_unit snapshot, subtotal, notes, created_at)
5. payments - transaksi pembayaran (id UUID, order_id FK, amount, payment_method, midtrans_transaction_id, midtrans_snap_token, status enum, paid_at nullable, raw_response jsonb, created_at)

Include:
- Proper foreign keys dengan ON DELETE
- Indexes untuk kolom yang sering di-query
- RLS policies: services public read, sisanya authenticated
- Seed data 6 layanan laundry

Semua harga dalam Rupiah (integer, bukan decimal).

Review Output (30%)

Buka SQL Editor di Supabase Dashboard, lalu jalankan SQL berikut:

1. Create Enums

-- Create custom types/enums
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'ready', 'picked_up', 'cancelled');
CREATE TYPE payment_status AS ENUM ('unpaid', 'paid', 'refunded');
CREATE TYPE payment_method AS ENUM ('cash', 'transfer', 'midtrans');
CREATE TYPE order_type AS ENUM ('walk_in', 'online');
CREATE TYPE payment_transaction_status AS ENUM ('pending', 'success', 'failed', 'expired');

2. Create Tables

-- Services table
CREATE TABLE services (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    description TEXT,
    price_per_kg INTEGER NOT NULL,
    estimated_hours INTEGER NOT NULL DEFAULT 24,
    icon VARCHAR(50) DEFAULT '🧺',
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Customers table
CREATE TABLE customers (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    phone VARCHAR(20) NOT NULL UNIQUE,
    email VARCHAR(255),
    address TEXT,
    notes TEXT,
    total_orders INTEGER DEFAULT 0,
    total_spent INTEGER DEFAULT 0,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Orders table
CREATE TABLE orders (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    order_number VARCHAR(20) NOT NULL UNIQUE,
    customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
    created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
    order_type order_type NOT NULL DEFAULT 'walk_in',
    status order_status NOT NULL DEFAULT 'pending',
    payment_status payment_status NOT NULL DEFAULT 'unpaid',
    payment_method payment_method,
    subtotal INTEGER NOT NULL DEFAULT 0,
    discount INTEGER NOT NULL DEFAULT 0,
    total INTEGER NOT NULL DEFAULT 0,
    notes TEXT,
    estimated_done_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    picked_up_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Order Items table
CREATE TABLE order_items (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    service_id UUID NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
    service_name VARCHAR(255) NOT NULL,
    quantity DECIMAL(10,2) NOT NULL,
    price_per_unit INTEGER NOT NULL,
    subtotal INTEGER NOT NULL,
    notes TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Payments table
CREATE TABLE payments (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    amount INTEGER NOT NULL,
    payment_method VARCHAR(50) NOT NULL,
    midtrans_transaction_id VARCHAR(255),
    midtrans_snap_token VARCHAR(255),
    status payment_transaction_status NOT NULL DEFAULT 'pending',
    paid_at TIMESTAMPTZ,
    raw_response JSONB,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

3. Create Indexes

-- Indexes for better query performance
CREATE INDEX idx_customers_phone ON customers(phone);
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_payment_status ON orders(payment_status);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
CREATE INDEX idx_orders_order_number ON orders(order_number);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_payments_order_id ON payments(order_id);
CREATE INDEX idx_payments_status ON payments(status);

4. Create Updated_at Trigger

-- Function to auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

-- Apply trigger to tables
CREATE TRIGGER update_services_updated_at
    BEFORE UPDATE ON services
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

CREATE TRIGGER update_customers_updated_at
    BEFORE UPDATE ON customers
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

CREATE TRIGGER update_orders_updated_at
    BEFORE UPDATE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

5. Enable Row Level Security (RLS)

-- Enable RLS on all tables
ALTER TABLE services ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;

-- Services: Public read, authenticated write
CREATE POLICY "Services are viewable by everyone"
    ON services FOR SELECT
    USING (true);

CREATE POLICY "Services are editable by authenticated users"
    ON services FOR ALL
    USING (auth.role() = 'authenticated')
    WITH CHECK (auth.role() = 'authenticated');

-- Customers: Authenticated only
CREATE POLICY "Customers are viewable by authenticated users"
    ON customers FOR SELECT
    USING (auth.role() = 'authenticated');

CREATE POLICY "Customers are editable by authenticated users"
    ON customers FOR ALL
    USING (auth.role() = 'authenticated')
    WITH CHECK (auth.role() = 'authenticated');

-- Orders: Authenticated users + customer can view own orders via phone
CREATE POLICY "Orders are viewable by authenticated users"
    ON orders FOR SELECT
    USING (auth.role() = 'authenticated');

CREATE POLICY "Orders are editable by authenticated users"
    ON orders FOR ALL
    USING (auth.role() = 'authenticated')
    WITH CHECK (auth.role() = 'authenticated');

-- Allow customers to view their own orders (for tracking page)
CREATE POLICY "Customers can view own orders"
    ON orders FOR SELECT
    USING (
        EXISTS (
            SELECT 1 FROM customers
            WHERE customers.id = orders.customer_id
        )
    );

-- Order Items: Same as orders
CREATE POLICY "Order items are viewable by authenticated users"
    ON order_items FOR SELECT
    USING (auth.role() = 'authenticated');

CREATE POLICY "Order items are editable by authenticated users"
    ON order_items FOR ALL
    USING (auth.role() = 'authenticated')
    WITH CHECK (auth.role() = 'authenticated');

-- Payments: Authenticated only
CREATE POLICY "Payments are viewable by authenticated users"
    ON payments FOR SELECT
    USING (auth.role() = 'authenticated');

CREATE POLICY "Payments are editable by authenticated users"
    ON payments FOR ALL
    USING (auth.role() = 'authenticated')
    WITH CHECK (auth.role() = 'authenticated');

6. Seed Data Services

-- Seed services data
INSERT INTO services (name, slug, description, price_per_kg, estimated_hours, icon) VALUES
    ('Cuci Kering', 'cuci-kering', 'Cuci dan keringkan saja tanpa setrika', 7000, 48, '🧺'),
    ('Cuci + Setrika', 'cuci-setrika', 'Cuci, keringkan, dan setrika rapi', 10000, 72, '👔'),
    ('Setrika Saja', 'setrika-saja', 'Setrika pakaian yang sudah bersih', 5000, 24, '🔥'),
    ('Express 6 Jam', 'express', 'Layanan cepat selesai dalam 6 jam', 20000, 6, '⚡'),
    ('Dry Clean', 'dry-clean', 'Untuk pakaian khusus yang tidak bisa dicuci air', 25000, 72, '✨'),
    ('Cuci Sepatu', 'cuci-sepatu', 'Cuci sepatu berbagai jenis (harga per pasang)', 35000, 72, '👟');

7. Create Admin User

-- Note: Untuk create user, gunakan Supabase Auth UI atau API
-- Ini hanya untuk reference struktur user metadata

-- Setelah user register via Auth, update metadata:
-- UPDATE auth.users
-- SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{role}', '"admin"')
-- WHERE email = '[email protected]';

Setup Supabase Client di Next.js

Prompt ke Claude

Buatkan setup Supabase client untuk Next.js 14 App Router dengan:
1. Client-side client (untuk komponen client)
2. Server-side client (untuk Server Actions dan Route Handlers)
3. Middleware untuk protect routes

Gunakan @supabase/ssr package untuk handling cookies.

Review Output (30%)

src/lib/supabase/client.ts (untuk Client Components):

import { createBrowserClient } from '@supabase/ssr';

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

src/lib/supabase/server.ts (untuk Server Components & Actions):

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing sessions.
          }
        },
      },
    }
  );
}

src/lib/supabase/admin.ts (untuk operasi dengan service role):

import { createClient } from '@supabase/supabase-js';

// Admin client dengan service role - HANYA untuk server-side!
export function createAdminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    {
      auth: {
        autoRefreshToken: false,
        persistSession: false,
      },
    }
  );
}

src/lib/supabase/middleware.ts (helper untuk middleware):

import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // Refresh session if expired
  const {
    data: { user },
  } = await supabase.auth.getUser();

  // Protected routes
  const isAuthPage = request.nextUrl.pathname.startsWith('/login');
  const isDashboard = request.nextUrl.pathname.startsWith('/dashboard');

  // Redirect unauthenticated users from dashboard to login
  if (!user && isDashboard) {
    const url = request.nextUrl.clone();
    url.pathname = '/login';
    return NextResponse.redirect(url);
  }

  // Redirect authenticated users from login to dashboard
  if (user && isAuthPage) {
    const url = request.nextUrl.clone();
    url.pathname = '/dashboard';
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

middleware.ts (di root project):

import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - public folder
     * - api routes (we'll handle auth there separately)
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

Test Koneksi

Buat file test untuk memastikan koneksi Supabase berfungsi.

src/app/test/page.tsx:

import { createClient } from '@/lib/supabase/server';

export default async function TestPage() {
  const supabase = await createClient();

  const { data: services, error } = await supabase
    .from('services')
    .select('*')
    .order('created_at', { ascending: true });

  if (error) {
    return (
      <div className="p-8">
        <h1 className="text-2xl font-bold text-red-600">Error</h1>
        <pre className="mt-4 p-4 bg-red-50 rounded">
          {JSON.stringify(error, null, 2)}
        </pre>
      </div>
    );
  }

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold text-green-600 mb-4">
        ✅ Supabase Connected!
      </h1>
      <h2 className="text-lg font-semibold mb-2">Services ({services?.length}):</h2>
      <div className="grid gap-4">
        {services?.map((service) => (
          <div key={service.id} className="p-4 border rounded-lg">
            <div className="flex items-center gap-2">
              <span className="text-2xl">{service.icon}</span>
              <span className="font-medium">{service.name}</span>
            </div>
            <p className="text-gray-600 mt-1">{service.description}</p>
            <p className="text-green-600 font-semibold mt-1">
              Rp {service.price_per_kg.toLocaleString('id-ID')}/kg
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

Buka http://localhost:3000/test — harusnya muncul list 6 layanan yang sudah di-seed.

Create Admin User via Supabase Dashboard

  1. Buka AuthenticationUsers di Supabase Dashboard
  2. Klik Add UserCreate New User
  3. Isi:
  4. Klik Create User

Atau via SQL untuk set role:

-- Setelah user dibuat, set role admin
UPDATE auth.users
SET raw_user_meta_data = raw_user_meta_data || '{"role": "admin", "name": "Admin Laundry"}'::jsonb
WHERE email = '[email protected]';

Yang Perlu Diperhatikan:

  • ✅ Semua tables sudah dibuat dengan proper relations
  • ✅ RLS policies mengamankan data
  • ✅ Indexes untuk query yang sering dipakai
  • ✅ Supabase client untuk client & server sudah ready
  • ✅ Middleware protect routes /dashboard/*
  • ✅ Seed data 6 layanan laundry
  • ⚠️ Ganti password admin di production
  • ⚠️ Service role key jangan sampai ke client

Di bagian selanjutnya, kita akan bikin halaman login dan dashboard layout dengan sidebar.

Bagian 3: Authentication & Dashboard Layout

Sekarang kita bikin sistem authentication dan layout dashboard. User harus login dulu sebelum bisa akses halaman dashboard, POS, dan fitur admin lainnya.

Halaman Login

Prompt ke Claude

Buatkan halaman login untuk Next.js 14 dengan:
- Form email dan password menggunakan react-hook-form + zod
- Styling dengan shadcn/ui components (Card, Input, Button, Label)
- Server Action untuk handle login via Supabase Auth
- Loading state saat submit
- Error handling dengan toast notification
- Redirect ke /dashboard setelah berhasil login
- Design clean dan centered di tengah layar
- Logo/brand di atas form

Review Output (30%)

src/lib/validations/auth.ts:

import { z } from 'zod';

export const loginSchema = z.object({
  email: z
    .string()
    .min(1, 'Email wajib diisi')
    .email('Format email tidak valid'),
  password: z
    .string()
    .min(1, 'Password wajib diisi')
    .min(6, 'Password minimal 6 karakter'),
});

export type LoginInput = z.infer<typeof loginSchema>;

src/actions/auth.ts:

'use server';

import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { loginSchema, type LoginInput } from '@/lib/validations/auth';

export async function login(data: LoginInput) {
  // Validate input
  const validated = loginSchema.safeParse(data);

  if (!validated.success) {
    return {
      error: validated.error.errors[0].message,
    };
  }

  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email: validated.data.email,
    password: validated.data.password,
  });

  if (error) {
    return {
      error: error.message === 'Invalid login credentials'
        ? 'Email atau password salah'
        : error.message,
    };
  }

  redirect('/dashboard');
}

export async function logout() {
  const supabase = await createClient();
  await supabase.auth.signOut();
  redirect('/login');
}

export async function getUser() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  return user;
}

src/app/(auth)/login/page.tsx:

'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, WashingMachine } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';

import { login } from '@/actions/auth';
import { loginSchema, type LoginInput } from '@/lib/validations/auth';

export default function LoginPage() {
  const [isLoading, setIsLoading] = useState(false);
  const { toast } = useToast();

  const form = useForm<LoginInput>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  async function onSubmit(data: LoginInput) {
    setIsLoading(true);

    try {
      const result = await login(data);

      if (result?.error) {
        toast({
          variant: 'destructive',
          title: 'Login Gagal',
          description: result.error,
        });
      }
    } catch (error) {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: 'Terjadi kesalahan. Silakan coba lagi.',
      });
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
      <Card className="w-full max-w-md">
        <CardHeader className="text-center">
          <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100">
            <WashingMachine className="h-8 w-8 text-blue-600" />
          </div>
          <CardTitle className="text-2xl">Laundry POS</CardTitle>
          <CardDescription>
            Masuk ke dashboard untuk mengelola laundry
          </CardDescription>
        </CardHeader>
        <CardContent>
          <form className="space-y-4">
            <div className="space-y-2">
              <Label htmlFor="email">Email</Label>
              <Input
                id="email"
                type="email"
                placeholder="[email protected]"
                {...form.register('email')}
                disabled={isLoading}
              />
              {form.formState.errors.email && (
                <p className="text-sm text-red-500">
                  {form.formState.errors.email.message}
                </p>
              )}
            </div>

            <div className="space-y-2">
              <Label htmlFor="password">Password</Label>
              <Input
                id="password"
                type="password"
                placeholder="••••••••"
                {...form.register('password')}
                disabled={isLoading}
              />
              {form.formState.errors.password && (
                <p className="text-sm text-red-500">
                  {form.formState.errors.password.message}
                </p>
              )}
            </div>

            <Button
              type="submit"
              className="w-full"
              disabled={isLoading}
            >
              {isLoading ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  Memproses...
                </>
              ) : (
                'Masuk'
              )}
            </Button>
          </form>

          <div className="mt-6 p-4 bg-gray-50 rounded-lg">
            <p className="text-sm text-gray-600 text-center">
              <strong>Demo Account:</strong><br />
              Email: [email protected]<br />
              Password: password123
            </p>
          </div>
        </CardContent>
      </Card>
    </div>
  );
}

src/app/(auth)/layout.tsx:

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <>{children}</>;
}

Dashboard Layout dengan Sidebar

Prompt ke Claude

Buatkan layout dashboard untuk Next.js 14 dengan:
- Sidebar navigation di kiri dengan menu:
  - Dashboard (home icon)
  - POS / Buat Order (plus-circle icon)
  - Orders (list icon)
  - Services (sparkles icon)
  - Customers (users icon)
- Header di atas dengan:
  - Toggle sidebar untuk mobile
  - Breadcrumb atau title halaman
  - User dropdown (nama, logout)
- Sidebar collapsible di mobile (sheet/drawer)
- Active state untuk menu yang sedang dibuka
- Styling dengan shadcn/ui dan TailwindCSS
- Responsive: sidebar hidden di mobile, muncul sebagai drawer

Review Output (30%)

src/components/dashboard/sidebar.tsx:

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
  LayoutDashboard,
  PlusCircle,
  ListOrdered,
  Sparkles,
  Users,
  WashingMachine,
} from 'lucide-react';
import { cn } from '@/lib/utils';

const menuItems = [
  {
    title: 'Dashboard',
    href: '/dashboard',
    icon: LayoutDashboard,
  },
  {
    title: 'Buat Order',
    href: '/dashboard/pos',
    icon: PlusCircle,
  },
  {
    title: 'Orders',
    href: '/dashboard/orders',
    icon: ListOrdered,
  },
  {
    title: 'Layanan',
    href: '/dashboard/services',
    icon: Sparkles,
  },
  {
    title: 'Customers',
    href: '/dashboard/customers',
    icon: Users,
  },
];

interface SidebarProps {
  className?: string;
}

export function Sidebar({ className }: SidebarProps) {
  const pathname = usePathname();

  return (
    <div className={cn('flex flex-col h-full', className)}>
      {/* Logo */}
      <div className="flex items-center gap-2 px-6 py-5 border-b">
        <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-600">
          <WashingMachine className="h-6 w-6 text-white" />
        </div>
        <div>
          <h1 className="font-bold text-lg">Laundry POS</h1>
          <p className="text-xs text-gray-500">Management System</p>
        </div>
      </div>

      {/* Navigation */}
      <nav className="flex-1 px-4 py-4 space-y-1">
        {menuItems.map((item) => {
          const isActive = pathname === item.href ||
            (item.href !== '/dashboard' && pathname.startsWith(item.href));

          return (
            <Link
              key={item.href}
              href={item.href}
              className={cn(
                'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
                isActive
                  ? 'bg-blue-50 text-blue-600'
                  : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
              )}
            >
              <item.icon className={cn(
                'h-5 w-5',
                isActive ? 'text-blue-600' : 'text-gray-400'
              )} />
              {item.title}
            </Link>
          );
        })}
      </nav>

      {/* Footer */}
      <div className="px-4 py-4 border-t">
        <p className="text-xs text-gray-400 text-center">
          © 2026 Laundry POS
        </p>
      </div>
    </div>
  );
}

src/components/dashboard/header.tsx:

'use client';

import { useState } from 'react';
import { Menu, LogOut, User } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Sidebar } from './sidebar';
import { logout } from '@/actions/auth';

interface HeaderProps {
  user: {
    email?: string;
    name?: string;
  } | null;
}

export function Header({ user }: HeaderProps) {
  const [open, setOpen] = useState(false);

  const userName = user?.name || user?.email?.split('@')[0] || 'User';
  const userInitials = userName.slice(0, 2).toUpperCase();

  return (
    <header className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b bg-white px-4 md:px-6">
      {/* Mobile menu button */}
      <Sheet open={open}>
        <SheetTrigger asChild>
          <Button variant="ghost" size="icon" className="md:hidden">
            <Menu className="h-5 w-5" />
            <span className="sr-only">Toggle menu</span>
          </Button>
        </SheetTrigger>
        <SheetContent side="left" className="p-0 w-72">
          <Sidebar />
        </SheetContent>
      </Sheet>

      {/* Spacer */}
      <div className="flex-1" />

      {/* User dropdown */}
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" className="gap-2">
            <Avatar className="h-8 w-8">
              <AvatarFallback className="bg-blue-100 text-blue-600 text-sm">
                {userInitials}
              </AvatarFallback>
            </Avatar>
            <span className="hidden md:inline-block font-medium">
              {userName}
            </span>
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" className="w-56">
          <DropdownMenuLabel>
            <div className="flex flex-col">
              <span>{userName}</span>
              <span className="text-xs font-normal text-gray-500">
                {user?.email}
              </span>
            </div>
          </DropdownMenuLabel>
          <DropdownMenuSeparator />
          <DropdownMenuItem asChild>
            <button => logout()}
              className="w-full flex items-center gap-2 text-red-600 cursor-pointer"
            >
              <LogOut className="h-4 w-4" />
              Logout
            </button>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </header>
  );
}

src/app/(dashboard)/layout.tsx:

import { redirect } from 'next/navigation';
import { getUser } from '@/actions/auth';
import { Sidebar } from '@/components/dashboard/sidebar';
import { Header } from '@/components/dashboard/header';
import { Toaster } from '@/components/ui/toaster';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();

  if (!user) {
    redirect('/login');
  }

  const userData = {
    email: user.email,
    name: user.user_metadata?.name || user.email?.split('@')[0],
  };

  return (
    <div className="flex h-screen bg-gray-50">
      {/* Sidebar - hidden on mobile */}
      <aside className="hidden md:flex md:w-64 md:flex-col border-r bg-white">
        <Sidebar />
      </aside>

      {/* Main content */}
      <div className="flex flex-1 flex-col overflow-hidden">
        <Header user={userData} />

        <main className="flex-1 overflow-y-auto p-4 md:p-6">
          {children}
        </main>
      </div>

      <Toaster />
    </div>
  );
}

src/app/(dashboard)/dashboard/page.tsx:

import { createClient } from '@/lib/supabase/server';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
  ShoppingCart,
  DollarSign,
  Clock,
  CheckCircle
} from 'lucide-react';
import { formatRupiah } from '@/lib/utils';

export default async function DashboardPage() {
  const supabase = await createClient();

  // Get today's date range
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const tomorrow = new Date(today);
  tomorrow.setDate(tomorrow.getDate() + 1);

  // Fetch stats
  const [
    { count: totalOrders },
    { data: todayOrders },
    { count: pendingOrders },
    { count: readyOrders },
  ] = await Promise.all([
    supabase.from('orders').select('*', { count: 'exact', head: true }),
    supabase
      .from('orders')
      .select('total')
      .gte('created_at', today.toISOString())
      .lt('created_at', tomorrow.toISOString())
      .eq('payment_status', 'paid'),
    supabase
      .from('orders')
      .select('*', { count: 'exact', head: true })
      .eq('status', 'processing'),
    supabase
      .from('orders')
      .select('*', { count: 'exact', head: true })
      .eq('status', 'ready'),
  ]);

  const todayRevenue = todayOrders?.reduce((sum, order) => sum + order.total, 0) || 0;

  const stats = [
    {
      title: 'Total Orders',
      value: totalOrders || 0,
      icon: ShoppingCart,
      color: 'text-blue-600',
      bgColor: 'bg-blue-100',
    },
    {
      title: 'Revenue Hari Ini',
      value: formatRupiah(todayRevenue),
      icon: DollarSign,
      color: 'text-green-600',
      bgColor: 'bg-green-100',
    },
    {
      title: 'Sedang Diproses',
      value: pendingOrders || 0,
      icon: Clock,
      color: 'text-orange-600',
      bgColor: 'bg-orange-100',
    },
    {
      title: 'Siap Diambil',
      value: readyOrders || 0,
      icon: CheckCircle,
      color: 'text-emerald-600',
      bgColor: 'bg-emerald-100',
    },
  ];

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold">Dashboard</h1>
        <p className="text-gray-500">Overview bisnis laundry Anda</p>
      </div>

      {/* Stats Grid */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        {stats.map((stat) => (
          <Card key={stat.title}>
            <CardHeader className="flex flex-row items-center justify-between pb-2">
              <CardTitle className="text-sm font-medium text-gray-600">
                {stat.title}
              </CardTitle>
              <div className={`p-2 rounded-lg ${stat.bgColor}`}>
                <stat.icon className={`h-4 w-4 ${stat.color}`} />
              </div>
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stat.value}</div>
            </CardContent>
          </Card>
        ))}
      </div>

      {/* Placeholder for charts and recent orders */}
      <div className="grid gap-6 md:grid-cols-2">
        <Card>
          <CardHeader>
            <CardTitle>Revenue 7 Hari Terakhir</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="h-64 flex items-center justify-center text-gray-400">
              Chart akan ditambahkan di bagian 10
            </div>
          </CardContent>
        </Card>

        <Card>
          <CardHeader>
            <CardTitle>Order Terbaru</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="h-64 flex items-center justify-center text-gray-400">
              Tabel orders akan ditambahkan di bagian 10
            </div>
          </CardContent>
        </Card>
      </div>
    </div>
  );
}

Setup Toaster untuk Notifications

Pastikan sudah ada hooks untuk toast:

src/hooks/use-toast.ts:

// Copy dari shadcn/ui atau jalankan:
// npx shadcn@latest add toast
// File ini akan auto-generated

Jika belum ada, install toast component:

npx shadcn@latest add toast

Test Login Flow

  1. Jalankan npm run dev
  2. Buka http://localhost:3000/login
  3. Login dengan:
  4. Setelah login, harusnya redirect ke /dashboard
  5. Coba klik menu di sidebar — pastikan active state bekerja
  6. Test logout via dropdown di header

Placeholder Pages untuk Menu Lain

Buat placeholder pages agar menu tidak error:

src/app/(dashboard)/dashboard/pos/page.tsx:

export default function POSPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold">Buat Order (POS)</h1>
      <p className="text-gray-500 mt-2">Coming in Bagian 6</p>
    </div>
  );
}

src/app/(dashboard)/dashboard/orders/page.tsx:

export default function OrdersPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold">Daftar Orders</h1>
      <p className="text-gray-500 mt-2">Coming in Bagian 7</p>
    </div>
  );
}

src/app/(dashboard)/dashboard/services/page.tsx:

export default function ServicesPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold">Kelola Layanan</h1>
      <p className="text-gray-500 mt-2">Coming in Bagian 4</p>
    </div>
  );
}

src/app/(dashboard)/dashboard/customers/page.tsx:

export default function CustomersPage() {
  return (
    <div>
      <h1 className="text-2xl font-bold">Kelola Customers</h1>
      <p className="text-gray-500 mt-2">Coming in Bagian 5</p>
    </div>
  );
}

Yang Perlu Diperhatikan:

  • ✅ Login form dengan validasi Zod
  • ✅ Server Action untuk authentication
  • ✅ Sidebar dengan active state
  • ✅ Header dengan user dropdown & logout
  • ✅ Mobile responsive dengan Sheet/drawer
  • ✅ Dashboard dengan stats cards
  • ✅ Protected routes via middleware
  • ⚠️ Toast component harus di-install via shadcn
  • ⚠️ User metadata (name, role) harus di-set di Supabase

Di bagian selanjutnya, kita akan bikin CRUD untuk Services/Layanan laundry.

Bagian 4: CRUD Services/Layanan

Services adalah data master yang menentukan layanan apa saja yang ditawarkan laundry — cuci kering, cuci setrika, express, dry clean, dan sebagainya. Setiap service punya harga per kg dan estimasi waktu pengerjaan.

Di bagian ini kita akan bikin halaman untuk:

  • Lihat semua layanan dalam tabel
  • Tambah layanan baru via dialog/modal
  • Edit layanan
  • Toggle aktif/nonaktif
  • Hapus layanan

Validasi Schema

Prompt ke Claude

Buatkan Zod schema untuk validasi service laundry dengan fields:
- name: string required, min 2 karakter
- slug: string required, lowercase, hanya huruf, angka, dan dash
- description: string optional
- price_per_kg: number required, minimal 1000
- estimated_hours: number required, minimal 1
- icon: string required (emoji)
- is_active: boolean default true

Buat juga type inference dari schema.

Review Output (30%)

src/lib/validations/service.ts:

import { z } from 'zod';

export const serviceSchema = z.object({
  name: z
    .string()
    .min(2, 'Nama layanan minimal 2 karakter')
    .max(100, 'Nama layanan maksimal 100 karakter'),
  slug: z
    .string()
    .min(2, 'Slug minimal 2 karakter')
    .max(100, 'Slug maksimal 100 karakter')
    .regex(
      /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
      'Slug hanya boleh huruf kecil, angka, dan dash'
    ),
  description: z
    .string()
    .max(500, 'Deskripsi maksimal 500 karakter')
    .optional()
    .or(z.literal('')),
  price_per_kg: z
    .number({ invalid_type_error: 'Harga harus berupa angka' })
    .min(1000, 'Harga minimal Rp 1.000'),
  estimated_hours: z
    .number({ invalid_type_error: 'Estimasi harus berupa angka' })
    .min(1, 'Estimasi minimal 1 jam'),
  icon: z
    .string()
    .min(1, 'Icon wajib diisi')
    .max(10, 'Icon maksimal 10 karakter'),
  is_active: z.boolean().default(true),
});

export type ServiceInput = z.infer<typeof serviceSchema>;

// Schema untuk create (tanpa id)
export const createServiceSchema = serviceSchema;

// Schema untuk update (semua optional kecuali yang di-pass)
export const updateServiceSchema = serviceSchema.partial();

Server Actions untuk Services

Prompt ke Claude

Buatkan Server Actions untuk CRUD services dengan:
1. getServices() - fetch semua services, urut by created_at
2. getService(id) - fetch single service by id
3. createService(data) - create service baru dengan validasi
4. updateService(id, data) - update service dengan validasi
5. toggleServiceStatus(id) - toggle is_active
6. deleteService(id) - soft delete atau hard delete

Gunakan Supabase server client.
Return proper response dengan success/error.
Revalidate path setelah mutasi.

Review Output (30%)

src/actions/services.ts:

'use server';

import { revalidatePath } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
import {
  serviceSchema,
  updateServiceSchema,
  type ServiceInput,
} from '@/lib/validations/service';

export type ActionResponse<T = unknown> = {
  success: boolean;
  data?: T;
  error?: string;
};

// Get all services
export async function getServices(): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from('services')
      .select('*')
      .order('created_at', { ascending: true });

    if (error) throw error;

    return { success: true, data };
  } catch (error) {
    console.error('Error fetching services:', error);
    return {
      success: false,
      error: 'Gagal mengambil data layanan'
    };
  }
}

// Get single service
export async function getService(id: string): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from('services')
      .select('*')
      .eq('id', id)
      .single();

    if (error) throw error;

    return { success: true, data };
  } catch (error) {
    console.error('Error fetching service:', error);
    return {
      success: false,
      error: 'Layanan tidak ditemukan'
    };
  }
}

// Create new service
export async function createService(
  input: ServiceInput
): Promise<ActionResponse> {
  try {
    // Validate input
    const validated = serviceSchema.safeParse(input);

    if (!validated.success) {
      return {
        success: false,
        error: validated.error.errors[0].message,
      };
    }

    const supabase = await createClient();

    // Check if slug already exists
    const { data: existing } = await supabase
      .from('services')
      .select('id')
      .eq('slug', validated.data.slug)
      .single();

    if (existing) {
      return {
        success: false,
        error: 'Slug sudah digunakan, pilih slug lain',
      };
    }

    // Insert service
    const { data, error } = await supabase
      .from('services')
      .insert(validated.data)
      .select()
      .single();

    if (error) throw error;

    revalidatePath('/dashboard/services');

    return { success: true, data };
  } catch (error) {
    console.error('Error creating service:', error);
    return {
      success: false,
      error: 'Gagal membuat layanan baru'
    };
  }
}

// Update service
export async function updateService(
  id: string,
  input: Partial<ServiceInput>
): Promise<ActionResponse> {
  try {
    // Validate input
    const validated = updateServiceSchema.safeParse(input);

    if (!validated.success) {
      return {
        success: false,
        error: validated.error.errors[0].message,
      };
    }

    const supabase = await createClient();

    // Check if slug already exists (if updating slug)
    if (validated.data.slug) {
      const { data: existing } = await supabase
        .from('services')
        .select('id')
        .eq('slug', validated.data.slug)
        .neq('id', id)
        .single();

      if (existing) {
        return {
          success: false,
          error: 'Slug sudah digunakan, pilih slug lain',
        };
      }
    }

    // Update service
    const { data, error } = await supabase
      .from('services')
      .update(validated.data)
      .eq('id', id)
      .select()
      .single();

    if (error) throw error;

    revalidatePath('/dashboard/services');

    return { success: true, data };
  } catch (error) {
    console.error('Error updating service:', error);
    return {
      success: false,
      error: 'Gagal mengupdate layanan'
    };
  }
}

// Toggle service active status
export async function toggleServiceStatus(
  id: string
): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    // Get current status
    const { data: service, error: fetchError } = await supabase
      .from('services')
      .select('is_active')
      .eq('id', id)
      .single();

    if (fetchError) throw fetchError;

    // Toggle status
    const { data, error } = await supabase
      .from('services')
      .update({ is_active: !service.is_active })
      .eq('id', id)
      .select()
      .single();

    if (error) throw error;

    revalidatePath('/dashboard/services');

    return { success: true, data };
  } catch (error) {
    console.error('Error toggling service status:', error);
    return {
      success: false,
      error: 'Gagal mengubah status layanan'
    };
  }
}

// Delete service
export async function deleteService(id: string): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    // Check if service is used in any order_items
    const { count } = await supabase
      .from('order_items')
      .select('*', { count: 'exact', head: true })
      .eq('service_id', id);

    if (count && count > 0) {
      return {
        success: false,
        error: 'Layanan tidak bisa dihapus karena sudah digunakan dalam order. Nonaktifkan saja.',
      };
    }

    const { error } = await supabase
      .from('services')
      .delete()
      .eq('id', id);

    if (error) throw error;

    revalidatePath('/dashboard/services');

    return { success: true };
  } catch (error) {
    console.error('Error deleting service:', error);
    return {
      success: false,
      error: 'Gagal menghapus layanan'
    };
  }
}

Komponen Form Service

Prompt ke Claude

Buatkan komponen ServiceForm untuk create/edit service dengan:
- React Hook Form + Zod resolver
- Fields: name (auto-generate slug), slug, description, price_per_kg (dengan format Rupiah), estimated_hours, icon (emoji picker sederhana), is_active (switch)
- Mode create dan edit (pre-fill data jika edit)
- Loading state saat submit
- Callback onSuccess untuk close dialog

Review Output (30%)

src/components/dashboard/services/service-form.tsx:

'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';

import { createService, updateService } from '@/actions/services';
import { serviceSchema, type ServiceInput } from '@/lib/validations/service';
import type { Service } from '@/types';

const EMOJI_OPTIONS = [
  { value: '🧺', label: '🧺 Keranjang' },
  { value: '👔', label: '👔 Kemeja' },
  { value: '🔥', label: '🔥 Setrika' },
  { value: '⚡', label: '⚡ Express' },
  { value: '✨', label: '✨ Premium' },
  { value: '👟', label: '👟 Sepatu' },
  { value: '🧥', label: '🧥 Jaket' },
  { value: '🛏️', label: '🛏️ Bedcover' },
  { value: '🧸', label: '🧸 Boneka' },
  { value: '👜', label: '👜 Tas' },
];

interface ServiceFormProps {
  service?: Service;
  onSuccess?: () => void;
}

export function ServiceForm({ service, onSuccess }: ServiceFormProps) {
  const [isLoading, setIsLoading] = useState(false);
  const { toast } = useToast();
  const isEdit = !!service;

  const form = useForm<ServiceInput>({
    resolver: zodResolver(serviceSchema),
    defaultValues: {
      name: service?.name || '',
      slug: service?.slug || '',
      description: service?.description || '',
      price_per_kg: service?.price_per_kg || 10000,
      estimated_hours: service?.estimated_hours || 24,
      icon: service?.icon || '🧺',
      is_active: service?.is_active ?? true,
    },
  });

  // Auto-generate slug from name
  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const name = e.target.value;
    form.setValue('name', name);

    // Only auto-generate slug if creating new or slug is empty
    if (!isEdit || !form.getValues('slug')) {
      const slug = name
        .toLowerCase()
        .replace(/[^a-z0-9\\s-]/g, '')
        .replace(/\\s+/g, '-')
        .replace(/-+/g, '-')
        .trim();
      form.setValue('slug', slug);
    }
  };

  async function onSubmit(data: ServiceInput) {
    setIsLoading(true);

    try {
      const result = isEdit
        ? await updateService(service.id, data)
        : await createService(data);

      if (result.success) {
        toast({
          title: isEdit ? 'Layanan Diupdate' : 'Layanan Dibuat',
          description: `Layanan "${data.name}" berhasil ${isEdit ? 'diupdate' : 'dibuat'}.`,
        });
        onSuccess?.();
      } else {
        toast({
          variant: 'destructive',
          title: 'Error',
          description: result.error,
        });
      }
    } catch (error) {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: 'Terjadi kesalahan. Silakan coba lagi.',
      });
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <form className="space-y-4">
      <div className="grid grid-cols-2 gap-4">
        {/* Name */}
        <div className="space-y-2">
          <Label htmlFor="name">Nama Layanan</Label>
          <Input
            id="name"
            placeholder="Cuci Setrika"
            {...form.register('name')}
            disabled={isLoading}
          />
          {form.formState.errors.name && (
            <p className="text-sm text-red-500">
              {form.formState.errors.name.message}
            </p>
          )}
        </div>

        {/* Slug */}
        <div className="space-y-2">
          <Label htmlFor="slug">Slug (URL)</Label>
          <Input
            id="slug"
            placeholder="cuci-setrika"
            {...form.register('slug')}
            disabled={isLoading}
          />
          {form.formState.errors.slug && (
            <p className="text-sm text-red-500">
              {form.formState.errors.slug.message}
            </p>
          )}
        </div>
      </div>

      {/* Description */}
      <div className="space-y-2">
        <Label htmlFor="description">Deskripsi</Label>
        <Textarea
          id="description"
          placeholder="Deskripsi singkat layanan..."
          rows={2}
          {...form.register('description')}
          disabled={isLoading}
        />
        {form.formState.errors.description && (
          <p className="text-sm text-red-500">
            {form.formState.errors.description.message}
          </p>
        )}
      </div>

      <div className="grid grid-cols-3 gap-4">
        {/* Price */}
        <div className="space-y-2">
          <Label htmlFor="price_per_kg">Harga per Kg</Label>
          <div className="relative">
            <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 text-sm">
              Rp
            </span>
            <Input
              id="price_per_kg"
              type="number"
              className="pl-10"
              placeholder="10000"
              {...form.register('price_per_kg', { valueAsNumber: true })}
              disabled={isLoading}
            />
          </div>
          {form.formState.errors.price_per_kg && (
            <p className="text-sm text-red-500">
              {form.formState.errors.price_per_kg.message}
            </p>
          )}
        </div>

        {/* Estimated Hours */}
        <div className="space-y-2">
          <Label htmlFor="estimated_hours">Estimasi (Jam)</Label>
          <Input
            id="estimated_hours"
            type="number"
            placeholder="24"
            {...form.register('estimated_hours', { valueAsNumber: true })}
            disabled={isLoading}
          />
          {form.formState.errors.estimated_hours && (
            <p className="text-sm text-red-500">
              {form.formState.errors.estimated_hours.message}
            </p>
          )}
        </div>

        {/* Icon */}
        <div className="space-y-2">
          <Label htmlFor="icon">Icon</Label>
          <Select
            value={form.watch('icon')} => form.setValue('icon', value)}
            disabled={isLoading}
          >
            <SelectTrigger>
              <SelectValue placeholder="Pilih icon" />
            </SelectTrigger>
            <SelectContent>
              {EMOJI_OPTIONS.map((emoji) => (
                <SelectItem key={emoji.value} value={emoji.value}>
                  {emoji.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          {form.formState.errors.icon && (
            <p className="text-sm text-red-500">
              {form.formState.errors.icon.message}
            </p>
          )}
        </div>
      </div>

      {/* Is Active */}
      <div className="flex items-center justify-between rounded-lg border p-4">
        <div className="space-y-0.5">
          <Label htmlFor="is_active" className="text-base">
            Status Aktif
          </Label>
          <p className="text-sm text-gray-500">
            Layanan yang tidak aktif tidak akan muncul di POS
          </p>
        </div>
        <Switch
          id="is_active"
          checked={form.watch('is_active')} => form.setValue('is_active', checked)}
          disabled={isLoading}
        />
      </div>

      {/* Submit Button */}
      <div className="flex justify-end gap-2 pt-4">
        <Button type="submit" disabled={isLoading}>
          {isLoading ? (
            <>
              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
              {isEdit ? 'Menyimpan...' : 'Membuat...'}
            </>
          ) : (
            <>{isEdit ? 'Simpan Perubahan' : 'Buat Layanan'}</>
          )}
        </Button>
      </div>
    </form>
  );
}

Halaman Services dengan DataTable

src/app/(dashboard)/dashboard/services/page.tsx:

import { Suspense } from 'react';
import { Plus } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';

import { getServices } from '@/actions/services';
import { ServicesTable } from '@/components/dashboard/services/services-table';
import { ServiceDialog } from '@/components/dashboard/services/service-dialog';

export default async function ServicesPage() {
  const { data: services, error } = await getServices();

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Layanan</h1>
          <p className="text-gray-500">
            Kelola layanan laundry yang tersedia
          </p>
        </div>
        <ServiceDialog>
          <Button>
            <Plus className="mr-2 h-4 w-4" />
            Tambah Layanan
          </Button>
        </ServiceDialog>
      </div>

      {error ? (
        <div className="rounded-lg border border-red-200 bg-red-50 p-4">
          <p className="text-red-600">{error}</p>
        </div>
      ) : (
        <Suspense fallback={<TableSkeleton />}>
          <ServicesTable services={services || []} />
        </Suspense>
      )}
    </div>
  );
}

function TableSkeleton() {
  return (
    <div className="space-y-4">
      <Skeleton className="h-10 w-full" />
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-16 w-full" />
    </div>
  );
}

src/components/dashboard/services/services-table.tsx:

'use client';

import { useState } from 'react';
import {
  MoreHorizontal,
  Pencil,
  Trash2,
  ToggleLeft,
  ToggleRight,
} from 'lucide-react';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/use-toast';

import { ServiceDialog } from './service-dialog';
import { toggleServiceStatus, deleteService } from '@/actions/services';
import { formatRupiah } from '@/lib/utils';
import type { Service } from '@/types';

interface ServicesTableProps {
  services: Service[];
}

export function ServicesTable({ services }: ServicesTableProps) {
  const [deleteId, setDeleteId] = useState<string | null>(null);
  const [isDeleting, setIsDeleting] = useState(false);
  const { toast } = useToast();

  async function handleToggle(id: string) {
    const result = await toggleServiceStatus(id);

    if (result.success) {
      toast({
        title: 'Status Diubah',
        description: 'Status layanan berhasil diubah.',
      });
    } else {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: result.error,
      });
    }
  }

  async function handleDelete() {
    if (!deleteId) return;

    setIsDeleting(true);
    const result = await deleteService(deleteId);

    if (result.success) {
      toast({
        title: 'Layanan Dihapus',
        description: 'Layanan berhasil dihapus.',
      });
    } else {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: result.error,
      });
    }

    setIsDeleting(false);
    setDeleteId(null);
  }

  function formatEstimatedTime(hours: number): string {
    if (hours < 24) {
      return `${hours} jam`;
    }
    const days = Math.floor(hours / 24);
    const remainingHours = hours % 24;
    if (remainingHours === 0) {
      return `${days} hari`;
    }
    return `${days} hari ${remainingHours} jam`;
  }

  if (services.length === 0) {
    return (
      <div className="rounded-lg border border-dashed p-8 text-center">
        <p className="text-gray-500">Belum ada layanan</p>
        <p className="text-sm text-gray-400 mt-1">
          Klik tombol "Tambah Layanan" untuk membuat layanan baru
        </p>
      </div>
    );
  }

  return (
    <>
      <div className="rounded-lg border bg-white">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead className="w-12"></TableHead>
              <TableHead>Nama Layanan</TableHead>
              <TableHead>Harga/Kg</TableHead>
              <TableHead>Estimasi</TableHead>
              <TableHead>Status</TableHead>
              <TableHead className="w-12"></TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {services.map((service) => (
              <TableRow key={service.id}>
                <TableCell>
                  <span className="text-2xl">{service.icon}</span>
                </TableCell>
                <TableCell>
                  <div>
                    <p className="font-medium">{service.name}</p>
                    {service.description && (
                      <p className="text-sm text-gray-500 line-clamp-1">
                        {service.description}
                      </p>
                    )}
                  </div>
                </TableCell>
                <TableCell className="font-medium text-green-600">
                  {formatRupiah(service.price_per_kg)}
                </TableCell>
                <TableCell>
                  {formatEstimatedTime(service.estimated_hours)}
                </TableCell>
                <TableCell>
                  <Badge
                    variant={service.is_active ? 'default' : 'secondary'}
                    className={
                      service.is_active
                        ? 'bg-green-100 text-green-700 hover:bg-green-100'
                        : 'bg-gray-100 text-gray-600'
                    }
                  >
                    {service.is_active ? 'Aktif' : 'Nonaktif'}
                  </Badge>
                </TableCell>
                <TableCell>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button variant="ghost" size="icon">
                        <MoreHorizontal className="h-4 w-4" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      <ServiceDialog service={service}>
                        <DropdownMenuItem => e.preventDefault()}>
                          <Pencil className="mr-2 h-4 w-4" />
                          Edit
                        </DropdownMenuItem>
                      </ServiceDialog>
                      <DropdownMenuItem => handleToggle(service.id)}>
                        {service.is_active ? (
                          <>
                            <ToggleLeft className="mr-2 h-4 w-4" />
                            Nonaktifkan
                          </>
                        ) : (
                          <>
                            <ToggleRight className="mr-2 h-4 w-4" />
                            Aktifkan
                          </>
                        )}
                      </DropdownMenuItem>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem
                        className="text-red-600" => setDeleteId(service.id)}
                      >
                        <Trash2 className="mr-2 h-4 w-4" />
                        Hapus
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>

      {/* Delete Confirmation Dialog */}
      <AlertDialog open={!!deleteId} => setDeleteId(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Hapus Layanan?</AlertDialogTitle>
            <AlertDialogDescription>
              Layanan yang sudah dihapus tidak bisa dikembalikan.
              Jika layanan sudah pernah digunakan dalam order, lebih baik
              nonaktifkan saja.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={isDeleting}>Batal</AlertDialogCancel>
            <AlertDialogAction
              disabled={isDeleting}
              className="bg-red-600 hover:bg-red-700"
            >
              {isDeleting ? 'Menghapus...' : 'Hapus'}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  );
}

src/components/dashboard/services/service-dialog.tsx:

'use client';

import { useState } from 'react';

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';

import { ServiceForm } from './service-form';
import type { Service } from '@/types';

interface ServiceDialogProps {
  children: React.ReactNode;
  service?: Service;
}

export function ServiceDialog({ children, service }: ServiceDialogProps) {
  const [open, setOpen] = useState(false);
  const isEdit = !!service;

  return (
    <Dialog open={open}>
      <DialogTrigger asChild>{children}</DialogTrigger>
      <DialogContent className="max-w-lg">
        <DialogHeader>
          <DialogTitle>
            {isEdit ? 'Edit Layanan' : 'Tambah Layanan Baru'}
          </DialogTitle>
          <DialogDescription>
            {isEdit
              ? 'Ubah informasi layanan di bawah ini'
              : 'Isi informasi layanan laundry baru'}
          </DialogDescription>
        </DialogHeader>
        <ServiceForm service={service} => setOpen(false)} />
      </DialogContent>
    </Dialog>
  );
}

Install Switch Component

Jika belum ada Switch component:

npx shadcn@latest add switch alert-dialog

Yang Perlu Diperhatikan:

  • ✅ Validasi dengan Zod schema yang ketat
  • ✅ Auto-generate slug dari nama layanan
  • ✅ Server Actions dengan proper error handling
  • ✅ Revalidate path setelah setiap mutasi
  • ✅ Cek sebelum delete — tidak bisa hapus jika sudah dipakai di order
  • ✅ Toggle status aktif/nonaktif
  • ✅ Delete confirmation dialog
  • ✅ Empty state jika belum ada data
  • ✅ Loading states di form
  • ⚠️ Pastikan Switch dan AlertDialog sudah di-install via shadcn

Di bagian selanjutnya, kita akan bikin CRUD untuk Customers.

Bagian 5: CRUD Customers

Customers adalah data pelanggan laundry — nama, nomor HP, alamat, dan statistik order mereka. Nomor HP jadi unique identifier karena akan dipakai untuk tracking order di sisi customer.

Validasi Schema

src/lib/validations/customer.ts:

import { z } from 'zod';

// Regex untuk nomor HP Indonesia (08xxx atau +628xxx)
const phoneRegex = /^(?:\\+62|62|0)8[1-9][0-9]{7,10}$/;

export const customerSchema = z.object({
  name: z
    .string()
    .min(2, 'Nama minimal 2 karakter')
    .max(100, 'Nama maksimal 100 karakter'),
  phone: z
    .string()
    .min(10, 'Nomor HP minimal 10 digit')
    .max(15, 'Nomor HP maksimal 15 digit')
    .regex(phoneRegex, 'Format nomor HP tidak valid (contoh: 081234567890)'),
  email: z
    .string()
    .email('Format email tidak valid')
    .optional()
    .or(z.literal('')),
  address: z
    .string()
    .max(500, 'Alamat maksimal 500 karakter')
    .optional()
    .or(z.literal('')),
  notes: z
    .string()
    .max(500, 'Catatan maksimal 500 karakter')
    .optional()
    .or(z.literal('')),
});

export type CustomerInput = z.infer<typeof customerSchema>;

export const updateCustomerSchema = customerSchema.partial();

Server Actions

src/actions/customers.ts:

'use server';

import { revalidatePath } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
import {
  customerSchema,
  updateCustomerSchema,
  type CustomerInput,
} from '@/lib/validations/customer';
import type { ActionResponse } from './services';

// Get all customers with optional search
export async function getCustomers(
  search?: string
): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    let query = supabase
      .from('customers')
      .select('*')
      .order('created_at', { ascending: false });

    // Search by name or phone
    if (search) {
      query = query.or(`name.ilike.%${search}%,phone.ilike.%${search}%`);
    }

    const { data, error } = await query;

    if (error) throw error;

    return { success: true, data };
  } catch (error) {
    console.error('Error fetching customers:', error);
    return {
      success: false,
      error: 'Gagal mengambil data customer'
    };
  }
}

// Get single customer with orders
export async function getCustomer(id: string): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from('customers')
      .select(`
        *,
        orders (
          id,
          order_number,
          status,
          payment_status,
          total,
          created_at
        )
      `)
      .eq('id', id)
      .single();

    if (error) throw error;

    return { success: true, data };
  } catch (error) {
    console.error('Error fetching customer:', error);
    return {
      success: false,
      error: 'Customer tidak ditemukan'
    };
  }
}

// Search customers (for autocomplete in POS)
export async function searchCustomers(
  query: string
): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from('customers')
      .select('id, name, phone')
      .or(`name.ilike.%${query}%,phone.ilike.%${query}%`)
      .limit(10);

    if (error) throw error;

    return { success: true, data };
  } catch (error) {
    console.error('Error searching customers:', error);
    return {
      success: false,
      error: 'Gagal mencari customer'
    };
  }
}

// Create new customer
export async function createCustomer(
  input: CustomerInput
): Promise<ActionResponse> {
  try {
    const validated = customerSchema.safeParse(input);

    if (!validated.success) {
      return {
        success: false,
        error: validated.error.errors[0].message,
      };
    }

    const supabase = await createClient();

    // Normalize phone number (remove +62 or 62 prefix, use 0)
    let phone = validated.data.phone.replace(/\\D/g, '');
    if (phone.startsWith('62')) {
      phone = '0' + phone.slice(2);
    }

    // Check if phone already exists
    const { data: existing } = await supabase
      .from('customers')
      .select('id')
      .eq('phone', phone)
      .single();

    if (existing) {
      return {
        success: false,
        error: 'Nomor HP sudah terdaftar',
      };
    }

    const { data, error } = await supabase
      .from('customers')
      .insert({
        ...validated.data,
        phone,
      })
      .select()
      .single();

    if (error) throw error;

    revalidatePath('/dashboard/customers');

    return { success: true, data };
  } catch (error) {
    console.error('Error creating customer:', error);
    return {
      success: false,
      error: 'Gagal membuat customer baru'
    };
  }
}

// Update customer
export async function updateCustomer(
  id: string,
  input: Partial<CustomerInput>
): Promise<ActionResponse> {
  try {
    const validated = updateCustomerSchema.safeParse(input);

    if (!validated.success) {
      return {
        success: false,
        error: validated.error.errors[0].message,
      };
    }

    const supabase = await createClient();

    // Normalize phone if provided
    let updateData = { ...validated.data };
    if (updateData.phone) {
      let phone = updateData.phone.replace(/\\D/g, '');
      if (phone.startsWith('62')) {
        phone = '0' + phone.slice(2);
      }

      // Check if phone already exists (exclude current customer)
      const { data: existing } = await supabase
        .from('customers')
        .select('id')
        .eq('phone', phone)
        .neq('id', id)
        .single();

      if (existing) {
        return {
          success: false,
          error: 'Nomor HP sudah digunakan customer lain',
        };
      }

      updateData.phone = phone;
    }

    const { data, error } = await supabase
      .from('customers')
      .update(updateData)
      .eq('id', id)
      .select()
      .single();

    if (error) throw error;

    revalidatePath('/dashboard/customers');

    return { success: true, data };
  } catch (error) {
    console.error('Error updating customer:', error);
    return {
      success: false,
      error: 'Gagal mengupdate customer'
    };
  }
}

// Delete customer
export async function deleteCustomer(id: string): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    // Check if customer has orders
    const { count } = await supabase
      .from('orders')
      .select('*', { count: 'exact', head: true })
      .eq('customer_id', id);

    if (count && count > 0) {
      return {
        success: false,
        error: `Customer memiliki ${count} order dan tidak bisa dihapus.`,
      };
    }

    const { error } = await supabase
      .from('customers')
      .delete()
      .eq('id', id);

    if (error) throw error;

    revalidatePath('/dashboard/customers');

    return { success: true };
  } catch (error) {
    console.error('Error deleting customer:', error);
    return {
      success: false,
      error: 'Gagal menghapus customer'
    };
  }
}

// Get customer by phone (for tracking page)
export async function getCustomerByPhone(
  phone: string
): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    // Normalize phone
    let normalizedPhone = phone.replace(/\\D/g, '');
    if (normalizedPhone.startsWith('62')) {
      normalizedPhone = '0' + normalizedPhone.slice(2);
    }

    const { data, error } = await supabase
      .from('customers')
      .select(`
        *,
        orders (
          id,
          order_number,
          status,
          payment_status,
          total,
          estimated_done_at,
          created_at,
          order_items (
            service_name,
            quantity,
            subtotal
          )
        )
      `)
      .eq('phone', normalizedPhone)
      .single();

    if (error) {
      if (error.code === 'PGRST116') {
        return {
          success: false,
          error: 'Nomor HP tidak ditemukan',
        };
      }
      throw error;
    }

    return { success: true, data };
  } catch (error) {
    console.error('Error fetching customer by phone:', error);
    return {
      success: false,
      error: 'Gagal mencari data customer'
    };
  }
}

Komponen Form Customer

src/components/dashboard/customers/customer-form.tsx:

'use client';

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/hooks/use-toast';

import { createCustomer, updateCustomer } from '@/actions/customers';
import { customerSchema, type CustomerInput } from '@/lib/validations/customer';
import type { Customer } from '@/types';

interface CustomerFormProps {
  customer?: Customer;
  onSuccess?: (customer?: Customer) => void;
}

export function CustomerForm({ customer, onSuccess }: CustomerFormProps) {
  const [isLoading, setIsLoading] = useState(false);
  const { toast } = useToast();
  const isEdit = !!customer;

  const form = useForm<CustomerInput>({
    resolver: zodResolver(customerSchema),
    defaultValues: {
      name: customer?.name || '',
      phone: customer?.phone || '',
      email: customer?.email || '',
      address: customer?.address || '',
      notes: customer?.notes || '',
    },
  });

  async function onSubmit(data: CustomerInput) {
    setIsLoading(true);

    try {
      const result = isEdit
        ? await updateCustomer(customer.id, data)
        : await createCustomer(data);

      if (result.success) {
        toast({
          title: isEdit ? 'Customer Diupdate' : 'Customer Ditambahkan',
          description: `Customer "${data.name}" berhasil ${isEdit ? 'diupdate' : 'ditambahkan'}.`,
        });
        onSuccess?.(result.data as Customer);
      } else {
        toast({
          variant: 'destructive',
          title: 'Error',
          description: result.error,
        });
      }
    } catch (error) {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: 'Terjadi kesalahan. Silakan coba lagi.',
      });
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <form className="space-y-4">
      {/* Name */}
      <div className="space-y-2">
        <Label htmlFor="name">Nama Lengkap</Label>
        <Input
          id="name"
          placeholder="John Doe"
          {...form.register('name')}
          disabled={isLoading}
        />
        {form.formState.errors.name && (
          <p className="text-sm text-red-500">
            {form.formState.errors.name.message}
          </p>
        )}
      </div>

      {/* Phone */}
      <div className="space-y-2">
        <Label htmlFor="phone">Nomor HP</Label>
        <Input
          id="phone"
          placeholder="081234567890"
          {...form.register('phone')}
          disabled={isLoading}
        />
        {form.formState.errors.phone && (
          <p className="text-sm text-red-500">
            {form.formState.errors.phone.message}
          </p>
        )}
        <p className="text-xs text-gray-500">
          Format: 08xxxxxxxxxx (tanpa spasi atau strip)
        </p>
      </div>

      {/* Email */}
      <div className="space-y-2">
        <Label htmlFor="email">Email (Opsional)</Label>
        <Input
          id="email"
          type="email"
          placeholder="[email protected]"
          {...form.register('email')}
          disabled={isLoading}
        />
        {form.formState.errors.email && (
          <p className="text-sm text-red-500">
            {form.formState.errors.email.message}
          </p>
        )}
      </div>

      {/* Address */}
      <div className="space-y-2">
        <Label htmlFor="address">Alamat (Opsional)</Label>
        <Textarea
          id="address"
          placeholder="Jl. Contoh No. 123, Kota"
          rows={2}
          {...form.register('address')}
          disabled={isLoading}
        />
        {form.formState.errors.address && (
          <p className="text-sm text-red-500">
            {form.formState.errors.address.message}
          </p>
        )}
      </div>

      {/* Notes */}
      <div className="space-y-2">
        <Label htmlFor="notes">Catatan (Opsional)</Label>
        <Textarea
          id="notes"
          placeholder="Catatan khusus untuk customer ini..."
          rows={2}
          {...form.register('notes')}
          disabled={isLoading}
        />
        {form.formState.errors.notes && (
          <p className="text-sm text-red-500">
            {form.formState.errors.notes.message}
          </p>
        )}
      </div>

      {/* Submit Button */}
      <div className="flex justify-end gap-2 pt-4">
        <Button type="submit" disabled={isLoading}>
          {isLoading ? (
            <>
              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
              {isEdit ? 'Menyimpan...' : 'Menambahkan...'}
            </>
          ) : (
            <>{isEdit ? 'Simpan Perubahan' : 'Tambah Customer'}</>
          )}
        </Button>
      </div>
    </form>
  );
}

Dialog Customer

src/components/dashboard/customers/customer-dialog.tsx:

'use client';

import { useState } from 'react';

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';

import { CustomerForm } from './customer-form';
import type { Customer } from '@/types';

interface CustomerDialogProps {
  children: React.ReactNode;
  customer?: Customer;
  onSuccess?: (customer?: Customer) => void;
}

export function CustomerDialog({
  children,
  customer,
  onSuccess
}: CustomerDialogProps) {
  const [open, setOpen] = useState(false);
  const isEdit = !!customer;

  function handleSuccess(data?: Customer) {
    setOpen(false);
    onSuccess?.(data);
  }

  return (
    <Dialog open={open}>
      <DialogTrigger asChild>{children}</DialogTrigger>
      <DialogContent className="max-w-md">
        <DialogHeader>
          <DialogTitle>
            {isEdit ? 'Edit Customer' : 'Tambah Customer Baru'}
          </DialogTitle>
          <DialogDescription>
            {isEdit
              ? 'Ubah informasi customer di bawah ini'
              : 'Isi data customer baru'}
          </DialogDescription>
        </DialogHeader>
        <CustomerForm customer={customer} />
      </DialogContent>
    </Dialog>
  );
}

Halaman Customers dengan Search

src/app/(dashboard)/dashboard/customers/page.tsx:

import { Suspense } from 'react';
import { Plus, Search } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';

import { getCustomers } from '@/actions/customers';
import { CustomersTable } from '@/components/dashboard/customers/customers-table';
import { CustomerDialog } from '@/components/dashboard/customers/customer-dialog';
import { CustomerSearch } from '@/components/dashboard/customers/customer-search';

interface CustomersPageProps {
  searchParams: Promise<{ q?: string }>;
}

export default async function CustomersPage({
  searchParams
}: CustomersPageProps) {
  const { q } = await searchParams;
  const { data: customers, error } = await getCustomers(q);

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Customers</h1>
          <p className="text-gray-500">
            Kelola data pelanggan laundry
          </p>
        </div>
        <CustomerDialog>
          <Button>
            <Plus className="mr-2 h-4 w-4" />
            Tambah Customer
          </Button>
        </CustomerDialog>
      </div>

      {/* Search */}
      <CustomerSearch initialSearch={q} />

      {error ? (
        <div className="rounded-lg border border-red-200 bg-red-50 p-4">
          <p className="text-red-600">{error}</p>
        </div>
      ) : (
        <Suspense fallback={<TableSkeleton />}>
          <CustomersTable customers={customers || []} />
        </Suspense>
      )}
    </div>
  );
}

function TableSkeleton() {
  return (
    <div className="space-y-4">
      <Skeleton className="h-10 w-full" />
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-16 w-full" />
    </div>
  );
}

src/components/dashboard/customers/customer-search.tsx:

'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Search, X } from 'lucide-react';

import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

interface CustomerSearchProps {
  initialSearch?: string;
}

export function CustomerSearch({ initialSearch }: CustomerSearchProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();
  const [search, setSearch] = useState(initialSearch || '');

  function handleSearch(value: string) {
    setSearch(value);

    startTransition(() => {
      const params = new URLSearchParams(searchParams);
      if (value) {
        params.set('q', value);
      } else {
        params.delete('q');
      }
      router.push(`/dashboard/customers?${params.toString()}`);
    });
  }

  function handleClear() {
    setSearch('');
    startTransition(() => {
      router.push('/dashboard/customers');
    });
  }

  return (
    <div className="relative max-w-sm">
      <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
      <Input
        value={search} => handleSearch(e.target.value)}
        placeholder="Cari nama atau nomor HP..."
        className="pl-10 pr-10"
      />
      {search && (
        <Button
          variant="ghost"
          size="icon"
          className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
        >
          <X className="h-4 w-4" />
        </Button>
      )}
      {isPending && (
        <div className="absolute right-10 top-1/2 -translate-y-1/2">
          <div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
        </div>
      )}
    </div>
  );
}

src/components/dashboard/customers/customers-table.tsx:

'use client';

import { useState } from 'react';
import { MoreHorizontal, Pencil, Trash2, Eye } from 'lucide-react';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/use-toast';

import { CustomerDialog } from './customer-dialog';
import { CustomerDetailSheet } from './customer-detail-sheet';
import { deleteCustomer } from '@/actions/customers';
import { formatRupiah, formatPhone, formatDate } from '@/lib/utils';
import type { Customer } from '@/types';

interface CustomersTableProps {
  customers: Customer[];
}

export function CustomersTable({ customers }: CustomersTableProps) {
  const [deleteId, setDeleteId] = useState<string | null>(null);
  const [isDeleting, setIsDeleting] = useState(false);
  const [detailCustomer, setDetailCustomer] = useState<Customer | null>(null);
  const { toast } = useToast();

  async function handleDelete() {
    if (!deleteId) return;

    setIsDeleting(true);
    const result = await deleteCustomer(deleteId);

    if (result.success) {
      toast({
        title: 'Customer Dihapus',
        description: 'Customer berhasil dihapus.',
      });
    } else {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: result.error,
      });
    }

    setIsDeleting(false);
    setDeleteId(null);
  }

  if (customers.length === 0) {
    return (
      <div className="rounded-lg border border-dashed p-8 text-center">
        <p className="text-gray-500">Belum ada customer</p>
        <p className="text-sm text-gray-400 mt-1">
          Customer akan otomatis ditambahkan saat membuat order
        </p>
      </div>
    );
  }

  return (
    <>
      <div className="rounded-lg border bg-white">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Nama</TableHead>
              <TableHead>Nomor HP</TableHead>
              <TableHead className="text-center">Orders</TableHead>
              <TableHead className="text-right">Total Spent</TableHead>
              <TableHead>Terdaftar</TableHead>
              <TableHead className="w-12"></TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {customers.map((customer) => (
              <TableRow key={customer.id}>
                <TableCell>
                  <div>
                    <p className="font-medium">{customer.name}</p>
                    {customer.email && (
                      <p className="text-sm text-gray-500">{customer.email}</p>
                    )}
                  </div>
                </TableCell>
                <TableCell className="font-mono">
                  {formatPhone(customer.phone)}
                </TableCell>
                <TableCell className="text-center">
                  <span className="inline-flex items-center justify-center h-6 w-6 rounded-full bg-blue-100 text-blue-600 text-sm font-medium">
                    {customer.total_orders}
                  </span>
                </TableCell>
                <TableCell className="text-right font-medium text-green-600">
                  {formatRupiah(customer.total_spent)}
                </TableCell>
                <TableCell className="text-gray-500">
                  {formatDate(customer.created_at)}
                </TableCell>
                <TableCell>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button variant="ghost" size="icon">
                        <MoreHorizontal className="h-4 w-4" />
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align="end">
                      <DropdownMenuItem => setDetailCustomer(customer)}
                      >
                        <Eye className="mr-2 h-4 w-4" />
                        Lihat Detail
                      </DropdownMenuItem>
                      <CustomerDialog customer={customer}>
                        <DropdownMenuItem => e.preventDefault()}>
                          <Pencil className="mr-2 h-4 w-4" />
                          Edit
                        </DropdownMenuItem>
                      </CustomerDialog>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem
                        className="text-red-600" => setDeleteId(customer.id)}
                      >
                        <Trash2 className="mr-2 h-4 w-4" />
                        Hapus
                      </DropdownMenuItem>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>

      {/* Detail Sheet */}
      <CustomerDetailSheet
        customer={detailCustomer}
        open={!!detailCustomer} => !open && setDetailCustomer(null)}
      />

      {/* Delete Confirmation */}
      <AlertDialog open={!!deleteId} => setDeleteId(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Hapus Customer?</AlertDialogTitle>
            <AlertDialogDescription>
              Customer yang sudah memiliki order tidak bisa dihapus.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={isDeleting}>Batal</AlertDialogCancel>
            <AlertDialogAction
              disabled={isDeleting}
              className="bg-red-600 hover:bg-red-700"
            >
              {isDeleting ? 'Menghapus...' : 'Hapus'}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  );
}

src/components/dashboard/customers/customer-detail-sheet.tsx:

'use client';

import { useEffect, useState } from 'react';
import { Phone, Mail, MapPin, FileText, ShoppingBag } from 'lucide-react';

import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';

import { getCustomer } from '@/actions/customers';
import { formatRupiah, formatDate, getStatusColor, getStatusLabel } from '@/lib/utils';
import type { Customer, Order } from '@/types';

interface CustomerDetailSheetProps {
  customer: Customer | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

interface CustomerWithOrders extends Customer {
  orders?: Order[];
}

export function CustomerDetailSheet({
  customer,
  open,
  onOpenChange,
}: CustomerDetailSheetProps) {
  const [data, setData] = useState<CustomerWithOrders | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (open && customer?.id) {
      setLoading(true);
      getCustomer(customer.id).then((result) => {
        if (result.success) {
          setData(result.data as CustomerWithOrders);
        }
        setLoading(false);
      });
    }
  }, [open, customer?.id]);

  return (
    <Sheet open={open}>
      <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
        <SheetHeader>
          <SheetTitle>{customer?.name}</SheetTitle>
          <SheetDescription>Detail informasi customer</SheetDescription>
        </SheetHeader>

        {loading ? (
          <div className="space-y-4 mt-6">
            <Skeleton className="h-20 w-full" />
            <Skeleton className="h-40 w-full" />
          </div>
        ) : data ? (
          <div className="mt-6 space-y-6">
            {/* Contact Info */}
            <div className="space-y-3">
              <div className="flex items-center gap-3 text-sm">
                <Phone className="h-4 w-4 text-gray-400" />
                <span>{data.phone}</span>
              </div>
              {data.email && (
                <div className="flex items-center gap-3 text-sm">
                  <Mail className="h-4 w-4 text-gray-400" />
                  <span>{data.email}</span>
                </div>
              )}
              {data.address && (
                <div className="flex items-start gap-3 text-sm">
                  <MapPin className="h-4 w-4 text-gray-400 mt-0.5" />
                  <span>{data.address}</span>
                </div>
              )}
              {data.notes && (
                <div className="flex items-start gap-3 text-sm">
                  <FileText className="h-4 w-4 text-gray-400 mt-0.5" />
                  <span className="text-gray-600">{data.notes}</span>
                </div>
              )}
            </div>

            {/* Stats */}
            <div className="grid grid-cols-2 gap-4">
              <div className="rounded-lg border p-4 text-center">
                <p className="text-2xl font-bold text-blue-600">
                  {data.total_orders}
                </p>
                <p className="text-sm text-gray-500">Total Orders</p>
              </div>
              <div className="rounded-lg border p-4 text-center">
                <p className="text-2xl font-bold text-green-600">
                  {formatRupiah(data.total_spent)}
                </p>
                <p className="text-sm text-gray-500">Total Spent</p>
              </div>
            </div>

            <Separator />

            {/* Order History */}
            <div>
              <h3 className="font-semibold flex items-center gap-2 mb-4">
                <ShoppingBag className="h-4 w-4" />
                Riwayat Order
              </h3>

              {data.orders && data.orders.length > 0 ? (
                <div className="space-y-3">
                  {data.orders.slice(0, 10).map((order) => (
                    <div
                      key={order.id}
                      className="flex items-center justify-between p-3 rounded-lg border"
                    >
                      <div>
                        <p className="font-mono text-sm font-medium">
                          {order.order_number}
                        </p>
                        <p className="text-xs text-gray-500">
                          {formatDate(order.created_at)}
                        </p>
                      </div>
                      <div className="text-right">
                        <p className="font-medium">
                          {formatRupiah(order.total)}
                        </p>
                        <Badge className={getStatusColor(order.status)}>
                          {getStatusLabel(order.status)}
                        </Badge>
                      </div>
                    </div>
                  ))}
                </div>
              ) : (
                <p className="text-sm text-gray-500 text-center py-4">
                  Belum ada order
                </p>
              )}
            </div>
          </div>
        ) : null}
      </SheetContent>
    </Sheet>
  );
}

Yang Perlu Diperhatikan:

  • ✅ Validasi nomor HP format Indonesia
  • ✅ Normalisasi nomor HP (hapus +62, 62 jadi 08)
  • ✅ Search realtime dengan URL params
  • ✅ Detail customer dengan order history di Sheet
  • ✅ Tidak bisa hapus customer yang punya order
  • ✅ Phone number unique constraint
  • ✅ Loading states dan empty states
  • ⚠️ Search menggunakan useTransition untuk smooth UX
  • ⚠️ Customer bisa diakses by phone untuk tracking page nanti

Di bagian selanjutnya, kita akan bikin fitur utama: POS (Point of Sales) untuk buat order baru.

Bagian 6: POS — Buat Order Baru

Ini adalah fitur inti dari sistem — Point of Sales (POS) untuk kasir membuat order baru. Layoutnya akan seperti kasir modern: pilih layanan di kiri, keranjang belanja di kanan.

Zustand Store untuk Cart

Prompt ke Claude

Buatkan Zustand store untuk cart POS dengan:
- items: array of { service, quantity, notes, subtotal }
- customer: selected customer atau null
- discount: angka diskon
- notes: catatan order

Actions:
- addItem(service, quantity): tambah item ke cart
- updateItemQuantity(serviceId, quantity): update qty
- removeItem(serviceId): hapus item
- setCustomer(customer): set customer
- setDiscount(amount): set diskon
- setNotes(notes): set catatan
- clearCart(): reset semua
- getSubtotal(): hitung subtotal
- getTotal(): hitung total setelah diskon

Dengan TypeScript dan persist ke localStorage.

Review Output (30%)

src/stores/cart-store.ts:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Service, Customer, CartItem } from '@/types';

interface CartStore {
  items: CartItem[];
  customer: Customer | null;
  discount: number;
  notes: string;

  // Actions
  addItem: (service: Service, quantity: number, notes?: string) => void;
  updateItemQuantity: (serviceId: string, quantity: number) => void;
  updateItemNotes: (serviceId: string, notes: string) => void;
  removeItem: (serviceId: string) => void;
  setCustomer: (customer: Customer | null) => void;
  setDiscount: (amount: number) => void;
  setNotes: (notes: string) => void;
  clearCart: () => void;

  // Computed
  getSubtotal: () => number;
  getTotal: () => number;
  getItemCount: () => number;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      customer: null,
      discount: 0,
      notes: '',

      addItem: (service, quantity, notes = '') => {
        set((state) => {
          const existingIndex = state.items.findIndex(
            (item) => item.service.id === service.id
          );

          if (existingIndex >= 0) {
            // Update existing item
            const newItems = [...state.items];
            const newQty = newItems[existingIndex].quantity + quantity;
            newItems[existingIndex] = {
              ...newItems[existingIndex],
              quantity: newQty,
              subtotal: service.price_per_kg * newQty,
            };
            return { items: newItems };
          }

          // Add new item
          const newItem: CartItem = {
            service,
            quantity,
            notes,
            subtotal: service.price_per_kg * quantity,
          };
          return { items: [...state.items, newItem] };
        });
      },

      updateItemQuantity: (serviceId, quantity) => {
        set((state) => {
          if (quantity <= 0) {
            return {
              items: state.items.filter((item) => item.service.id !== serviceId),
            };
          }

          return {
            items: state.items.map((item) =>
              item.service.id === serviceId
                ? {
                    ...item,
                    quantity,
                    subtotal: item.service.price_per_kg * quantity,
                  }
                : item
            ),
          };
        });
      },

      updateItemNotes: (serviceId, notes) => {
        set((state) => ({
          items: state.items.map((item) =>
            item.service.id === serviceId ? { ...item, notes } : item
          ),
        }));
      },

      removeItem: (serviceId) => {
        set((state) => ({
          items: state.items.filter((item) => item.service.id !== serviceId),
        }));
      },

      setCustomer: (customer) => {
        set({ customer });
      },

      setDiscount: (amount) => {
        set({ discount: Math.max(0, amount) });
      },

      setNotes: (notes) => {
        set({ notes });
      },

      clearCart: () => {
        set({
          items: [],
          customer: null,
          discount: 0,
          notes: '',
        });
      },

      getSubtotal: () => {
        return get().items.reduce((sum, item) => sum + item.subtotal, 0);
      },

      getTotal: () => {
        const subtotal = get().getSubtotal();
        const discount = get().discount;
        return Math.max(0, subtotal - discount);
      },

      getItemCount: () => {
        return get().items.length;
      },
    }),
    {
      name: 'laundry-cart',
      partialize: (state) => ({
        items: state.items,
        customer: state.customer,
        discount: state.discount,
        notes: state.notes,
      }),
    }
  )
);

Server Action Create Order

src/actions/orders.ts:

'use server';

import { revalidatePath } from 'next/cache';
import { createClient } from '@/lib/supabase/server';
import { getUser } from './auth';
import { generateOrderNumber } from '@/lib/utils';
import type { ActionResponse } from './services';
import type { CartItem, Customer } from '@/types';

interface CreateOrderInput {
  items: CartItem[];
  customer: Customer;
  discount: number;
  notes: string;
  paymentMethod: 'cash' | 'transfer' | 'midtrans';
}

export async function createOrder(
  input: CreateOrderInput
): Promise<ActionResponse> {
  try {
    const { items, customer, discount, notes, paymentMethod } = input;

    // Validation
    if (!items || items.length === 0) {
      return { success: false, error: 'Keranjang kosong' };
    }

    if (!customer?.id) {
      return { success: false, error: 'Customer belum dipilih' };
    }

    const supabase = await createClient();
    const user = await getUser();

    // Calculate totals
    const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0);
    const total = Math.max(0, subtotal - discount);

    // Calculate estimated done time (max dari semua items)
    const maxHours = Math.max(
      ...items.map((item) => item.service.estimated_hours)
    );
    const estimatedDoneAt = new Date();
    estimatedDoneAt.setHours(estimatedDoneAt.getHours() + maxHours);

    // Generate order number
    const orderNumber = generateOrderNumber();

    // Start transaction - create order
    const { data: order, error: orderError } = await supabase
      .from('orders')
      .insert({
        order_number: orderNumber,
        customer_id: customer.id,
        created_by: user?.id || null,
        order_type: 'walk_in',
        status: 'pending',
        payment_status: paymentMethod === 'midtrans' ? 'unpaid' : 'paid',
        payment_method: paymentMethod,
        subtotal,
        discount,
        total,
        notes: notes || null,
        estimated_done_at: estimatedDoneAt.toISOString(),
      })
      .select()
      .single();

    if (orderError) {
      console.error('Error creating order:', orderError);
      throw orderError;
    }

    // Create order items
    const orderItems = items.map((item) => ({
      order_id: order.id,
      service_id: item.service.id,
      service_name: item.service.name,
      quantity: item.quantity,
      price_per_unit: item.service.price_per_kg,
      subtotal: item.subtotal,
      notes: item.notes || null,
    }));

    const { error: itemsError } = await supabase
      .from('order_items')
      .insert(orderItems);

    if (itemsError) {
      console.error('Error creating order items:', itemsError);
      // Rollback: delete the order
      await supabase.from('orders').delete().eq('id', order.id);
      throw itemsError;
    }

    // Update customer stats
    await supabase
      .from('customers')
      .update({
        total_orders: customer.total_orders + 1,
        total_spent: customer.total_spent + total,
      })
      .eq('id', customer.id);

    revalidatePath('/dashboard/orders');
    revalidatePath('/dashboard/customers');
    revalidatePath('/dashboard');

    return {
      success: true,
      data: order,
    };
  } catch (error) {
    console.error('Error in createOrder:', error);
    return {
      success: false,
      error: 'Gagal membuat order. Silakan coba lagi.',
    };
  }
}

// Get orders with filters
export async function getOrders(filters?: {
  status?: string;
  paymentStatus?: string;
  search?: string;
  startDate?: string;
  endDate?: string;
}): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    let query = supabase
      .from('orders')
      .select(`
        *,
        customer:customers(id, name, phone),
        order_items(id, service_name, quantity, subtotal)
      `)
      .order('created_at', { ascending: false });

    if (filters?.status && filters.status !== 'all') {
      query = query.eq('status', filters.status);
    }

    if (filters?.paymentStatus && filters.paymentStatus !== 'all') {
      query = query.eq('payment_status', filters.paymentStatus);
    }

    if (filters?.search) {
      query = query.or(
        `order_number.ilike.%${filters.search}%,customer.name.ilike.%${filters.search}%`
      );
    }

    if (filters?.startDate) {
      query = query.gte('created_at', filters.startDate);
    }

    if (filters?.endDate) {
      query = query.lte('created_at', filters.endDate);
    }

    const { data, error } = await query;

    if (error) throw error;

    return { success: true, data };
  } catch (error) {
    console.error('Error fetching orders:', error);
    return { success: false, error: 'Gagal mengambil data order' };
  }
}

// Get single order detail
export async function getOrder(id: string): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from('orders')
      .select(`
        *,
        customer:customers(*),
        order_items(
          *,
          service:services(id, name, icon)
        )
      `)
      .eq('id', id)
      .single();

    if (error) throw error;

    return { success: true, data };
  } catch (error) {
    console.error('Error fetching order:', error);
    return { success: false, error: 'Order tidak ditemukan' };
  }
}

// Update order status
export async function updateOrderStatus(
  id: string,
  status: string
): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    const updateData: Record<string, unknown> = { status };

    // Set timestamp based on status
    if (status === 'ready') {
      updateData.completed_at = new Date().toISOString();
    } else if (status === 'picked_up') {
      updateData.picked_up_at = new Date().toISOString();
    }

    const { data, error } = await supabase
      .from('orders')
      .update(updateData)
      .eq('id', id)
      .select()
      .single();

    if (error) throw error;

    revalidatePath('/dashboard/orders');
    revalidatePath('/dashboard');

    return { success: true, data };
  } catch (error) {
    console.error('Error updating order status:', error);
    return { success: false, error: 'Gagal mengupdate status order' };
  }
}

// Update payment status
export async function updatePaymentStatus(
  id: string,
  paymentStatus: string
): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    const { data, error } = await supabase
      .from('orders')
      .update({ payment_status: paymentStatus })
      .eq('id', id)
      .select()
      .single();

    if (error) throw error;

    revalidatePath('/dashboard/orders');
    revalidatePath('/dashboard');

    return { success: true, data };
  } catch (error) {
    console.error('Error updating payment status:', error);
    return { success: false, error: 'Gagal mengupdate status pembayaran' };
  }
}

// Cancel order
export async function cancelOrder(id: string): Promise<ActionResponse> {
  try {
    const supabase = await createClient();

    // Get order to check status and customer
    const { data: order } = await supabase
      .from('orders')
      .select('status, customer_id, total')
      .eq('id', id)
      .single();

    if (!order) {
      return { success: false, error: 'Order tidak ditemukan' };
    }

    if (!['pending', 'processing'].includes(order.status)) {
      return {
        success: false,
        error: 'Order tidak bisa dibatalkan karena sudah selesai',
      };
    }

    // Update order status
    const { error } = await supabase
      .from('orders')
      .update({ status: 'cancelled' })
      .eq('id', id);

    if (error) throw error;

    // Revert customer stats
    const { data: customer } = await supabase
      .from('customers')
      .select('total_orders, total_spent')
      .eq('id', order.customer_id)
      .single();

    if (customer) {
      await supabase
        .from('customers')
        .update({
          total_orders: Math.max(0, customer.total_orders - 1),
          total_spent: Math.max(0, customer.total_spent - order.total),
        })
        .eq('id', order.customer_id);
    }

    revalidatePath('/dashboard/orders');
    revalidatePath('/dashboard/customers');
    revalidatePath('/dashboard');

    return { success: true };
  } catch (error) {
    console.error('Error cancelling order:', error);
    return { success: false, error: 'Gagal membatalkan order' };
  }
}

Komponen POS

src/components/pos/service-grid.tsx:

'use client';

import { useState } from 'react';
import { Plus, Minus } from 'lucide-react';

import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';

import { useCartStore } from '@/stores/cart-store';
import { formatRupiah } from '@/lib/utils';
import type { Service } from '@/types';

interface ServiceGridProps {
  services: Service[];
}

export function ServiceGrid({ services }: ServiceGridProps) {
  const [selectedService, setSelectedService] = useState<Service | null>(null);
  const [quantity, setQuantity] = useState<string>('1');
  const addItem = useCartStore((state) => state.addItem);

  const activeServices = services.filter((s) => s.is_active);

  function handleSelectService(service: Service) {
    setSelectedService(service);
    setQuantity('1');
  }

  function handleAddToCart() {
    if (selectedService && parseFloat(quantity) > 0) {
      addItem(selectedService, parseFloat(quantity));
      setSelectedService(null);
      setQuantity('1');
    }
  }

  function adjustQuantity(delta: number) {
    const current = parseFloat(quantity) || 0;
    const newValue = Math.max(0.5, current + delta);
    setQuantity(newValue.toString());
  }

  return (
    <>
      <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
        {activeServices.map((service) => (
          <Card
            key={service.id}
            className="cursor-pointer hover:border-blue-500 hover:shadow-md transition-all" => handleSelectService(service)}
          >
            <CardContent className="p-4 text-center">
              <span className="text-3xl">{service.icon}</span>
              <h3 className="font-medium mt-2 text-sm">{service.name}</h3>
              <p className="text-green-600 font-semibold text-sm mt-1">
                {formatRupiah(service.price_per_kg)}/kg
              </p>
              <p className="text-xs text-gray-400 mt-1">
                ±{service.estimated_hours} jam
              </p>
            </CardContent>
          </Card>
        ))}
      </div>

      {/* Quantity Dialog */}
      <Dialog
        open={!!selectedService} => setSelectedService(null)}
      >
        <DialogContent className="max-w-sm">
          <DialogHeader>
            <DialogTitle className="flex items-center gap-2">
              <span className="text-2xl">{selectedService?.icon}</span>
              {selectedService?.name}
            </DialogTitle>
          </DialogHeader>

          <div className="py-4">
            <Label>Berat (kg)</Label>
            <div className="flex items-center gap-2 mt-2">
              <Button
                variant="outline"
                size="icon" => adjustQuantity(-0.5)}
              >
                <Minus className="h-4 w-4" />
              </Button>
              <Input
                type="number"
                value={quantity} => setQuantity(e.target.value)}
                className="text-center text-lg font-semibold"
                min="0.5"
                step="0.5"
              />
              <Button
                variant="outline"
                size="icon" => adjustQuantity(0.5)}
              >
                <Plus className="h-4 w-4" />
              </Button>
            </div>

            {selectedService && (
              <div className="mt-4 p-3 bg-gray-50 rounded-lg">
                <div className="flex justify-between text-sm">
                  <span>Harga per kg</span>
                  <span>{formatRupiah(selectedService.price_per_kg)}</span>
                </div>
                <div className="flex justify-between font-semibold mt-2">
                  <span>Subtotal</span>
                  <span className="text-green-600">
                    {formatRupiah(
                      selectedService.price_per_kg * (parseFloat(quantity) || 0)
                    )}
                  </span>
                </div>
              </div>
            )}
          </div>

          <DialogFooter>
            <Button
              variant="outline" => setSelectedService(null)}
            >
              Batal
            </Button>
            <Button>Tambah ke Keranjang</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}

src/components/pos/cart.tsx:

'use client';

import { useState } from 'react';
import { Trash2, Minus, Plus, User, Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';

import { useCartStore } from '@/stores/cart-store';
import { CustomerSelect } from './customer-select';
import { createOrder } from '@/actions/orders';
import { formatRupiah } from '@/lib/utils';

export function Cart() {
  const router = useRouter();
  const { toast } = useToast();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [paymentMethod, setPaymentMethod] = useState<'cash' | 'transfer'>('cash');

  const {
    items,
    customer,
    discount,
    notes,
    updateItemQuantity,
    removeItem,
    setCustomer,
    setDiscount,
    setNotes,
    clearCart,
    getSubtotal,
    getTotal,
  } = useCartStore();

  const subtotal = getSubtotal();
  const total = getTotal();

  async function handleSubmit() {
    if (items.length === 0) {
      toast({
        variant: 'destructive',
        title: 'Keranjang Kosong',
        description: 'Tambahkan layanan terlebih dahulu.',
      });
      return;
    }

    if (!customer) {
      toast({
        variant: 'destructive',
        title: 'Customer Belum Dipilih',
        description: 'Pilih atau tambah customer terlebih dahulu.',
      });
      return;
    }

    setIsSubmitting(true);

    try {
      const result = await createOrder({
        items,
        customer,
        discount,
        notes,
        paymentMethod,
      });

      if (result.success) {
        toast({
          title: 'Order Berhasil Dibuat',
          description: `Order ${result.data.order_number} telah dibuat.`,
        });
        clearCart();
        router.push('/dashboard/orders');
      } else {
        toast({
          variant: 'destructive',
          title: 'Gagal Membuat Order',
          description: result.error,
        });
      }
    } catch (error) {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: 'Terjadi kesalahan. Silakan coba lagi.',
      });
    } finally {
      setIsSubmitting(false);
    }
  }

  return (
    <div className="flex flex-col h-full">
      {/* Customer Selection */}
      <div className="p-4 border-b">
        <Label className="text-sm font-medium mb-2 block">Customer</Label>
        <CustomerSelect
          value={customer}
        />
      </div>

      {/* Cart Items */}
      <div className="flex-1 overflow-y-auto p-4">
        {items.length === 0 ? (
          <div className="text-center text-gray-400 py-8">
            <p>Keranjang kosong</p>
            <p className="text-sm">Pilih layanan di sebelah kiri</p>
          </div>
        ) : (
          <div className="space-y-3">
            {items.map((item) => (
              <div
                key={item.service.id}
                className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg"
              >
                <span className="text-xl">{item.service.icon}</span>
                <div className="flex-1 min-w-0">
                  <p className="font-medium text-sm truncate">
                    {item.service.name}
                  </p>
                  <p className="text-xs text-gray-500">
                    {formatRupiah(item.service.price_per_kg)}/kg
                  </p>
                  <div className="flex items-center gap-2 mt-2">
                    <Button
                      variant="outline"
                      size="icon"
                      className="h-7 w-7" =>
                        updateItemQuantity(
                          item.service.id,
                          item.quantity - 0.5
                        )
                      }
                    >
                      <Minus className="h-3 w-3" />
                    </Button>
                    <span className="text-sm font-medium w-12 text-center">
                      {item.quantity} kg
                    </span>
                    <Button
                      variant="outline"
                      size="icon"
                      className="h-7 w-7" =>
                        updateItemQuantity(
                          item.service.id,
                          item.quantity + 0.5
                        )
                      }
                    >
                      <Plus className="h-3 w-3" />
                    </Button>
                  </div>
                </div>
                <div className="text-right">
                  <p className="font-semibold text-green-600">
                    {formatRupiah(item.subtotal)}
                  </p>
                  <Button
                    variant="ghost"
                    size="icon"
                    className="h-7 w-7 text-red-500 hover:text-red-600 mt-1" => removeItem(item.service.id)}
                  >
                    <Trash2 className="h-4 w-4" />
                  </Button>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* Order Options */}
      {items.length > 0 && (
        <div className="p-4 border-t space-y-3">
          {/* Discount */}
          <div>
            <Label className="text-sm">Diskon (Rp)</Label>
            <Input
              type="number"
              value={discount || ''} => setDiscount(parseInt(e.target.value) || 0)}
              placeholder="0"
              className="mt-1"
            />
          </div>

          {/* Notes */}
          <div>
            <Label className="text-sm">Catatan</Label>
            <Textarea
              value={notes} => setNotes(e.target.value)}
              placeholder="Catatan untuk order ini..."
              rows={2}
              className="mt-1"
            />
          </div>

          {/* Payment Method */}
          <div>
            <Label className="text-sm">Metode Pembayaran</Label>
            <Select
              value={paymentMethod} => setPaymentMethod(v as 'cash' | 'transfer')}
            >
              <SelectTrigger className="mt-1">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="cash">Cash</SelectItem>
                <SelectItem value="transfer">Transfer Bank</SelectItem>
              </SelectContent>
            </Select>
          </div>
        </div>
      )}

      {/* Summary & Submit */}
      <div className="p-4 border-t bg-gray-50">
        <div className="space-y-2 mb-4">
          <div className="flex justify-between text-sm">
            <span>Subtotal</span>
            <span>{formatRupiah(subtotal)}</span>
          </div>
          {discount > 0 && (
            <div className="flex justify-between text-sm text-red-600">
              <span>Diskon</span>
              <span>-{formatRupiah(discount)}</span>
            </div>
          )}
          <Separator />
          <div className="flex justify-between font-bold text-lg">
            <span>Total</span>
            <span className="text-green-600">{formatRupiah(total)}</span>
          </div>
        </div>

        <Button
          className="w-full"
          size="lg"
          disabled={isSubmitting || items.length === 0 || !customer}
        >
          {isSubmitting ? (
            <>
              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
              Memproses...
            </>
          ) : (
            'Buat Order'
          )}
        </Button>
      </div>
    </div>
  );
}

src/components/pos/customer-select.tsx:

'use client';

import { useState, useEffect } from 'react';
import { Check, ChevronsUpDown, Plus, User } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from '@/components/ui/command';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';

import { CustomerDialog } from '@/components/dashboard/customers/customer-dialog';
import { searchCustomers } from '@/actions/customers';
import { formatPhone } from '@/lib/utils';
import { cn } from '@/lib/utils';
import type { Customer } from '@/types';

interface CustomerSelectProps {
  value: Customer | null;
  onChange: (customer: Customer | null) => void;
}

export function CustomerSelect({ value, onChange }: CustomerSelectProps) {
  const [open, setOpen] = useState(false);
  const [search, setSearch] = useState('');
  const [customers, setCustomers] = useState<Customer[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (search.length >= 2) {
      setLoading(true);
      searchCustomers(search).then((result) => {
        if (result.success) {
          setCustomers(result.data as Customer[]);
        }
        setLoading(false);
      });
    } else {
      setCustomers([]);
    }
  }, [search]);

  function handleNewCustomer(customer?: Customer) {
    if (customer) {
      onChange(customer);
      setOpen(false);
    }
  }

  return (
    <Popover open={open}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          className="w-full justify-between"
        >
          {value ? (
            <span className="flex items-center gap-2">
              <User className="h-4 w-4 text-gray-400" />
              {value.name}
              <span className="text-gray-400 text-xs">
                ({formatPhone(value.phone)})
              </span>
            </span>
          ) : (
            <span className="text-gray-400">Pilih customer...</span>
          )}
          <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-full p-0" align="start">
        <Command>
          <CommandInput
            placeholder="Cari nama atau HP..."
            value={search}
          />
          <CommandList>
            <CommandEmpty>
              {loading ? (
                'Mencari...'
              ) : search.length < 2 ? (
                'Ketik minimal 2 karakter'
              ) : (
                'Customer tidak ditemukan'
              )}
            </CommandEmpty>
            <CommandGroup>
              {customers.map((customer) => (
                <CommandItem
                  key={customer.id}
                  value={customer.id} => {
                    onChange(customer);
                    setOpen(false);
                  }}
                >
                  <Check
                    className={cn(
                      'mr-2 h-4 w-4',
                      value?.id === customer.id ? 'opacity-100' : 'opacity-0'
                    )}
                  />
                  <div>
                    <p className="font-medium">{customer.name}</p>
                    <p className="text-xs text-gray-500">
                      {formatPhone(customer.phone)}
                    </p>
                  </div>
                </CommandItem>
              ))}
            </CommandGroup>
            <CommandSeparator />
            <CommandGroup>
              <CustomerDialog>
                <CommandItem => e.preventDefault()}>
                  <Plus className="mr-2 h-4 w-4" />
                  Tambah Customer Baru
                </CommandItem>
              </CustomerDialog>
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

Halaman POS

src/app/(dashboard)/dashboard/pos/page.tsx:

import { getServices } from '@/actions/services';
import { ServiceGrid } from '@/components/pos/service-grid';
import { Cart } from '@/components/pos/cart';

export default async function POSPage() {
  const { data: services } = await getServices();

  return (
    <div className="h-[calc(100vh-8rem)]">
      <div className="flex items-center justify-between mb-4">
        <div>
          <h1 className="text-2xl font-bold">Buat Order Baru</h1>
          <p className="text-gray-500">Pilih layanan dan customer</p>
        </div>
      </div>

      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-[calc(100%-4rem)]">
        {/* Services Grid */}
        <div className="lg:col-span-2 overflow-y-auto pr-2">
          <h2 className="font-semibold mb-3">Pilih Layanan</h2>
          <ServiceGrid services={services || []} />
        </div>

        {/* Cart */}
        <div className="bg-white rounded-lg border shadow-sm overflow-hidden">
          <div className="p-4 border-b bg-gray-50">
            <h2 className="font-semibold">Keranjang</h2>
          </div>
          <Cart />
        </div>
      </div>
    </div>
  );
}

Install Command Component

npx shadcn@latest add command popover

Yang Perlu Diperhatikan:

  • ✅ Zustand store dengan persist ke localStorage
  • ✅ Cart state (items, customer, discount, notes)
  • ✅ Service grid dengan quantity dialog
  • ✅ Cart dengan qty adjustment dan remove
  • ✅ Customer select dengan search autocomplete
  • ✅ Inline add customer baru
  • ✅ Payment method selection
  • ✅ Order creation dengan customer stats update
  • ✅ Proper error handling dan loading states
  • ⚠️ Command component harus di-install via shadcn
  • ⚠️ Cart persist ke localStorage agar tidak hilang saat refresh

Di bagian selanjutnya, kita akan bikin halaman untuk manage orders dan update status.

Bagian 7: Manage Orders & Update Status

Setelah order dibuat via POS, kasir perlu bisa melihat semua order, filter berdasarkan status, dan update status order seiring proses laundry berjalan. Di bagian ini kita akan bikin halaman orders list yang lengkap.

Halaman Orders dengan Filter

Prompt ke Claude

Buatkan halaman orders list untuk dashboard laundry dengan:
- DataTable menampilkan: order number, customer name, jumlah items, total, status badge, payment badge, tanggal
- Filter dropdown: by status (all, pending, processing, ready, picked_up, cancelled)
- Filter dropdown: by payment (all, unpaid, paid)
- Search by order number atau customer name
- Klik row membuka detail sheet di samping
- Di detail sheet ada: info customer, list items, timeline status, action buttons untuk update status
- Action buttons: Mulai Proses, Selesai, Sudah Diambil, Batalkan
- Update payment status button

Gunakan shadcn/ui components dan Server Actions.

Review Output (30%)

src/app/(dashboard)/dashboard/orders/page.tsx:

import { Suspense } from 'react';

import { Skeleton } from '@/components/ui/skeleton';

import { getOrders } from '@/actions/orders';
import { OrdersTable } from '@/components/dashboard/orders/orders-table';
import { OrdersFilter } from '@/components/dashboard/orders/orders-filter';

interface OrdersPageProps {
  searchParams: Promise<{
    status?: string;
    payment?: string;
    q?: string;
  }>;
}

export default async function OrdersPage({ searchParams }: OrdersPageProps) {
  const params = await searchParams;

  const { data: orders, error } = await getOrders({
    status: params.status,
    paymentStatus: params.payment,
    search: params.q,
  });

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold">Daftar Orders</h1>
        <p className="text-gray-500">
          Kelola dan update status order laundry
        </p>
      </div>

      {/* Filters */}
      <OrdersFilter
        currentStatus={params.status}
        currentPayment={params.payment}
        currentSearch={params.q}
      />

      {error ? (
        <div className="rounded-lg border border-red-200 bg-red-50 p-4">
          <p className="text-red-600">{error}</p>
        </div>
      ) : (
        <Suspense fallback={<TableSkeleton />}>
          <OrdersTable orders={orders || []} />
        </Suspense>
      )}
    </div>
  );
}

function TableSkeleton() {
  return (
    <div className="space-y-4">
      <Skeleton className="h-10 w-full" />
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-16 w-full" />
      <Skeleton className="h-16 w-full" />
    </div>
  );
}

src/components/dashboard/orders/orders-filter.tsx:

'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Search, X, Filter } from 'lucide-react';

import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';

interface OrdersFilterProps {
  currentStatus?: string;
  currentPayment?: string;
  currentSearch?: string;
}

const STATUS_OPTIONS = [
  { value: 'all', label: 'Semua Status' },
  { value: 'pending', label: 'Menunggu' },
  { value: 'processing', label: 'Diproses' },
  { value: 'ready', label: 'Selesai' },
  { value: 'picked_up', label: 'Diambil' },
  { value: 'cancelled', label: 'Dibatalkan' },
];

const PAYMENT_OPTIONS = [
  { value: 'all', label: 'Semua Pembayaran' },
  { value: 'unpaid', label: 'Belum Bayar' },
  { value: 'paid', label: 'Lunas' },
];

export function OrdersFilter({
  currentStatus,
  currentPayment,
  currentSearch,
}: OrdersFilterProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();
  const [search, setSearch] = useState(currentSearch || '');

  function updateParams(key: string, value: string) {
    startTransition(() => {
      const params = new URLSearchParams(searchParams);
      if (value && value !== 'all') {
        params.set(key, value);
      } else {
        params.delete(key);
      }
      router.push(`/dashboard/orders?${params.toString()}`);
    });
  }

  function handleSearch(value: string) {
    setSearch(value);
    startTransition(() => {
      const params = new URLSearchParams(searchParams);
      if (value) {
        params.set('q', value);
      } else {
        params.delete('q');
      }
      router.push(`/dashboard/orders?${params.toString()}`);
    });
  }

  function clearFilters() {
    setSearch('');
    startTransition(() => {
      router.push('/dashboard/orders');
    });
  }

  const hasFilters = currentStatus || currentPayment || currentSearch;

  return (
    <div className="flex flex-col sm:flex-row gap-3">
      {/* Search */}
      <div className="relative flex-1 max-w-sm">
        <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
        <Input
          value={search} => handleSearch(e.target.value)}
          placeholder="Cari order number atau customer..."
          className="pl-10"
        />
      </div>

      {/* Status Filter */}
      <Select
        value={currentStatus || 'all'} => updateParams('status', v)}
      >
        <SelectTrigger className="w-[180px]">
          <SelectValue placeholder="Filter Status" />
        </SelectTrigger>
        <SelectContent>
          {STATUS_OPTIONS.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      {/* Payment Filter */}
      <Select
        value={currentPayment || 'all'} => updateParams('payment', v)}
      >
        <SelectTrigger className="w-[180px]">
          <SelectValue placeholder="Filter Pembayaran" />
        </SelectTrigger>
        <SelectContent>
          {PAYMENT_OPTIONS.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      {/* Clear Filters */}
      {hasFilters && (
        <Button variant="ghost">
          <X className="mr-2 h-4 w-4" />
          Clear
        </Button>
      )}

      {isPending && (
        <div className="flex items-center">
          <div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
        </div>
      )}
    </div>
  );
}

src/components/dashboard/orders/orders-table.tsx:

'use client';

import { useState } from 'react';
import { Eye } from 'lucide-react';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';

import { OrderDetailSheet } from './order-detail-sheet';
import {
  formatRupiah,
  formatDateTime,
  getStatusColor,
  getStatusLabel,
} from '@/lib/utils';
import type { Order, Customer, OrderItem } from '@/types';

interface OrderWithRelations extends Order {
  customer: Customer;
  order_items: OrderItem[];
}

interface OrdersTableProps {
  orders: OrderWithRelations[];
}

export function OrdersTable({ orders }: OrdersTableProps) {
  const [selectedOrder, setSelectedOrder] = useState<OrderWithRelations | null>(
    null
  );

  if (orders.length === 0) {
    return (
      <div className="rounded-lg border border-dashed p-8 text-center">
        <p className="text-gray-500">Tidak ada order</p>
        <p className="text-sm text-gray-400 mt-1">
          Order akan muncul di sini setelah dibuat via POS
        </p>
      </div>
    );
  }

  return (
    <>
      <div className="rounded-lg border bg-white">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Order #</TableHead>
              <TableHead>Customer</TableHead>
              <TableHead className="text-center">Items</TableHead>
              <TableHead className="text-right">Total</TableHead>
              <TableHead>Status</TableHead>
              <TableHead>Pembayaran</TableHead>
              <TableHead>Tanggal</TableHead>
              <TableHead className="w-12"></TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {orders.map((order) => (
              <TableRow
                key={order.id}
                className="cursor-pointer hover:bg-gray-50" => setSelectedOrder(order)}
              >
                <TableCell className="font-mono font-medium">
                  {order.order_number}
                </TableCell>
                <TableCell>
                  <div>
                    <p className="font-medium">{order.customer?.name}</p>
                    <p className="text-xs text-gray-500">
                      {order.customer?.phone}
                    </p>
                  </div>
                </TableCell>
                <TableCell className="text-center">
                  <span className="inline-flex items-center justify-center h-6 w-6 rounded-full bg-gray-100 text-sm">
                    {order.order_items?.length || 0}
                  </span>
                </TableCell>
                <TableCell className="text-right font-medium text-green-600">
                  {formatRupiah(order.total)}
                </TableCell>
                <TableCell>
                  <Badge className={getStatusColor(order.status)}>
                    {getStatusLabel(order.status)}
                  </Badge>
                </TableCell>
                <TableCell>
                  <Badge className={getStatusColor(order.payment_status)}>
                    {getStatusLabel(order.payment_status)}
                  </Badge>
                </TableCell>
                <TableCell className="text-gray-500 text-sm">
                  {formatDateTime(order.created_at)}
                </TableCell>
                <TableCell>
                  <Button variant="ghost" size="icon">
                    <Eye className="h-4 w-4" />
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>

      {/* Detail Sheet */}
      <OrderDetailSheet
        order={selectedOrder}
        open={!!selectedOrder} => !open && setSelectedOrder(null)} => setSelectedOrder(null)}
      />
    </>
  );
}

src/components/dashboard/orders/order-detail-sheet.tsx:

'use client';

import { useState } from 'react';
import {
  Clock,
  Package,
  CheckCircle,
  Truck,
  XCircle,
  User,
  Phone,
  CreditCard,
  Loader2,
} from 'lucide-react';

import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/use-toast';

import {
  updateOrderStatus,
  updatePaymentStatus,
  cancelOrder,
} from '@/actions/orders';
import {
  formatRupiah,
  formatDateTime,
  getStatusColor,
  getStatusLabel,
} from '@/lib/utils';
import type { Order, Customer, OrderItem } from '@/types';

interface OrderWithRelations extends Order {
  customer: Customer;
  order_items: OrderItem[];
}

interface OrderDetailSheetProps {
  order: OrderWithRelations | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onUpdate: () => void;
}

const STATUS_TIMELINE = [
  { status: 'pending', icon: Clock, label: 'Order Dibuat' },
  { status: 'processing', icon: Package, label: 'Diproses' },
  { status: 'ready', icon: CheckCircle, label: 'Selesai' },
  { status: 'picked_up', icon: Truck, label: 'Diambil' },
];

export function OrderDetailSheet({
  order,
  open,
  onOpenChange,
  onUpdate,
}: OrderDetailSheetProps) {
  const { toast } = useToast();
  const [loading, setLoading] = useState<string | null>(null);
  const [showCancelDialog, setShowCancelDialog] = useState(false);

  async function handleStatusUpdate(newStatus: string) {
    if (!order) return;

    setLoading(newStatus);
    const result = await updateOrderStatus(order.id, newStatus);

    if (result.success) {
      toast({
        title: 'Status Diupdate',
        description: `Order ${order.order_number} sekarang ${getStatusLabel(newStatus)}.`,
      });
      onUpdate();
    } else {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: result.error,
      });
    }
    setLoading(null);
  }

  async function handlePaymentUpdate() {
    if (!order) return;

    setLoading('payment');
    const result = await updatePaymentStatus(order.id, 'paid');

    if (result.success) {
      toast({
        title: 'Pembayaran Dikonfirmasi',
        description: `Order ${order.order_number} sudah lunas.`,
      });
      onUpdate();
    } else {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: result.error,
      });
    }
    setLoading(null);
  }

  async function handleCancel() {
    if (!order) return;

    setLoading('cancel');
    const result = await cancelOrder(order.id);

    if (result.success) {
      toast({
        title: 'Order Dibatalkan',
        description: `Order ${order.order_number} telah dibatalkan.`,
      });
      onUpdate();
    } else {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: result.error,
      });
    }
    setLoading(null);
    setShowCancelDialog(false);
  }

  function getNextAction(): { label: string; status: string } | null {
    if (!order) return null;

    const actions: Record<string, { label: string; status: string }> = {
      pending: { label: 'Mulai Proses', status: 'processing' },
      processing: { label: 'Tandai Selesai', status: 'ready' },
      ready: { label: 'Sudah Diambil', status: 'picked_up' },
    };

    return actions[order.status] || null;
  }

  function getCurrentStatusIndex(): number {
    if (!order) return 0;
    if (order.status === 'cancelled') return -1;
    return STATUS_TIMELINE.findIndex((s) => s.status === order.status);
  }

  if (!order) return null;

  const nextAction = getNextAction();
  const statusIndex = getCurrentStatusIndex();
  const canCancel = ['pending', 'processing'].includes(order.status);

  return (
    <>
      <Sheet open={open}>
        <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
          <SheetHeader>
            <SheetTitle className="flex items-center gap-2">
              <span className="font-mono">{order.order_number}</span>
              <Badge className={getStatusColor(order.status)}>
                {getStatusLabel(order.status)}
              </Badge>
            </SheetTitle>
            <SheetDescription>
              {formatDateTime(order.created_at)}
            </SheetDescription>
          </SheetHeader>

          <div className="mt-6 space-y-6">
            {/* Customer Info */}
            <div className="p-4 bg-gray-50 rounded-lg space-y-2">
              <div className="flex items-center gap-2 text-sm">
                <User className="h-4 w-4 text-gray-400" />
                <span className="font-medium">{order.customer?.name}</span>
              </div>
              <div className="flex items-center gap-2 text-sm text-gray-600">
                <Phone className="h-4 w-4 text-gray-400" />
                <span>{order.customer?.phone}</span>
              </div>
            </div>

            {/* Status Timeline */}
            {order.status !== 'cancelled' && (
              <div>
                <h3 className="font-semibold mb-4">Status Order</h3>
                <div className="relative">
                  {STATUS_TIMELINE.map((step, index) => {
                    const isComplete = index <= statusIndex;
                    const isCurrent = index === statusIndex;

                    return (
                      <div key={step.status} className="flex gap-3 pb-6 last:pb-0">
                        {/* Line */}
                        {index < STATUS_TIMELINE.length - 1 && (
                          <div
                            className={`absolute left-[15px] top-8 w-0.5 h-[calc(100%-2rem)] ${
                              index < statusIndex ? 'bg-green-500' : 'bg-gray-200'
                            }`}
                            style={{ top: `${index * 56 + 32}px`, height: '40px' }}
                          />
                        )}

                        {/* Icon */}
                        <div
                          className={`relative z-10 flex h-8 w-8 items-center justify-center rounded-full ${
                            isComplete
                              ? 'bg-green-500 text-white'
                              : 'bg-gray-200 text-gray-400'
                          }`}
                        >
                          <step.icon className="h-4 w-4" />
                        </div>

                        {/* Label */}
                        <div className="flex-1 pt-1">
                          <p
                            className={`text-sm font-medium ${
                              isCurrent ? 'text-green-600' : ''
                            }`}
                          >
                            {step.label}
                          </p>
                          {step.status === 'ready' && order.completed_at && (
                            <p className="text-xs text-gray-500">
                              {formatDateTime(order.completed_at)}
                            </p>
                          )}
                          {step.status === 'picked_up' && order.picked_up_at && (
                            <p className="text-xs text-gray-500">
                              {formatDateTime(order.picked_up_at)}
                            </p>
                          )}
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            )}

            {/* Cancelled Status */}
            {order.status === 'cancelled' && (
              <div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg text-red-600">
                <XCircle className="h-5 w-5" />
                <span className="font-medium">Order Dibatalkan</span>
              </div>
            )}

            <Separator />

            {/* Order Items */}
            <div>
              <h3 className="font-semibold mb-3">Detail Item</h3>
              <div className="space-y-2">
                {order.order_items?.map((item) => (
                  <div
                    key={item.id}
                    className="flex justify-between items-center p-3 bg-gray-50 rounded-lg"
                  >
                    <div>
                      <p className="font-medium">{item.service_name}</p>
                      <p className="text-sm text-gray-500">
                        {item.quantity} kg × {formatRupiah(item.price_per_unit)}
                      </p>
                    </div>
                    <p className="font-medium text-green-600">
                      {formatRupiah(item.subtotal)}
                    </p>
                  </div>
                ))}
              </div>
            </div>

            {/* Totals */}
            <div className="p-4 bg-gray-50 rounded-lg space-y-2">
              <div className="flex justify-between text-sm">
                <span>Subtotal</span>
                <span>{formatRupiah(order.subtotal)}</span>
              </div>
              {order.discount > 0 && (
                <div className="flex justify-between text-sm text-red-600">
                  <span>Diskon</span>
                  <span>-{formatRupiah(order.discount)}</span>
                </div>
              )}
              <Separator />
              <div className="flex justify-between font-bold text-lg">
                <span>Total</span>
                <span className="text-green-600">{formatRupiah(order.total)}</span>
              </div>
            </div>

            {/* Payment Status */}
            <div className="flex items-center justify-between p-4 border rounded-lg">
              <div className="flex items-center gap-2">
                <CreditCard className="h-4 w-4 text-gray-400" />
                <span className="text-sm">Pembayaran</span>
              </div>
              <div className="flex items-center gap-2">
                <Badge className={getStatusColor(order.payment_status)}>
                  {getStatusLabel(order.payment_status)}
                </Badge>
                {order.payment_status === 'unpaid' && (
                  <Button
                    size="sm"
                    variant="outline"
                    disabled={loading === 'payment'}
                  >
                    {loading === 'payment' ? (
                      <Loader2 className="h-4 w-4 animate-spin" />
                    ) : (
                      'Konfirmasi'
                    )}
                  </Button>
                )}
              </div>
            </div>

            {/* Notes */}
            {order.notes && (
              <div>
                <h3 className="font-semibold mb-2">Catatan</h3>
                <p className="text-sm text-gray-600 p-3 bg-gray-50 rounded-lg">
                  {order.notes}
                </p>
              </div>
            )}

            {/* Action Buttons */}
            {order.status !== 'cancelled' && order.status !== 'picked_up' && (
              <div className="flex gap-2 pt-4">
                {nextAction && (
                  <Button
                    className="flex-1" => handleStatusUpdate(nextAction.status)}
                    disabled={!!loading}
                  >
                    {loading === nextAction.status ? (
                      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                    ) : null}
                    {nextAction.label}
                  </Button>
                )}
                {canCancel && (
                  <Button
                    variant="destructive" => setShowCancelDialog(true)}
                    disabled={!!loading}
                  >
                    Batalkan
                  </Button>
                )}
              </div>
            )}
          </div>
        </SheetContent>
      </Sheet>

      {/* Cancel Confirmation */}
      <AlertDialog open={showCancelDialog}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Batalkan Order?</AlertDialogTitle>
            <AlertDialogDescription>
              Order yang dibatalkan tidak bisa dikembalikan.
              Statistik customer akan diupdate.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={loading === 'cancel'}>
              Tidak
            </AlertDialogCancel>
            <AlertDialogAction
              disabled={loading === 'cancel'}
              className="bg-red-600 hover:bg-red-700"
            >
              {loading === 'cancel' ? 'Membatalkan...' : 'Ya, Batalkan'}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  );
}

Yang Perlu Diperhatikan:

  • ✅ Filter by status dan payment dengan URL params
  • ✅ Search dengan debounce-like behavior via useTransition
  • ✅ Status timeline yang visual dan informatif
  • ✅ Next action button berubah sesuai status saat ini
  • ✅ Update payment status inline
  • ✅ Cancel order dengan konfirmasi dan revert stats
  • ✅ Timestamps untuk completed_at dan picked_up_at
  • ✅ Loading states untuk setiap action
  • ⚠️ Sheet component untuk detail di samping (tidak full page)
  • ⚠️ Order yang sudah picked_up atau cancelled tidak bisa diupdate

Di bagian selanjutnya, kita akan bikin Front Checkout Customer — landing page dan flow order online.

Bagian 8: Front Checkout Customer

Sekarang kita beralih ke sisi customer — landing page yang informatif dan flow checkout untuk order online. Customer bisa lihat layanan, estimasi harga, dan langsung order via website.

Layout Frontend

src/app/(frontend)/layout.tsx:

import { Toaster } from '@/components/ui/toaster';

export default function FrontendLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-white">
      {children}
      <Toaster />
    </div>
  );
}

Landing Page

Prompt ke Claude

Buatkan landing page untuk laundry dengan:
- Navbar dengan logo, menu (Home, Layanan, Cara Order, FAQ), dan CTA button "Order Sekarang"
- Hero section dengan headline menarik, subheadline, dan 2 buttons (Order Sekarang, Track Order)
- Services section menampilkan semua layanan dengan harga (fetch dari database)
- How it works section dengan 4 steps: Pilih Layanan → Isi Data → Bayar → Selesai
- Benefits section: Cepat, Bersih, Terjangkau, Tracking Realtime
- FAQ accordion dengan 5 pertanyaan umum
- Footer dengan info kontak dan links

Styling dengan TailwindCSS, warna utama biru. Responsive mobile-first.

Review Output (30%)

src/components/frontend/navbar.tsx:

'use client';

import { useState } from 'react';
import Link from 'next/link';
import { Menu, X, WashingMachine } from 'lucide-react';

import { Button } from '@/components/ui/button';

const NAV_LINKS = [
  { href: '#layanan', label: 'Layanan' },
  { href: '#cara-order', label: 'Cara Order' },
  { href: '#faq', label: 'FAQ' },
];

export function Navbar() {
  const [mobileOpen, setMobileOpen] = useState(false);

  return (
    <nav className="sticky top-0 z-50 bg-white border-b">
      <div className="max-w-6xl mx-auto px-4">
        <div className="flex items-center justify-between h-16">
          {/* Logo */}
          <Link href="/" className="flex items-center gap-2">
            <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-600">
              <WashingMachine className="h-5 w-5 text-white" />
            </div>
            <span className="font-bold text-lg">LaundryKu</span>
          </Link>

          {/* Desktop Nav */}
          <div className="hidden md:flex items-center gap-6">
            {NAV_LINKS.map((link) => (
              <a
                key={link.href}
                href={link.href}
                className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
              >
                {link.label}
              </a>
            ))}
          </div>

          {/* CTA Buttons */}
          <div className="hidden md:flex items-center gap-3">
            <Button variant="outline" asChild>
              <Link href="/track">Track Order</Link>
            </Button>
            <Button asChild>
              <Link href="/order">Order Sekarang</Link>
            </Button>
          </div>

          {/* Mobile Menu Button */}
          <button
            className="md:hidden p-2" => setMobileOpen(!mobileOpen)}
          >
            {mobileOpen ? (
              <X className="h-6 w-6" />
            ) : (
              <Menu className="h-6 w-6" />
            )}
          </button>
        </div>

        {/* Mobile Menu */}
        {mobileOpen && (
          <div className="md:hidden py-4 border-t">
            <div className="flex flex-col gap-4">
              {NAV_LINKS.map((link) => (
                <a
                  key={link.href}
                  href={link.href}
                  className="text-gray-600 hover:text-gray-900" => setMobileOpen(false)}
                >
                  {link.label}
                </a>
              ))}
              <div className="flex flex-col gap-2 pt-4 border-t">
                <Button variant="outline" asChild>
                  <Link href="/track">Track Order</Link>
                </Button>
                <Button asChild>
                  <Link href="/order">Order Sekarang</Link>
                </Button>
              </div>
            </div>
          </div>
        )}
      </div>
    </nav>
  );
}

src/components/frontend/hero.tsx:

import Link from 'next/link';
import { ArrowRight, MapPin } from 'lucide-react';

import { Button } from '@/components/ui/button';

export function Hero() {
  return (
    <section className="relative py-20 md:py-32 overflow-hidden">
      {/* Background Pattern */}
      <div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-white -z-10" />
      <div className="absolute top-0 right-0 w-1/2 h-full bg-blue-100/30 rounded-l-full -z-10" />

      <div className="max-w-6xl mx-auto px-4">
        <div className="max-w-2xl">
          <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-100 text-blue-700 text-sm mb-6">
            <MapPin className="h-4 w-4" />
            Melayani area Jakarta Selatan
          </div>

          <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
            Laundry Kiloan{' '}
            <span className="text-blue-600">Cepat & Bersih</span>
          </h1>

          <p className="mt-6 text-lg md:text-xl text-gray-600 leading-relaxed">
            Tidak perlu repot cuci baju sendiri. Serahkan ke kami, dijamin
            bersih, wangi, dan siap pakai. Order online, bayar mudah,
            tracking realtime.
          </p>

          <div className="flex flex-col sm:flex-row gap-4 mt-8">
            <Button size="lg" asChild>
              <Link href="/order">
                Order Sekarang
                <ArrowRight className="ml-2 h-5 w-5" />
              </Link>
            </Button>
            <Button size="lg" variant="outline" asChild>
              <Link href="/track">Track Order Saya</Link>
            </Button>
          </div>

          {/* Trust Badges */}
          <div className="flex items-center gap-8 mt-12 text-sm text-gray-500">
            <div className="flex items-center gap-2">
              <span className="text-2xl">⭐</span>
              <span>4.9/5 Rating</span>
            </div>
            <div className="flex items-center gap-2">
              <span className="text-2xl">👥</span>
              <span>1000+ Customer</span>
            </div>
            <div className="flex items-center gap-2">
              <span className="text-2xl">✅</span>
              <span>Garansi Bersih</span>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

src/components/frontend/services-section.tsx:

import { Clock } from 'lucide-react';

import { Card, CardContent } from '@/components/ui/card';
import { formatRupiah } from '@/lib/utils';
import type { Service } from '@/types';

interface ServicesSectionProps {
  services: Service[];
}

export function ServicesSection({ services }: ServicesSectionProps) {
  function formatEstimate(hours: number): string {
    if (hours < 24) return `${hours} jam`;
    return `${Math.floor(hours / 24)} hari`;
  }

  return (
    <section id="layanan" className="py-20 bg-gray-50">
      <div className="max-w-6xl mx-auto px-4">
        <div className="text-center mb-12">
          <h2 className="text-3xl md:text-4xl font-bold text-gray-900">
            Layanan Kami
          </h2>
          <p className="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
            Pilih layanan sesuai kebutuhan Anda. Semua dikerjakan dengan
            mesin modern dan deterjen premium.
          </p>
        </div>

        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
          {services
            .filter((s) => s.is_active)
            .map((service) => (
              <Card
                key={service.id}
                className="hover:shadow-lg transition-shadow"
              >
                <CardContent className="p-6">
                  <div className="flex items-start gap-4">
                    <span className="text-4xl">{service.icon}</span>
                    <div className="flex-1">
                      <h3 className="font-bold text-lg">{service.name}</h3>
                      {service.description && (
                        <p className="text-sm text-gray-500 mt-1">
                          {service.description}
                        </p>
                      )}
                      <div className="flex items-center justify-between mt-4">
                        <p className="text-2xl font-bold text-blue-600">
                          {formatRupiah(service.price_per_kg)}
                          <span className="text-sm font-normal text-gray-400">
                            /kg
                          </span>
                        </p>
                        <div className="flex items-center gap-1 text-sm text-gray-500">
                          <Clock className="h-4 w-4" />
                          <span>±{formatEstimate(service.estimated_hours)}</span>
                        </div>
                      </div>
                    </div>
                  </div>
                </CardContent>
              </Card>
            ))}
        </div>
      </div>
    </section>
  );
}

src/components/frontend/how-it-works.tsx:

const STEPS = [
  {
    number: '01',
    title: 'Pilih Layanan',
    description: 'Pilih jenis layanan dan estimasi berat cucian Anda.',
    icon: '🧺',
  },
  {
    number: '02',
    title: 'Isi Data',
    description: 'Masukkan nama, nomor HP, dan alamat untuk pickup/delivery.',
    icon: '📝',
  },
  {
    number: '03',
    title: 'Bayar Online',
    description: 'Bayar dengan transfer bank, e-wallet, atau bayar di tempat.',
    icon: '💳',
  },
  {
    number: '04',
    title: 'Selesai!',
    description: 'Cucian diproses, track status via HP, ambil saat selesai.',
    icon: '✨',
  },
];

export function HowItWorks() {
  return (
    <section id="cara-order" className="py-20">
      <div className="max-w-6xl mx-auto px-4">
        <div className="text-center mb-12">
          <h2 className="text-3xl md:text-4xl font-bold text-gray-900">
            Cara Order
          </h2>
          <p className="mt-4 text-lg text-gray-600">
            Hanya 4 langkah mudah untuk cucian bersih dan wangi
          </p>
        </div>

        <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
          {STEPS.map((step, index) => (
            <div key={step.number} className="relative">
              {/* Connector Line */}
              {index < STEPS.length - 1 && (
                <div className="hidden lg:block absolute top-12 left-1/2 w-full h-0.5 bg-blue-100" />
              )}

              <div className="relative text-center">
                {/* Number Circle */}
                <div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-blue-100 text-4xl mb-4">
                  {step.icon}
                </div>

                {/* Step Number */}
                <div className="absolute top-0 right-1/4 w-8 h-8 rounded-full bg-blue-600 text-white text-sm font-bold flex items-center justify-center">
                  {step.number}
                </div>

                <h3 className="font-bold text-lg mt-2">{step.title}</h3>
                <p className="text-gray-600 text-sm mt-2">{step.description}</p>
              </div>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

src/components/frontend/faq.tsx:

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from '@/components/ui/accordion';

const FAQ_ITEMS = [
  {
    question: 'Berapa lama proses laundry?',
    answer:
      'Waktu pengerjaan tergantung jenis layanan. Cuci Kering 2 hari, Cuci Setrika 3 hari, Express hanya 6 jam. Estimasi waktu tertera di setiap layanan.',
  },
  {
    question: 'Bagaimana cara tracking order?',
    answer:
      'Setelah order, Anda akan mendapat nomor order. Gunakan nomor tersebut atau nomor HP di halaman Track Order untuk melihat status cucian secara realtime.',
  },
  {
    question: 'Apakah ada layanan antar-jemput?',
    answer:
      'Saat ini kami belum menyediakan layanan antar-jemput. Silakan antar cucian langsung ke outlet kami. Layanan delivery sedang dalam pengembangan.',
  },
  {
    question: 'Metode pembayaran apa saja yang tersedia?',
    answer:
      'Kami menerima pembayaran via Transfer Bank (BCA, Mandiri, BNI, BRI), E-Wallet (GoPay, OVO, DANA), dan bayar tunai di outlet.',
  },
  {
    question: 'Bagaimana jika cucian rusak atau hilang?',
    answer:
      'Kami memiliki garansi penuh. Jika ada kerusakan atau kehilangan akibat kelalaian kami, akan kami ganti sesuai nilai wajar. Klaim bisa dilakukan dalam 24 jam setelah pengambilan.',
  },
];

export function FAQ() {
  return (
    <section id="faq" className="py-20 bg-gray-50">
      <div className="max-w-3xl mx-auto px-4">
        <div className="text-center mb-12">
          <h2 className="text-3xl md:text-4xl font-bold text-gray-900">
            Pertanyaan Umum
          </h2>
          <p className="mt-4 text-lg text-gray-600">
            Temukan jawaban untuk pertanyaan yang sering diajukan
          </p>
        </div>

        <Accordion type="single" collapsible className="space-y-4">
          {FAQ_ITEMS.map((item, index) => (
            <AccordionItem
              key={index}
              value={`item-${index}`}
              className="bg-white rounded-lg border px-6"
            >
              <AccordionTrigger className="text-left font-semibold">
                {item.question}
              </AccordionTrigger>
              <AccordionContent className="text-gray-600">
                {item.answer}
              </AccordionContent>
            </AccordionItem>
          ))}
        </Accordion>
      </div>
    </section>
  );
}

src/components/frontend/footer.tsx:

import { WashingMachine, Phone, Mail, MapPin } from 'lucide-react';

export function Footer() {
  return (
    <footer className="bg-gray-900 text-gray-300 py-12">
      <div className="max-w-6xl mx-auto px-4">
        <div className="grid md:grid-cols-3 gap-8">
          {/* Brand */}
          <div>
            <div className="flex items-center gap-2 mb-4">
              <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-600">
                <WashingMachine className="h-5 w-5 text-white" />
              </div>
              <span className="font-bold text-lg text-white">LaundryKu</span>
            </div>
            <p className="text-sm">
              Layanan laundry kiloan terpercaya dengan kualitas premium.
              Bersih, wangi, dan tepat waktu.
            </p>
          </div>

          {/* Contact */}
          <div>
            <h3 className="font-bold text-white mb-4">Kontak</h3>
            <div className="space-y-3 text-sm">
              <div className="flex items-center gap-2">
                <Phone className="h-4 w-4" />
                <span>0812-3456-7890</span>
              </div>
              <div className="flex items-center gap-2">
                <Mail className="h-4 w-4" />
                <span>[email protected]</span>
              </div>
              <div className="flex items-start gap-2">
                <MapPin className="h-4 w-4 mt-1" />
                <span>
                  Jl. Laundry No. 123<br />
                  Jakarta Selatan, 12345
                </span>
              </div>
            </div>
          </div>

          {/* Hours */}
          <div>
            <h3 className="font-bold text-white mb-4">Jam Operasional</h3>
            <div className="space-y-2 text-sm">
              <p>Senin - Sabtu: 07:00 - 21:00</p>
              <p>Minggu: 08:00 - 18:00</p>
              <p>Hari Libur: 08:00 - 15:00</p>
            </div>
          </div>
        </div>

        <div className="border-t border-gray-800 mt-8 pt-8 text-center text-sm">
          <p>© 2026 LaundryKu. All rights reserved.</p>
        </div>
      </div>
    </footer>
  );
}

src/app/(frontend)/page.tsx:

import { getServices } from '@/actions/services';

import { Navbar } from '@/components/frontend/navbar';
import { Hero } from '@/components/frontend/hero';
import { ServicesSection } from '@/components/frontend/services-section';
import { HowItWorks } from '@/components/frontend/how-it-works';
import { FAQ } from '@/components/frontend/faq';
import { Footer } from '@/components/frontend/footer';

export default async function HomePage() {
  const { data: services } = await getServices();

  return (
    <>
      <Navbar />
      <Hero />
      <ServicesSection services={services || []} />
      <HowItWorks />
      <FAQ />
      <Footer />
    </>
  );
}

Multi-step Checkout Form

src/app/(frontend)/order/page.tsx:

'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, ArrowRight, Loader2, Check } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/hooks/use-toast';

import { Navbar } from '@/components/frontend/navbar';
import { Footer } from '@/components/frontend/footer';
import { getServices } from '@/actions/services';
import { createCustomer, getCustomerByPhone } from '@/actions/customers';
import { createOrder } from '@/actions/orders';
import { formatRupiah } from '@/lib/utils';
import type { Service, Customer } from '@/types';

interface OrderItem {
  service: Service;
  quantity: number;
}

export default function OrderPage() {
  const router = useRouter();
  const { toast } = useToast();

  const [step, setStep] = useState(1);
  const [services, setServices] = useState<Service[]>([]);
  const [loading, setLoading] = useState(true);
  const [submitting, setSubmitting] = useState(false);

  // Step 1: Selected services
  const [items, setItems] = useState<OrderItem[]>([]);

  // Step 2: Customer data
  const [customerData, setCustomerData] = useState({
    name: '',
    phone: '',
    email: '',
    address: '',
  });
  const [existingCustomer, setExistingCustomer] = useState<Customer | null>(null);

  // Step 3: Notes
  const [notes, setNotes] = useState('');

  useEffect(() => {
    getServices().then((result) => {
      if (result.success) {
        setServices(result.data as Service[]);
      }
      setLoading(false);
    });
  }, []);

  function updateQuantity(service: Service, quantity: number) {
    if (quantity <= 0) {
      setItems(items.filter((item) => item.service.id !== service.id));
    } else {
      const existing = items.find((item) => item.service.id === service.id);
      if (existing) {
        setItems(
          items.map((item) =>
            item.service.id === service.id ? { ...item, quantity } : item
          )
        );
      } else {
        setItems([...items, { service, quantity }]);
      }
    }
  }

  function getItemQuantity(serviceId: string): number {
    return items.find((item) => item.service.id === serviceId)?.quantity || 0;
  }

  function getSubtotal(): number {
    return items.reduce(
      (sum, item) => sum + item.service.price_per_kg * item.quantity,
      0
    );
  }

  async function checkExistingCustomer() {
    if (customerData.phone.length >= 10) {
      const result = await getCustomerByPhone(customerData.phone);
      if (result.success && result.data) {
        setExistingCustomer(result.data as Customer);
        setCustomerData({
          ...customerData,
          name: (result.data as Customer).name,
          email: (result.data as Customer).email || '',
          address: (result.data as Customer).address || '',
        });
      } else {
        setExistingCustomer(null);
      }
    }
  }

  async function handleSubmit() {
    setSubmitting(true);

    try {
      let customer = existingCustomer;

      // Create customer if not exists
      if (!customer) {
        const customerResult = await createCustomer({
          name: customerData.name,
          phone: customerData.phone,
          email: customerData.email || undefined,
          address: customerData.address || undefined,
        });

        if (!customerResult.success) {
          toast({
            variant: 'destructive',
            title: 'Error',
            description: customerResult.error,
          });
          setSubmitting(false);
          return;
        }

        customer = customerResult.data as Customer;
      }

      // Create order
      const cartItems = items.map((item) => ({
        service: item.service,
        quantity: item.quantity,
        subtotal: item.service.price_per_kg * item.quantity,
      }));

      const orderResult = await createOrder({
        items: cartItems,
        customer,
        discount: 0,
        notes,
        paymentMethod: 'midtrans', // Will redirect to payment
      });

      if (orderResult.success) {
        toast({
          title: 'Order Berhasil!',
          description: `Order ${orderResult.data.order_number} telah dibuat.`,
        });
        router.push(`/payment/success?order=${orderResult.data.order_number}`);
      } else {
        toast({
          variant: 'destructive',
          title: 'Gagal',
          description: orderResult.error,
        });
      }
    } catch (error) {
      toast({
        variant: 'destructive',
        title: 'Error',
        description: 'Terjadi kesalahan. Silakan coba lagi.',
      });
    } finally {
      setSubmitting(false);
    }
  }

  function canProceed(): boolean {
    if (step === 1) return items.length > 0;
    if (step === 2) return !!customerData.name && customerData.phone.length >= 10;
    return true;
  }

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <Loader2 className="h-8 w-8 animate-spin text-blue-600" />
      </div>
    );
  }

  return (
    <>
      <Navbar />

      <div className="min-h-screen bg-gray-50 py-8">
        <div className="max-w-4xl mx-auto px-4">
          {/* Back Link */}
          <Link
            href="/"
            className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
          >
            <ArrowLeft className="h-4 w-4" />
            Kembali ke Home
          </Link>

          {/* Progress Steps */}
          <div className="flex items-center justify-center gap-4 mb-8">
            {[1, 2, 3].map((s) => (
              <div key={s} className="flex items-center gap-2">
                <div
                  className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
                    s < step
                      ? 'bg-green-500 text-white'
                      : s === step
                      ? 'bg-blue-600 text-white'
                      : 'bg-gray-200 text-gray-500'
                  }`}
                >
                  {s < step ? <Check className="h-4 w-4" /> : s}
                </div>
                <span
                  className={`text-sm ${
                    s === step ? 'font-medium' : 'text-gray-500'
                  }`}
                >
                  {s === 1 ? 'Pilih Layanan' : s === 2 ? 'Data Diri' : 'Review'}
                </span>
                {s < 3 && (
                  <div className="w-8 h-0.5 bg-gray-200 mx-2" />
                )}
              </div>
            ))}
          </div>

          <div className="grid lg:grid-cols-3 gap-6">
            {/* Main Content */}
            <div className="lg:col-span-2">
              <Card>
                <CardContent className="p-6">
                  {/* Step 1: Select Services */}
                  {step === 1 && (
                    <div>
                      <h2 className="text-xl font-bold mb-4">
                        Pilih Layanan & Berat
                      </h2>
                      <div className="space-y-4">
                        {services
                          .filter((s) => s.is_active)
                          .map((service) => (
                            <div
                              key={service.id}
                              className="flex items-center justify-between p-4 border rounded-lg"
                            >
                              <div className="flex items-center gap-3">
                                <span className="text-2xl">{service.icon}</span>
                                <div>
                                  <p className="font-medium">{service.name}</p>
                                  <p className="text-sm text-gray-500">
                                    {formatRupiah(service.price_per_kg)}/kg
                                  </p>
                                </div>
                              </div>
                              <div className="flex items-center gap-2">
                                <Button
                                  variant="outline"
                                  size="icon"
                                  className="h-8 w-8" =>
                                    updateQuantity(
                                      service,
                                      getItemQuantity(service.id) - 0.5
                                    )
                                  }
                                >
                                  -
                                </Button>
                                <Input
                                  type="number"
                                  value={getItemQuantity(service.id) || ''} =>
                                    updateQuantity(
                                      service,
                                      parseFloat(e.target.value) || 0
                                    )
                                  }
                                  className="w-16 text-center"
                                  min="0"
                                  step="0.5"
                                  placeholder="0"
                                />
                                <Button
                                  variant="outline"
                                  size="icon"
                                  className="h-8 w-8" =>
                                    updateQuantity(
                                      service,
                                      getItemQuantity(service.id) + 0.5
                                    )
                                  }
                                >
                                  +
                                </Button>
                              </div>
                            </div>
                          ))}
                      </div>
                    </div>
                  )}

                  {/* Step 2: Customer Data */}
                  {step === 2 && (
                    <div>
                      <h2 className="text-xl font-bold mb-4">Data Diri</h2>
                      <div className="space-y-4">
                        <div>
                          <Label>Nomor HP</Label>
                          <Input
                            value={customerData.phone} =>
                              setCustomerData({
                                ...customerData,
                                phone: e.target.value,
                              })
                            }
                            placeholder="081234567890"
                          />
                          {existingCustomer && (
                            <p className="text-sm text-green-600 mt-1">
                              ✓ Data ditemukan, nama: {existingCustomer.name}
                            </p>
                          )}
                        </div>
                        <div>
                          <Label>Nama Lengkap</Label>
                          <Input
                            value={customerData.name} =>
                              setCustomerData({
                                ...customerData,
                                name: e.target.value,
                              })
                            }
                            placeholder="John Doe"
                          />
                        </div>
                        <div>
                          <Label>Email (Opsional)</Label>
                          <Input
                            type="email"
                            value={customerData.email} =>
                              setCustomerData({
                                ...customerData,
                                email: e.target.value,
                              })
                            }
                            placeholder="[email protected]"
                          />
                        </div>
                        <div>
                          <Label>Alamat (Opsional)</Label>
                          <Textarea
                            value={customerData.address} =>
                              setCustomerData({
                                ...customerData,
                                address: e.target.value,
                              })
                            }
                            placeholder="Alamat lengkap..."
                            rows={2}
                          />
                        </div>
                      </div>
                    </div>
                  )}

                  {/* Step 3: Review */}
                  {step === 3 && (
                    <div>
                      <h2 className="text-xl font-bold mb-4">Review Order</h2>

                      {/* Items Summary */}
                      <div className="space-y-3 mb-6">
                        {items.map((item) => (
                          <div
                            key={item.service.id}
                            className="flex justify-between p-3 bg-gray-50 rounded-lg"
                          >
                            <div>
                              <p className="font-medium">
                                {item.service.icon} {item.service.name}
                              </p>
                              <p className="text-sm text-gray-500">
                                {item.quantity} kg ×{' '}
                                {formatRupiah(item.service.price_per_kg)}
                              </p>
                            </div>
                            <p className="font-medium text-green-600">
                              {formatRupiah(
                                item.service.price_per_kg * item.quantity
                              )}
                            </p>
                          </div>
                        ))}
                      </div>

                      {/* Customer Summary */}
                      <div className="p-4 bg-blue-50 rounded-lg mb-6">
                        <p className="font-medium">{customerData.name}</p>
                        <p className="text-sm text-gray-600">
                          {customerData.phone}
                        </p>
                        {customerData.address && (
                          <p className="text-sm text-gray-600">
                            {customerData.address}
                          </p>
                        )}
                      </div>

                      {/* Notes */}
                      <div>
                        <Label>Catatan untuk Laundry (Opsional)</Label>
                        <Textarea
                          value={notes} => setNotes(e.target.value)}
                          placeholder="Contoh: Pisahkan baju putih, jangan pakai pewangi..."
                          rows={3}
                          className="mt-2"
                        />
                      </div>
                    </div>
                  )}
                </CardContent>
              </Card>

              {/* Navigation Buttons */}
              <div className="flex justify-between mt-6">
                {step > 1 ? (
                  <Button variant="outline" => setStep(step - 1)}>
                    <ArrowLeft className="mr-2 h-4 w-4" />
                    Kembali
                  </Button>
                ) : (
                  <div />
                )}

                {step < 3 ? (
                  <Button => setStep(step + 1)}
                    disabled={!canProceed()}
                  >
                    Lanjut
                    <ArrowRight className="ml-2 h-4 w-4" />
                  </Button>
                ) : (
                  <Button disabled={submitting}>
                    {submitting ? (
                      <>
                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                        Memproses...
                      </>
                    ) : (
                      'Buat Order'
                    )}
                  </Button>
                )}
              </div>
            </div>

            {/* Order Summary Sidebar */}
            <div>
              <Card className="sticky top-24">
                <CardContent className="p-6">
                  <h3 className="font-bold mb-4">Ringkasan Order</h3>

                  {items.length === 0 ? (
                    <p className="text-sm text-gray-500">
                      Belum ada layanan dipilih
                    </p>
                  ) : (
                    <>
                      <div className="space-y-2 mb-4">
                        {items.map((item) => (
                          <div
                            key={item.service.id}
                            className="flex justify-between text-sm"
                          >
                            <span>
                              {item.service.name} ({item.quantity}kg)
                            </span>
                            <span>
                              {formatRupiah(
                                item.service.price_per_kg * item.quantity
                              )}
                            </span>
                          </div>
                        ))}
                      </div>
                      <Separator className="my-4" />
                      <div className="flex justify-between font-bold text-lg">
                        <span>Total</span>
                        <span className="text-green-600">
                          {formatRupiah(getSubtotal())}
                        </span>
                      </div>
                    </>
                  )}
                </CardContent>
              </Card>
            </div>
          </div>
        </div>
      </div>

      <Footer />
    </>
  );
}

Install Accordion Component

npx shadcn@latest add accordion

Yang Perlu Diperhatikan:

  • ✅ Landing page lengkap dengan Navbar, Hero, Services, How it Works, FAQ, Footer
  • ✅ Multi-step checkout form (3 steps)
  • ✅ Auto-detect existing customer by phone
  • ✅ Running total di sidebar
  • ✅ Progress indicator visual
  • ✅ Validation per step
  • ✅ Mobile responsive
  • ⚠️ Accordion component perlu di-install
  • ⚠️ Payment integration akan di bagian 9

Di bagian selanjutnya, kita akan integrasikan Midtrans Payment dan buat halaman Tracking Order.

Bagian 9: Midtrans Payment & Order Tracking

Sekarang kita integrasikan pembayaran online via Midtrans dan buat halaman tracking untuk customer melihat status order mereka.

Setup Midtrans

  1. Buka dashboard.midtrans.com
  2. Signup/login dan pilih Sandbox environment
  3. Buka SettingsAccess Keys
  4. Copy Server Key dan Client Key ke .env.local:
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxx
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxx
MIDTRANS_IS_PRODUCTION=false

Midtrans Helper

src/lib/midtrans.ts:

const MIDTRANS_SERVER_KEY = process.env.MIDTRANS_SERVER_KEY!;
const IS_PRODUCTION = process.env.MIDTRANS_IS_PRODUCTION === 'true';

const MIDTRANS_API_URL = IS_PRODUCTION
  ? '<https://app.midtrans.com/snap/v1>'
  : '<https://app.sandbox.midtrans.com/snap/v1>';

interface SnapTokenParams {
  orderId: string;
  grossAmount: number;
  customerName: string;
  customerEmail?: string;
  customerPhone: string;
  itemDetails: {
    id: string;
    name: string;
    price: number;
    quantity: number;
  }[];
}

export async function createSnapToken(params: SnapTokenParams): Promise<string> {
  const {
    orderId,
    grossAmount,
    customerName,
    customerEmail,
    customerPhone,
    itemDetails,
  } = params;

  const payload = {
    transaction_details: {
      order_id: orderId,
      gross_amount: grossAmount,
    },
    customer_details: {
      first_name: customerName,
      email: customerEmail || `${customerPhone}@laundry.local`,
      phone: customerPhone,
    },
    item_details: itemDetails,
    callbacks: {
      finish: `${process.env.NEXT_PUBLIC_APP_URL}/payment/finish`,
    },
  };

  const response = await fetch(`${MIDTRANS_API_URL}/transactions`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      Authorization: `Basic ${Buffer.from(MIDTRANS_SERVER_KEY + ':').toString('base64')}`,
    },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    const error = await response.json();
    console.error('Midtrans error:', error);
    throw new Error(error.error_messages?.[0] || 'Failed to create payment');
  }

  const data = await response.json();
  return data.token;
}

export function verifySignature(
  orderId: string,
  statusCode: string,
  grossAmount: string,
  signatureKey: string
): boolean {
  const crypto = require('crypto');
  const hash = crypto
    .createHash('sha512')
    .update(orderId + statusCode + grossAmount + MIDTRANS_SERVER_KEY)
    .digest('hex');

  return hash === signatureKey;
}

API Route: Create Snap Token

src/app/api/payment/create/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createSnapToken } from '@/lib/midtrans';

export async function POST(request: NextRequest) {
  try {
    const { orderId } = await request.json();

    if (!orderId) {
      return NextResponse.json(
        { error: 'Order ID is required' },
        { status: 400 }
      );
    }

    const supabase = await createClient();

    // Get order details
    const { data: order, error: orderError } = await supabase
      .from('orders')
      .select(`
        *,
        customer:customers(*),
        order_items(*)
      `)
      .eq('id', orderId)
      .single();

    if (orderError || !order) {
      return NextResponse.json(
        { error: 'Order not found' },
        { status: 404 }
      );
    }

    // Check if already paid
    if (order.payment_status === 'paid') {
      return NextResponse.json(
        { error: 'Order already paid' },
        { status: 400 }
      );
    }

    // Create item details for Midtrans
    const itemDetails = order.order_items.map((item: any) => ({
      id: item.service_id,
      name: item.service_name,
      price: item.price_per_unit,
      quantity: item.quantity,
    }));

    // Add discount as negative item if exists
    if (order.discount > 0) {
      itemDetails.push({
        id: 'DISCOUNT',
        name: 'Diskon',
        price: -order.discount,
        quantity: 1,
      });
    }

    // Create Snap token
    const snapToken = await createSnapToken({
      orderId: order.order_number,
      grossAmount: order.total,
      customerName: order.customer.name,
      customerEmail: order.customer.email,
      customerPhone: order.customer.phone,
      itemDetails,
    });

    // Save snap token to payments table
    await supabase.from('payments').upsert({
      order_id: order.id,
      amount: order.total,
      payment_method: 'midtrans',
      midtrans_snap_token: snapToken,
      status: 'pending',
    });

    return NextResponse.json({ snapToken });
  } catch (error) {
    console.error('Error creating payment:', error);
    return NextResponse.json(
      { error: 'Failed to create payment' },
      { status: 500 }
    );
  }
}

API Route: Webhook Handler

src/app/api/payment/webhook/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifySignature } from '@/lib/midtrans';

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

    const {
      order_id,
      status_code,
      gross_amount,
      signature_key,
      transaction_status,
      transaction_id,
      payment_type,
    } = body;

    // Verify signature
    const isValid = verifySignature(
      order_id,
      status_code,
      gross_amount,
      signature_key
    );

    if (!isValid) {
      console.error('Invalid signature');
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 403 }
      );
    }

    const supabase = createAdminClient();

    // Get order by order_number
    const { data: order, error: orderError } = await supabase
      .from('orders')
      .select('id, payment_status')
      .eq('order_number', order_id)
      .single();

    if (orderError || !order) {
      console.error('Order not found:', order_id);
      return NextResponse.json(
        { error: 'Order not found' },
        { status: 404 }
      );
    }

    // Determine payment status based on transaction_status
    let paymentStatus: 'pending' | 'success' | 'failed' | 'expired' = 'pending';
    let orderPaymentStatus = order.payment_status;

    if (['capture', 'settlement'].includes(transaction_status)) {
      paymentStatus = 'success';
      orderPaymentStatus = 'paid';
    } else if (['deny', 'cancel'].includes(transaction_status)) {
      paymentStatus = 'failed';
    } else if (transaction_status === 'expire') {
      paymentStatus = 'expired';
    }

    // Update payment record
    await supabase
      .from('payments')
      .update({
        status: paymentStatus,
        midtrans_transaction_id: transaction_id,
        paid_at: paymentStatus === 'success' ? new Date().toISOString() : null,
        raw_response: body,
      })
      .eq('order_id', order.id);

    // Update order payment status if payment successful
    if (orderPaymentStatus !== order.payment_status) {
      await supabase
        .from('orders')
        .update({
          payment_status: orderPaymentStatus,
          payment_method: payment_type,
        })
        .eq('id', order.id);
    }

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Webhook error:', error);
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    );
  }
}

Payment Success Page

src/app/(frontend)/payment/success/page.tsx:

import Link from 'next/link';
import { CheckCircle } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Navbar } from '@/components/frontend/navbar';
import { Footer } from '@/components/frontend/footer';

interface PaymentSuccessPageProps {
  searchParams: Promise<{ order?: string }>;
}

export default async function PaymentSuccessPage({
  searchParams,
}: PaymentSuccessPageProps) {
  const { order: orderNumber } = await searchParams;

  return (
    <>
      <Navbar />

      <div className="min-h-[60vh] flex items-center justify-center py-20">
        <Card className="max-w-md w-full mx-4">
          <CardContent className="p-8 text-center">
            <div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
              <CheckCircle className="h-8 w-8 text-green-600" />
            </div>

            <h1 className="text-2xl font-bold mb-2">Order Berhasil!</h1>

            <p className="text-gray-600 mb-4">
              Terima kasih, order Anda telah kami terima dan akan segera
              diproses.
            </p>

            {orderNumber && (
              <div className="bg-gray-50 rounded-lg p-4 mb-6">
                <p className="text-sm text-gray-500">Nomor Order:</p>
                <p className="text-xl font-mono font-bold">{orderNumber}</p>
              </div>
            )}

            <div className="space-y-3">
              <Button asChild className="w-full">
                <Link href={`/track?phone=`}>Track Order Saya</Link>
              </Button>
              <Button variant="outline" asChild className="w-full">
                <Link href="/">Kembali ke Home</Link>
              </Button>
            </div>

            <p className="text-sm text-gray-500 mt-6">
              Silakan antar cucian Anda ke outlet kami. Kami akan menghubungi
              Anda jika cucian sudah selesai.
            </p>
          </CardContent>
        </Card>
      </div>

      <Footer />
    </>
  );
}

Halaman Tracking Order

src/app/(frontend)/track/page.tsx:

'use client';

import { useState } from 'react';
import Link from 'next/link';
import {
  ArrowLeft,
  Search,
  Loader2,
  Clock,
  Package,
  CheckCircle,
  Truck,
  XCircle,
} from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';

import { Navbar } from '@/components/frontend/navbar';
import { Footer } from '@/components/frontend/footer';
import { getCustomerByPhone } from '@/actions/customers';
import {
  formatRupiah,
  formatDateTime,
  getStatusColor,
  getStatusLabel,
} from '@/lib/utils';
import type { Customer, Order, OrderItem } from '@/types';

interface OrderWithItems extends Order {
  order_items: OrderItem[];
}

interface CustomerWithOrders extends Customer {
  orders: OrderWithItems[];
}

const STATUS_STEPS = [
  { status: 'pending', icon: Clock, label: 'Order Diterima' },
  { status: 'processing', icon: Package, label: 'Sedang Dicuci' },
  { status: 'ready', icon: CheckCircle, label: 'Siap Diambil' },
  { status: 'picked_up', icon: Truck, label: 'Sudah Diambil' },
];

export default function TrackPage() {
  const { toast } = useToast();
  const [phone, setPhone] = useState('');
  const [loading, setLoading] = useState(false);
  const [customer, setCustomer] = useState<CustomerWithOrders | null>(null);
  const [searched, setSearched] = useState(false);

  async function handleSearch() {
    if (phone.length < 10) {
      toast({
        variant: 'destructive',
        title: 'Nomor HP Tidak Valid',
        description: 'Masukkan nomor HP minimal 10 digit.',
      });
      return;
    }

    setLoading(true);
    setSearched(true);

    const result = await getCustomerByPhone(phone);

    if (result.success) {
      setCustomer(result.data as CustomerWithOrders);
    } else {
      setCustomer(null);
      toast({
        variant: 'destructive',
        title: 'Tidak Ditemukan',
        description: result.error,
      });
    }

    setLoading(false);
  }

  function getStatusIndex(status: string): number {
    if (status === 'cancelled') return -1;
    return STATUS_STEPS.findIndex((s) => s.status === status);
  }

  return (
    <>
      <Navbar />

      <div className="min-h-screen bg-gray-50 py-8">
        <div className="max-w-2xl mx-auto px-4">
          {/* Back Link */}
          <Link
            href="/"
            className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
          >
            <ArrowLeft className="h-4 w-4" />
            Kembali ke Home
          </Link>

          <h1 className="text-2xl font-bold mb-2">Track Order</h1>
          <p className="text-gray-600 mb-6">
            Masukkan nomor HP yang digunakan saat order untuk melihat status
            cucian Anda.
          </p>

          {/* Search Form */}
          <Card className="mb-8">
            <CardContent className="p-4">
              <div className="flex gap-2">
                <Input
                  value={phone} => setPhone(e.target.value)}
                  placeholder="Contoh: 081234567890"
                  type="tel" => e.key === 'Enter' && handleSearch()}
                />
                <Button disabled={loading}>
                  {loading ? (
                    <Loader2 className="h-4 w-4 animate-spin" />
                  ) : (
                    <>
                      <Search className="mr-2 h-4 w-4" />
                      Cari
                    </>
                  )}
                </Button>
              </div>
            </CardContent>
          </Card>

          {/* Results */}
          {searched && !loading && (
            <>
              {customer ? (
                <div>
                  {/* Customer Info */}
                  <div className="bg-blue-50 rounded-lg p-4 mb-6">
                    <p className="font-medium">{customer.name}</p>
                    <p className="text-sm text-gray-600">{customer.phone}</p>
                  </div>

                  {/* Orders List */}
                  {customer.orders && customer.orders.length > 0 ? (
                    <div className="space-y-4">
                      <h2 className="font-semibold">
                        Riwayat Order ({customer.orders.length})
                      </h2>

                      {customer.orders.map((order) => {
                        const statusIndex = getStatusIndex(order.status);
                        const isCancelled = order.status === 'cancelled';

                        return (
                          <Card key={order.id}>
                            <CardContent className="p-4">
                              {/* Header */}
                              <div className="flex items-start justify-between mb-4">
                                <div>
                                  <p className="font-mono font-bold">
                                    {order.order_number}
                                  </p>
                                  <p className="text-sm text-gray-500">
                                    {formatDateTime(order.created_at)}
                                  </p>
                                </div>
                                <div className="text-right">
                                  <p className="font-bold text-green-600">
                                    {formatRupiah(order.total)}
                                  </p>
                                  <Badge
                                    className={getStatusColor(
                                      order.payment_status
                                    )}
                                  >
                                    {getStatusLabel(order.payment_status)}
                                  </Badge>
                                </div>
                              </div>

                              {/* Status Timeline */}
                              {isCancelled ? (
                                <div className="flex items-center gap-2 p-3 bg-red-50 rounded-lg text-red-600">
                                  <XCircle className="h-5 w-5" />
                                  <span>Order Dibatalkan</span>
                                </div>
                              ) : (
                                <div className="flex items-center justify-between mb-4">
                                  {STATUS_STEPS.map((step, index) => {
                                    const isComplete = index <= statusIndex;
                                    const isCurrent = index === statusIndex;

                                    return (
                                      <div
                                        key={step.status}
                                        className="flex flex-col items-center flex-1"
                                      >
                                        {/* Connector */}
                                        {index > 0 && (
                                          <div
                                            className={`absolute h-0.5 w-full -left-1/2 top-4 -z-10 ${
                                              index <= statusIndex
                                                ? 'bg-green-500'
                                                : 'bg-gray-200'
                                            }`}
                                          />
                                        )}

                                        <div
                                          className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center ${
                                            isComplete
                                              ? 'bg-green-500 text-white'
                                              : 'bg-gray-200 text-gray-400'
                                          }`}
                                        >
                                          <step.icon className="h-4 w-4" />
                                        </div>
                                        <span
                                          className={`text-xs mt-1 text-center ${
                                            isCurrent
                                              ? 'font-medium text-green-600'
                                              : 'text-gray-500'
                                          }`}
                                        >
                                          {step.label}
                                        </span>
                                      </div>
                                    );
                                  })}
                                </div>
                              )}

                              {/* Items */}
                              <div className="border-t pt-3 mt-3">
                                <p className="text-sm text-gray-500 mb-2">
                                  Detail Item:
                                </p>
                                <div className="space-y-1">
                                  {order.order_items?.map((item) => (
                                    <div
                                      key={item.id}
                                      className="flex justify-between text-sm"
                                    >
                                      <span>
                                        {item.service_name} ({item.quantity}kg)
                                      </span>
                                      <span>
                                        {formatRupiah(item.subtotal)}
                                      </span>
                                    </div>
                                  ))}
                                </div>
                              </div>

                              {/* Estimated Time */}
                              {order.status === 'processing' &&
                                order.estimated_done_at && (
                                  <div className="bg-yellow-50 rounded-lg p-3 mt-3">
                                    <p className="text-sm text-yellow-700">
                                      <Clock className="inline h-4 w-4 mr-1" />
                                      Estimasi selesai:{' '}
                                      {formatDateTime(order.estimated_done_at)}
                                    </p>
                                  </div>
                                )}

                              {/* Ready for pickup */}
                              {order.status === 'ready' && (
                                <div className="bg-green-50 rounded-lg p-3 mt-3">
                                  <p className="text-sm text-green-700 font-medium">
                                    <CheckCircle className="inline h-4 w-4 mr-1" />
                                    Cucian Anda sudah selesai dan siap diambil!
                                  </p>
                                </div>
                              )}
                            </CardContent>
                          </Card>
                        );
                      })}
                    </div>
                  ) : (
                    <div className="text-center py-8 text-gray-500">
                      <p>Belum ada order dengan nomor HP ini.</p>
                    </div>
                  )}
                </div>
              ) : (
                <div className="text-center py-8">
                  <p className="text-gray-500">
                    Nomor HP tidak ditemukan di sistem kami.
                  </p>
                  <p className="text-sm text-gray-400 mt-1">
                    Pastikan nomor HP yang dimasukkan sama dengan saat order.
                  </p>
                </div>
              )}
            </>
          )}
        </div>
      </div>

      <Footer />
    </>
  );
}

Update Order Page untuk Redirect ke Payment

Update bagian submit di /order/page.tsx untuk redirect ke Midtrans:

src/app/(frontend)/order/page.tsx (update handleSubmit):

// Add this after successful order creation
if (orderResult.success) {
  // For online orders, redirect to payment
  if (orderResult.data.payment_status === 'unpaid') {
    // Get snap token
    const paymentResponse = await fetch('/api/payment/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ orderId: orderResult.data.id }),
    });

    if (paymentResponse.ok) {
      const { snapToken } = await paymentResponse.json();

      // Load Midtrans Snap
      if (typeof window !== 'undefined' && (window as any).snap) {
        (window as any).snap.pay(snapToken, {
          onSuccess: function () {
            router.push(`/payment/success?order=${orderResult.data.order_number}`);
          },
          onPending: function () {
            router.push(`/payment/success?order=${orderResult.data.order_number}`);
          },
          onError: function () {
            toast({
              variant: 'destructive',
              title: 'Pembayaran Gagal',
              description: 'Silakan coba lagi atau pilih metode lain.',
            });
          },
          onClose: function () {
            toast({
              title: 'Pembayaran Dibatalkan',
              description: 'Anda bisa melanjutkan pembayaran nanti.',
            });
          },
        });
      }
    }
  } else {
    router.push(`/payment/success?order=${orderResult.data.order_number}`);
  }
}

Load Midtrans Snap Script

Update layout untuk load Midtrans:

src/app/(frontend)/layout.tsx:

import Script from 'next/script';
import { Toaster } from '@/components/ui/toaster';

const MIDTRANS_CLIENT_KEY = process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY;
const IS_PRODUCTION = process.env.MIDTRANS_IS_PRODUCTION === 'true';

const SNAP_URL = IS_PRODUCTION
  ? '<https://app.midtrans.com/snap/snap.js>'
  : '<https://app.sandbox.midtrans.com/snap/snap.js>';

export default function FrontendLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-white">
      {children}
      <Toaster />
      <Script
        src={SNAP_URL}
        data-client-key={MIDTRANS_CLIENT_KEY}
        strategy="lazyOnload"
      />
    </div>
  );
}

Yang Perlu Diperhatikan:

  • ✅ Midtrans Snap integration untuk popup payment
  • ✅ API route untuk create snap token
  • ✅ Webhook handler dengan signature verification
  • ✅ Payment success page
  • ✅ Tracking page dengan visual status timeline
  • ✅ Search by phone number
  • ✅ Order history dengan item details
  • ✅ Estimated time dan ready notification
  • ⚠️ Webhook URL harus di-register di Midtrans Dashboard → Settings → Payment Notification URL
  • ⚠️ Untuk production, ganti ke production keys dan URL

Di bagian terakhir, kita akan lengkapi Dashboard Stats, Charts dan Deploy ke Vercel.

Bagian 10: Dashboard Stats, Charts & Deploy

Di bagian terakhir ini, kita akan lengkapi dashboard dengan chart revenue, tabel order terbaru, dan deploy aplikasi ke Vercel.

Dashboard dengan Recharts

Prompt ke Claude

Update halaman dashboard untuk menampilkan:
1. Stats cards yang sudah ada (total orders, revenue hari ini, processing, ready)
2. Line chart revenue 7 hari terakhir menggunakan Recharts
3. Tabel 5 order terbaru dengan status badges
4. Quick stats: total customers, total revenue all time

Fetch semua data via Server Component.
Styling responsive dengan grid layout.

Review Output (30%)

src/app/(dashboard)/dashboard/page.tsx:

import { createClient } from '@/lib/supabase/server';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
  ShoppingCart,
  DollarSign,
  Clock,
  CheckCircle,
  Users,
  TrendingUp,
} from 'lucide-react';
import { formatRupiah, formatDateTime, getStatusColor, getStatusLabel } from '@/lib/utils';
import { RevenueChart } from '@/components/dashboard/revenue-chart';
import type { Order, Customer } from '@/types';

interface OrderWithCustomer extends Order {
  customer: Customer;
}

export default async function DashboardPage() {
  const supabase = await createClient();

  // Get today's date range
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const tomorrow = new Date(today);
  tomorrow.setDate(tomorrow.getDate() + 1);

  // Get 7 days ago
  const sevenDaysAgo = new Date(today);
  sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6);

  // Fetch all stats in parallel
  const [
    { count: totalOrders },
    { data: todayOrders },
    { count: processingOrders },
    { count: readyOrders },
    { count: totalCustomers },
    { data: allPaidOrders },
    { data: recentOrders },
    { data: weeklyOrders },
  ] = await Promise.all([
    // Total orders
    supabase.from('orders').select('*', { count: 'exact', head: true }),

    // Today's paid orders
    supabase
      .from('orders')
      .select('total')
      .gte('created_at', today.toISOString())
      .lt('created_at', tomorrow.toISOString())
      .eq('payment_status', 'paid'),

    // Processing orders
    supabase
      .from('orders')
      .select('*', { count: 'exact', head: true })
      .eq('status', 'processing'),

    // Ready orders
    supabase
      .from('orders')
      .select('*', { count: 'exact', head: true })
      .eq('status', 'ready'),

    // Total customers
    supabase.from('customers').select('*', { count: 'exact', head: true }),

    // All time revenue
    supabase
      .from('orders')
      .select('total')
      .eq('payment_status', 'paid'),

    // Recent orders (last 5)
    supabase
      .from('orders')
      .select(`
        *,
        customer:customers(id, name, phone)
      `)
      .order('created_at', { ascending: false })
      .limit(5),

    // Weekly orders for chart
    supabase
      .from('orders')
      .select('total, created_at, payment_status')
      .gte('created_at', sevenDaysAgo.toISOString())
      .eq('payment_status', 'paid')
      .order('created_at', { ascending: true }),
  ]);

  // Calculate stats
  const todayRevenue = todayOrders?.reduce((sum, o) => sum + o.total, 0) || 0;
  const allTimeRevenue = allPaidOrders?.reduce((sum, o) => sum + o.total, 0) || 0;

  // Process weekly data for chart
  const chartData = processWeeklyData(weeklyOrders || []);

  const stats = [
    {
      title: 'Total Orders',
      value: totalOrders || 0,
      icon: ShoppingCart,
      color: 'text-blue-600',
      bgColor: 'bg-blue-100',
    },
    {
      title: 'Revenue Hari Ini',
      value: formatRupiah(todayRevenue),
      icon: DollarSign,
      color: 'text-green-600',
      bgColor: 'bg-green-100',
    },
    {
      title: 'Sedang Diproses',
      value: processingOrders || 0,
      icon: Clock,
      color: 'text-orange-600',
      bgColor: 'bg-orange-100',
    },
    {
      title: 'Siap Diambil',
      value: readyOrders || 0,
      icon: CheckCircle,
      color: 'text-emerald-600',
      bgColor: 'bg-emerald-100',
    },
  ];

  const secondaryStats = [
    {
      title: 'Total Customers',
      value: totalCustomers || 0,
      icon: Users,
    },
    {
      title: 'Total Revenue',
      value: formatRupiah(allTimeRevenue),
      icon: TrendingUp,
    },
  ];

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold">Dashboard</h1>
        <p className="text-gray-500">Overview bisnis laundry Anda</p>
      </div>

      {/* Primary Stats Grid */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        {stats.map((stat) => (
          <Card key={stat.title}>
            <CardHeader className="flex flex-row items-center justify-between pb-2">
              <CardTitle className="text-sm font-medium text-gray-600">
                {stat.title}
              </CardTitle>
              <div className={`p-2 rounded-lg ${stat.bgColor}`}>
                <stat.icon className={`h-4 w-4 ${stat.color}`} />
              </div>
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stat.value}</div>
            </CardContent>
          </Card>
        ))}
      </div>

      {/* Charts & Recent Orders */}
      <div className="grid gap-6 lg:grid-cols-2">
        {/* Revenue Chart */}
        <Card>
          <CardHeader>
            <CardTitle>Revenue 7 Hari Terakhir</CardTitle>
          </CardHeader>
          <CardContent>
            <RevenueChart data={chartData} />
          </CardContent>
        </Card>

        {/* Recent Orders */}
        <Card>
          <CardHeader>
            <CardTitle>Order Terbaru</CardTitle>
          </CardHeader>
          <CardContent>
            {recentOrders && recentOrders.length > 0 ? (
              <div className="space-y-4">
                {(recentOrders as OrderWithCustomer[]).map((order) => (
                  <div
                    key={order.id}
                    className="flex items-center justify-between p-3 rounded-lg border"
                  >
                    <div className="flex-1 min-w-0">
                      <p className="font-mono text-sm font-medium truncate">
                        {order.order_number}
                      </p>
                      <p className="text-sm text-gray-500 truncate">
                        {order.customer?.name}
                      </p>
                    </div>
                    <div className="text-right ml-4">
                      <p className="font-medium text-green-600">
                        {formatRupiah(order.total)}
                      </p>
                      <Badge className={getStatusColor(order.status)}>
                        {getStatusLabel(order.status)}
                      </Badge>
                    </div>
                  </div>
                ))}
              </div>
            ) : (
              <p className="text-center text-gray-500 py-8">
                Belum ada order
              </p>
            )}
          </CardContent>
        </Card>
      </div>

      {/* Secondary Stats */}
      <div className="grid gap-4 md:grid-cols-2">
        {secondaryStats.map((stat) => (
          <Card key={stat.title}>
            <CardContent className="flex items-center gap-4 p-6">
              <div className="p-3 rounded-lg bg-gray-100">
                <stat.icon className="h-6 w-6 text-gray-600" />
              </div>
              <div>
                <p className="text-sm text-gray-500">{stat.title}</p>
                <p className="text-2xl font-bold">{stat.value}</p>
              </div>
            </CardContent>
          </Card>
        ))}
      </div>
    </div>
  );
}

// Helper function to process weekly data
function processWeeklyData(
  orders: { total: number; created_at: string }[]
): { date: string; revenue: number }[] {
  const dailyTotals: Record<string, number> = {};

  // Initialize last 7 days with 0
  for (let i = 6; i >= 0; i--) {
    const date = new Date();
    date.setDate(date.getDate() - i);
    const key = date.toISOString().split('T')[0];
    dailyTotals[key] = 0;
  }

  // Sum up orders by date
  orders.forEach((order) => {
    const date = order.created_at.split('T')[0];
    if (dailyTotals[date] !== undefined) {
      dailyTotals[date] += order.total;
    }
  });

  // Convert to array for Recharts
  return Object.entries(dailyTotals).map(([date, revenue]) => ({
    date: formatChartDate(date),
    revenue,
  }));
}

function formatChartDate(dateStr: string): string {
  const date = new Date(dateStr);
  return date.toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric' });
}

src/components/dashboard/revenue-chart.tsx:

'use client';

import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from 'recharts';
import { formatRupiah } from '@/lib/utils';

interface RevenueChartProps {
  data: { date: string; revenue: number }[];
}

export function RevenueChart({ data }: RevenueChartProps) {
  if (data.length === 0) {
    return (
      <div className="h-64 flex items-center justify-center text-gray-400">
        Belum ada data revenue
      </div>
    );
  }

  return (
    <div className="h-64">
      <ResponsiveContainer width="100%" height="100%">
        <LineChart
          data={data}
          margin={{ top: 5, right: 10, left: 10, bottom: 5 }}
        >
          <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
          <XAxis
            dataKey="date"
            tick={{ fontSize: 12 }}
            tickLine={false}
            axisLine={false}
          />
          <YAxis
            tick={{ fontSize: 12 }}
            tickLine={false}
            axisLine={false}
            tickFormatter={(value) =>
              value >= 1000000
                ? `${(value / 1000000).toFixed(1)}jt`
                : value >= 1000
                ? `${(value / 1000).toFixed(0)}rb`
                : value.toString()
            }
          />
          <Tooltip
            formatter={(value: number) => [formatRupiah(value), 'Revenue']}
            labelStyle={{ fontWeight: 'bold' }}
            contentStyle={{
              borderRadius: '8px',
              border: '1px solid #e5e7eb',
            }}
          />
          <Line
            type="monotone"
            dataKey="revenue"
            stroke="#2563eb"
            strokeWidth={2}
            dot={{ fill: '#2563eb', strokeWidth: 2 }}
            activeDot={{ r: 6, fill: '#2563eb' }}
          />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

Deploy ke Vercel

Sekarang aplikasi sudah lengkap, saatnya deploy ke production.

Step 1: Push ke GitHub

# Initialize git (jika belum)
git init

# Create .gitignore
cat > .gitignore << 'EOF'
node_modules
.next
.env.local
.env*.local
.vercel
EOF

# Add all files
git add .
git commit -m "Initial commit: Laundry POS complete"

# Create repo di GitHub, lalu:
git remote add origin <https://github.com/USERNAME/laundry-pos.git>
git branch -M main
git push -u origin main

Step 2: Deploy di Vercel

  1. Buka vercel.com dan login dengan GitHub
  2. Klik Add New Project
  3. Import repository laundry-pos
  4. Vercel akan auto-detect Next.js project
  5. Configure Environment Variables — tambahkan semua env dari .env.local:
    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_ANON_KEY
    • SUPABASE_SERVICE_ROLE_KEY
    • MIDTRANS_SERVER_KEY
    • NEXT_PUBLIC_MIDTRANS_CLIENT_KEY
    • MIDTRANS_IS_PRODUCTION = false (ganti true untuk production)
    • NEXT_PUBLIC_APP_URL = URL Vercel Anda (contoh: https://laundry-pos.vercel.app)
  6. Klik Deploy

Step 3: Update Midtrans Webhook URL

  1. Buka Midtrans Dashboard
  2. Pergi ke SettingsPayment Notification URL
  3. Set URL ke: https://YOUR-DOMAIN.vercel.app/api/payment/webhook
  4. Save

Step 4: Update Supabase (Production)

Jika menggunakan project Supabase yang sama:

  1. Buka AuthenticationURL Configuration
  2. Tambahkan domain Vercel ke Site URL dan Redirect URLs

Untuk production proper, buat Supabase project terpisah dan migrate schema.

Testing Production

Setelah deploy berhasil:

  1. Test Landing Page — Buka domain Vercel, pastikan semua section muncul
  2. Test Login — Login dengan akun admin
  3. Test POS — Buat order baru
  4. Test Payment — Checkout dengan Midtrans sandbox
  5. Test Tracking — Track order dengan nomor HP

Recap: Fitur yang Sudah Dibangun

Di tutorial ini, kita sudah membangun sistem laundry management lengkap:

Dashboard Admin/Kasir:

  • ✅ Authentication (login/logout)
  • ✅ Dashboard dengan stats cards dan revenue chart
  • ✅ CRUD Services/Layanan
  • ✅ CRUD Customers dengan search
  • ✅ POS untuk buat order (walk-in)
  • ✅ Manage Orders dengan filter dan update status
  • ✅ Order detail dengan timeline status

Front Checkout Customer:

  • ✅ Landing page informatif
  • ✅ Multi-step checkout form
  • ✅ Auto-detect existing customer
  • ✅ Midtrans payment integration
  • ✅ Order tracking by phone

Technical Stack:

  • ✅ Next.js 14 App Router
  • ✅ TypeScript end-to-end
  • ✅ Supabase (PostgreSQL + Auth + RLS)
  • ✅ Server Actions untuk mutations
  • ✅ Zustand untuk cart state
  • ✅ React Hook Form + Zod validation
  • ✅ TailwindCSS + shadcn/ui
  • ✅ Recharts untuk visualisasi

Ideas untuk Enhancement

Beberapa fitur yang bisa ditambahkan untuk pengembangan lebih lanjut:

  1. Multi-outlet Support — Tambah table outlets, assign orders ke outlet tertentu
  2. Role Management — Bedakan akses admin vs kasir vs owner
  3. Pickup/Delivery Service — Integrasi dengan kurir
  4. Notifications — WhatsApp/SMS notifikasi status order
  5. Reporting — Export laporan ke Excel, filter by date range
  6. Inventory — Track stok deterjen, parfum, dll
  7. Loyalty Program — Sistem poin untuk customer
  8. PWA Support — Jadikan Progressive Web App
  9. Dark Mode — Toggle tema gelap/terang
  10. Multi-language — Support English + Bahasa Indonesia

Penutup

Selamat! Anda sudah berhasil membangun aplikasi laundry management yang production-ready dari nol. Sistem ini bisa langsung dipakai untuk bisnis laundry beneran — tinggal sesuaikan branding, harga layanan, dan detail bisnis lainnya.

Key takeaways dari tutorial ini:

  • Next.js 14 App Router sangat powerful untuk full-stack apps
  • Server Actions menyederhanakan data mutations
  • Supabase memberikan backend lengkap dengan setup minimal
  • Component libraries seperti shadcn/ui mempercepat development
  • TypeScript membantu catch errors lebih awal

Kalau ada pertanyaan atau stuck di bagian tertentu, jangan ragu untuk bertanya di komunitas BuildWithAngga.

Untuk memperdalam skill Next.js dan React, cek juga kelas-kelas premium di BuildWithAngga:

┌─────────────────────────────────────────────────────────┐
│  🎓 KELAS PREMIUM BUILDWITHANGGA                        │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ✅ Full-Stack Next.js Developer                        │
│  ✅ React.js Mastery                                    │
│  ✅ TypeScript untuk Web Developer                      │
│  ✅ Supabase Backend Development                        │
│  ✅ Tailwind CSS Pro                                    │
│                                                         │
│  🎁 Benefit:                                            │
│  • Akses seumur hidup                                   │
│  • Project portfolio real-world                         │
│  • Sertifikat completion                                │
│  • Support grup eksklusif                               │
│  • Update materi berkala                                │
│                                                         │
│  👉 buildwithangga.com/kelas                            │
└─────────────────────────────────────────────────────────┘

Happy coding! 🚀

Angga Risky Setiawan Founder, BuildWithAngga