39457-vm/frontend/src/pages/pos/checkout.tsx
Flatlogic Bot 91068a8627 cct
2026-04-03 16:11:53 +00:00

1103 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as icon from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import BaseButton from '../../components/BaseButton';
import BaseButtons from '../../components/BaseButtons';
import BaseDivider from '../../components/BaseDivider';
import BaseIcon from '../../components/BaseIcon';
import CardBox from '../../components/CardBox';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { useAppSelector } from '../../stores/hooks';
type PosProduct = {
id: string;
name: string;
sku?: string;
barcode?: string;
sell_price: number;
tax_rate: number;
track_stock: boolean;
is_active: boolean;
stockOnHand: number | null;
category?: {
id: string;
name: string;
} | null;
};
type PosStore = {
id: string;
name: string;
code?: string;
};
type PosRegister = {
id: string;
name: string;
code?: string;
printer_name?: string;
printer_type?: string;
auto_print_receipt?: boolean;
storeId?: string;
};
type PosCustomer = {
id: string;
name: string;
phone?: string;
};
type CartItem = {
productId: string;
name: string;
sku?: string;
unitPrice: number;
taxRate: number;
quantity: number;
trackStock: boolean;
stockOnHand: number | null;
};
type SaleSummary = {
id: string;
receipt_number: string;
sold_at: string;
total_amount: number;
payment_status: string;
itemCount: number;
quantityTotal: number;
customer?: {
name?: string;
} | null;
cashier?: {
firstName?: string;
lastName?: string;
} | null;
};
type SaleDetail = {
id: string;
receipt_number: string;
sold_at: string;
subtotal_amount: number;
tax_amount: number;
total_amount: number;
paid_amount: number;
change_amount: number;
notes?: string;
customer?: {
name?: string;
} | null;
cashier?: {
firstName?: string;
lastName?: string;
} | null;
store?: {
name?: string;
receipt_header?: string;
receipt_footer?: string;
currency_code?: string;
} | null;
register?: {
name?: string;
printer_type?: string;
} | null;
sale_items_sale?: Array<{
id: string;
product_name_snapshot: string;
sku_snapshot?: string;
quantity: number;
unit_price: number;
line_total: number;
}>;
payments_sale?: Array<{
id: string;
method: string;
amount: number;
status: string;
}>;
};
type PosContext = {
activeStoreId: string | null;
stores: PosStore[];
registers: PosRegister[];
customers: PosCustomer[];
products: PosProduct[];
recentSales: SaleSummary[];
summary: {
todaySalesCount: number;
todayRevenue: number;
};
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(Number(value || 0));
const formatDateTime = (value?: string) => {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
const getCashierName = (cashier?: { firstName?: string; lastName?: string } | null) =>
`${cashier?.firstName || ''} ${cashier?.lastName || ''}`.trim() || 'Kasir';
const paymentMethodLabels: Record<string, string> = {
cash: 'Tunai',
card: 'Kartu',
bank_transfer: 'Transfer',
qris: 'QRIS',
ewallet: 'E-Wallet',
voucher: 'Voucher',
split: 'Split Bill',
};
const cashRegisterIcon =
'mdiCashRegister' in icon ? icon['mdiCashRegister' as keyof typeof icon] : icon.mdiTable;
const productIcon =
'mdiPackageVariantClosed' in icon
? icon['mdiPackageVariantClosed' as keyof typeof icon]
: icon.mdiTable;
const receiptIcon =
'mdiReceiptText' in icon ? icon['mdiReceiptText' as keyof typeof icon] : icon.mdiTable;
const searchIcon =
'mdiMagnify' in icon ? icon['mdiMagnify' as keyof typeof icon] : icon.mdiTable;
const printerIcon =
'mdiPrinter' in icon ? icon['mdiPrinter' as keyof typeof icon] : receiptIcon;
const alertIcon =
'mdiAlertCircleOutline' in icon
? icon['mdiAlertCircleOutline' as keyof typeof icon]
: icon.mdiTable;
const storeIcon =
'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable;
const customersIcon =
'mdiAccountBox' in icon ? icon['mdiAccountBox' as keyof typeof icon] : icon.mdiTable;
const PosCheckoutPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [context, setContext] = useState<PosContext | null>(null);
const [selectedStoreId, setSelectedStoreId] = useState('');
const [selectedRegisterId, setSelectedRegisterId] = useState('');
const [selectedCustomerId, setSelectedCustomerId] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [paymentMethod, setPaymentMethod] = useState('cash');
const [paidAmount, setPaidAmount] = useState('0');
const [notes, setNotes] = useState('');
const [printReceipt, setPrintReceipt] = useState(true);
const [cart, setCart] = useState<CartItem[]>([]);
const [selectedReceipt, setSelectedReceipt] = useState<SaleDetail | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [loadingReceiptId, setLoadingReceiptId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const canUsePos = hasPermission(currentUser, ['READ_SALES', 'CREATE_SALES']) && hasPermission(currentUser, 'READ_PRODUCTS');
const loadContext = async (storeId?: string, options?: { keepSelection?: boolean }) => {
try {
setLoading(true);
const query = storeId ? `?storeId=${storeId}` : '';
const response = await axios.get(`sales/pos-context${query}`);
const nextContext = response.data as PosContext;
setContext(nextContext);
const nextStoreId = storeId || nextContext.activeStoreId || nextContext.stores[0]?.id || '';
setSelectedStoreId(nextStoreId);
setSelectedRegisterId((currentRegisterId) => {
const matchingCurrent = nextContext.registers.find(
(register) => register.id === currentRegisterId && register.storeId === nextStoreId,
);
if (matchingCurrent) {
return currentRegisterId;
}
return nextContext.registers.find((register) => register.storeId === nextStoreId)?.id || '';
});
if (!options?.keepSelection) {
setSelectedCustomerId('');
}
} catch (error: any) {
console.error('Failed to load POS context', error);
setErrorMessage(error?.response?.data || 'Gagal memuat data kasir.');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (canUsePos) {
loadContext();
} else {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUsePos]);
useEffect(() => {
if (!context) {
return;
}
setCart((currentCart) =>
currentCart.map((item) => {
const freshProduct = context.products.find((product) => product.id === item.productId);
if (!freshProduct) {
return item;
}
return {
...item,
stockOnHand: freshProduct.stockOnHand,
unitPrice: Number(freshProduct.sell_price || item.unitPrice),
taxRate: Number(freshProduct.tax_rate || item.taxRate),
};
}),
);
}, [context]);
const filteredRegisters = useMemo(
() => (context?.registers || []).filter((register) => !selectedStoreId || register.storeId === selectedStoreId),
[context?.registers, selectedStoreId],
);
useEffect(() => {
if (!filteredRegisters.length) {
setSelectedRegisterId('');
return;
}
const registerStillValid = filteredRegisters.some((register) => register.id === selectedRegisterId);
if (!registerStillValid) {
setSelectedRegisterId(filteredRegisters[0].id);
}
}, [filteredRegisters, selectedRegisterId]);
const filteredProducts = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return (context?.products || []).filter((product) => {
if (!normalizedSearch) {
return true;
}
return [product.name, product.sku, product.barcode, product.category?.name]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedSearch));
});
}, [context?.products, searchTerm]);
const cartSummary = useMemo(() => {
const summary = cart.reduce(
(accumulator, item) => {
const lineSubtotal = item.unitPrice * item.quantity;
const lineTax = (lineSubtotal * item.taxRate) / 100;
const lineTotal = lineSubtotal + lineTax;
accumulator.subtotal += lineSubtotal;
accumulator.tax += lineTax;
accumulator.total += lineTotal;
accumulator.quantity += item.quantity;
return accumulator;
},
{
subtotal: 0,
tax: 0,
total: 0,
quantity: 0,
},
);
return {
subtotal: Number(summary.subtotal.toFixed(2)),
tax: Number(summary.tax.toFixed(2)),
total: Number(summary.total.toFixed(2)),
quantity: summary.quantity,
};
}, [cart]);
useEffect(() => {
setPaidAmount((currentValue) => {
if (!cart.length) {
return '0';
}
const numericValue = Number(currentValue || 0);
if (!numericValue || numericValue < cartSummary.total) {
return String(Number(cartSummary.total.toFixed(2)));
}
return currentValue;
});
}, [cart, cartSummary.total]);
const addProductToCart = (product: PosProduct) => {
setSuccessMessage('');
setErrorMessage('');
setCart((currentCart) => {
const existingItem = currentCart.find((item) => item.productId === product.id);
const nextQuantity = (existingItem?.quantity || 0) + 1;
if (product.track_stock && product.stockOnHand !== null && nextQuantity > product.stockOnHand) {
setErrorMessage(`Stok ${product.name} tidak cukup untuk ditambahkan.`);
return currentCart;
}
if (existingItem) {
return currentCart.map((item) =>
item.productId === product.id
? {
...item,
quantity: nextQuantity,
}
: item,
);
}
return [
...currentCart,
{
productId: product.id,
name: product.name,
sku: product.sku,
quantity: 1,
unitPrice: Number(product.sell_price || 0),
taxRate: Number(product.tax_rate || 0),
trackStock: product.track_stock,
stockOnHand: product.stockOnHand,
},
];
});
};
const updateCartQuantity = (productId: string, quantity: number) => {
setCart((currentCart) =>
currentCart
.map((item) => {
if (item.productId !== productId) {
return item;
}
const maxQuantity = item.trackStock && item.stockOnHand !== null ? Number(item.stockOnHand) : Number.MAX_SAFE_INTEGER;
const safeQuantity = Math.min(Math.max(quantity, 1), maxQuantity);
return {
...item,
quantity: safeQuantity,
};
})
.filter((item) => item.quantity > 0),
);
};
const removeCartItem = (productId: string) => {
setCart((currentCart) => currentCart.filter((item) => item.productId !== productId));
};
const openPrintWindow = (sale: SaleDetail) => {
const receiptWindow = window.open('', '_blank', 'width=420,height=720');
if (!receiptWindow) {
setErrorMessage('Popup printer terblokir browser. Silakan izinkan popup lalu coba print ulang.');
return;
}
const receiptLines = (sale.sale_items_sale || [])
.map(
(item) => `
<tr>
<td style="padding:6px 0; vertical-align:top;">${item.product_name_snapshot}<br /><span style="color:#6b7280; font-size:11px;">${item.quantity} x ${formatCurrency(Number(item.unit_price || 0))}</span></td>
<td style="padding:6px 0; text-align:right; vertical-align:top; font-weight:600;">${formatCurrency(Number(item.line_total || 0))}</td>
</tr>
`,
)
.join('');
const paymentLine = sale.payments_sale?.[0];
receiptWindow.document.write(`
<html>
<head>
<title>${sale.receipt_number}</title>
<style>
body {
font-family: Inter, Arial, sans-serif;
color: #0f172a;
padding: 16px;
max-width: 360px;
margin: 0 auto;
}
.muted { color: #64748b; }
.headline { text-align: center; margin-bottom: 16px; }
.divider { border-top: 1px dashed #cbd5e1; margin: 12px 0; }
table { width: 100%; border-collapse: collapse; }
.totals td { padding: 4px 0; }
</style>
</head>
<body>
<div class="headline">
<div style="font-size:18px; font-weight:800;">${sale.store?.name || 'POS Checkout'}</div>
${sale.store?.receipt_header ? `<div class="muted" style="margin-top:4px;">${sale.store.receipt_header}</div>` : ''}
<div style="margin-top:8px; font-size:12px;">${sale.receipt_number}</div>
<div class="muted" style="font-size:12px;">${formatDateTime(sale.sold_at)}</div>
</div>
<div style="font-size:12px; display:flex; justify-content:space-between; gap:8px;">
<div><strong>Kasir:</strong> ${getCashierName(sale.cashier)}</div>
<div><strong>Register:</strong> ${sale.register?.name || '-'}</div>
</div>
${sale.customer?.name ? `<div style="font-size:12px; margin-top:4px;"><strong>Pelanggan:</strong> ${sale.customer.name}</div>` : ''}
<div class="divider"></div>
<table>
<tbody>
${receiptLines}
</tbody>
</table>
<div class="divider"></div>
<table class="totals">
<tbody>
<tr><td class="muted">Subtotal</td><td style="text-align:right;">${formatCurrency(Number(sale.subtotal_amount || 0))}</td></tr>
<tr><td class="muted">Pajak</td><td style="text-align:right;">${formatCurrency(Number(sale.tax_amount || 0))}</td></tr>
<tr><td style="font-weight:700;">Total</td><td style="text-align:right; font-weight:700;">${formatCurrency(Number(sale.total_amount || 0))}</td></tr>
<tr><td class="muted">Bayar (${paymentMethodLabels[paymentLine?.method || 'cash'] || paymentLine?.method || 'Tunai'})</td><td style="text-align:right;">${formatCurrency(Number(sale.paid_amount || paymentLine?.amount || 0))}</td></tr>
<tr><td class="muted">Kembalian</td><td style="text-align:right;">${formatCurrency(Number(sale.change_amount || 0))}</td></tr>
</tbody>
</table>
${sale.notes ? `<div class="divider"></div><div style="font-size:12px;"><strong>Catatan:</strong> ${sale.notes}</div>` : ''}
${sale.store?.receipt_footer ? `<div class="divider"></div><div class="muted" style="text-align:center; font-size:12px;">${sale.store.receipt_footer}</div>` : ''}
<script>
window.onload = function() {
window.print();
};
</script>
</body>
</html>
`);
receiptWindow.document.close();
};
const handleCheckout = async () => {
if (!selectedStoreId || !selectedRegisterId) {
setErrorMessage('Pilih toko dan register sebelum menyimpan transaksi.');
return;
}
if (!cart.length) {
setErrorMessage('Keranjang masih kosong.');
return;
}
try {
setSubmitting(true);
setErrorMessage('');
setSuccessMessage('');
const response = await axios.post('sales/checkout', {
storeId: selectedStoreId,
registerId: selectedRegisterId,
customerId: selectedCustomerId || null,
paymentMethod,
paidAmount: Number(paidAmount || 0),
notes,
printReceipt,
items: cart.map((item) => ({
productId: item.productId,
quantity: item.quantity,
})),
});
const sale = response.data as SaleDetail;
setSelectedReceipt(sale);
setSuccessMessage(`Transaksi ${sale.receipt_number} berhasil disimpan.`);
setCart([]);
setNotes('');
setSelectedCustomerId('');
await loadContext(selectedStoreId, { keepSelection: true });
if (printReceipt) {
openPrintWindow(sale);
}
} catch (error: any) {
console.error('Checkout failed', error);
setErrorMessage(error?.response?.data || 'Transaksi gagal diproses.');
} finally {
setSubmitting(false);
}
};
const loadReceiptDetail = async (saleId: string) => {
try {
setLoadingReceiptId(saleId);
const response = await axios.get(`sales/${saleId}`);
setSelectedReceipt(response.data as SaleDetail);
} catch (error: any) {
console.error('Failed to load receipt detail', error);
setErrorMessage(error?.response?.data || 'Gagal memuat detail struk.');
} finally {
setLoadingReceiptId('');
}
};
const setupIncomplete = Boolean(context) && (!context.stores.length || !context.registers.length || !context.products.length);
return (
<>
<Head>
<title>{getPageTitle('POS Checkout')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={cashRegisterIcon} title="POS Checkout" main>
<BaseButtons noWrap>
<BaseButton color="whiteDark" label="Produk" href="/products/products-list" />
<BaseButton color="info" label="Riwayat penjualan" href="/sales/sales-list" />
</BaseButtons>
</SectionTitleLineWithButton>
{!canUsePos && (
<CardBox className="border border-red-100 bg-white/90">
<div className="flex items-start gap-3 rounded-2xl border border-red-100 bg-red-50 p-5 text-red-900">
<BaseIcon path={alertIcon} className="mt-0.5 text-red-500" />
<div>
<h2 className="text-lg font-semibold">Akses POS belum tersedia</h2>
<p className="mt-1 text-sm text-red-700">
Halaman ini membutuhkan izin baca produk serta buat transaksi penjualan. Gunakan akun Admin atau tambahkan izin untuk role kasir.
</p>
</div>
</div>
</CardBox>
)}
{canUsePos && (
<>
<div className="mb-6 grid gap-6 xl:grid-cols-[1.6fr_1fr]">
<div className="overflow-hidden rounded-3xl bg-gradient-to-br from-[#0F172A] via-[#1D4ED8] to-[#14B8A6] p-6 text-white shadow-lg">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<div className="mb-3 inline-flex items-center rounded-full bg-white/15 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-white/80">
MVP kasir print dialog browser
</div>
<h2 className="text-3xl font-black tracking-tight">Transaksi cepat, stok langsung berkurang, struk siap dicetak.</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-100/90">
Satu layar untuk pilih produk, hitung pajak, simpan pembayaran, dan buka print struk otomatis. Cocok untuk admin maupun kasir harian.
</p>
</div>
<div className="grid min-w-[280px] gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.2em] text-white/70">Penjualan hari ini</div>
<div className="mt-2 text-3xl font-bold">{context?.summary.todaySalesCount || 0}</div>
<div className="mt-1 text-sm text-white/75">transaksi selesai</div>
</div>
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
<div className="text-xs uppercase tracking-[0.2em] text-white/70">Omzet hari ini</div>
<div className="mt-2 text-3xl font-bold">{formatCurrency(context?.summary.todayRevenue || 0)}</div>
<div className="mt-1 text-sm text-white/75">berdasarkan toko aktif</div>
</div>
</div>
</div>
</div>
<CardBox className="border border-slate-200/70 bg-white/90 shadow-sm">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Toko aktif</span>
<select
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-4 focus:ring-sky-100"
value={selectedStoreId}
onChange={(event) => {
const nextStoreId = event.target.value;
setSelectedStoreId(nextStoreId);
loadContext(nextStoreId, { keepSelection: true });
}}
>
{(context?.stores || []).map((store) => (
<option key={store.id} value={store.id}>
{store.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Register</span>
<select
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-4 focus:ring-sky-100"
value={selectedRegisterId}
onChange={(event) => setSelectedRegisterId(event.target.value)}
>
{filteredRegisters.map((register) => (
<option key={register.id} value={register.id}>
{register.name}
</option>
))}
</select>
</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
<div className="font-semibold text-slate-900">Mode cetak</div>
<div className="mt-1 leading-6">
Checkout akan membuka <span className="font-semibold">browser print dialog</span> sebagai struk pertama.
</div>
<div className="mt-3 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
<BaseIcon path={printerIcon} className="text-sky-600" />
{filteredRegisters.find((register) => register.id === selectedRegisterId)?.printer_type || 'browser_print'}
</div>
</div>
</div>
</CardBox>
</div>
{(errorMessage || successMessage) && (
<div className="mb-6 space-y-3">
{errorMessage && (
<div className="rounded-2xl border border-red-100 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">
{errorMessage}
</div>
)}
{successMessage && (
<div className="rounded-2xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
{successMessage}
</div>
)}
</div>
)}
{loading && (
<CardBox className="border border-slate-200/70 bg-white/90 shadow-sm">
<div className="py-20 text-center text-sm text-slate-500">Memuat katalog kasir...</div>
</CardBox>
)}
{!loading && setupIncomplete && (
<CardBox className="border border-amber-100 bg-white/95 shadow-sm">
<div className="grid gap-5 rounded-3xl border border-amber-200 bg-amber-50 p-6 lg:grid-cols-[1.2fr_1fr] lg:items-center">
<div>
<div className="mb-3 inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-amber-700">
Setup dibutuhkan
</div>
<h3 className="text-2xl font-black text-slate-900">Sebelum transaksi pertama, lengkapi data toko POS Anda.</h3>
<p className="mt-3 text-sm leading-6 text-slate-600">
Halaman kasir ini siap dipakai begitu ada minimal 1 toko, 1 register, dan 1 produk. CRUD bawaan tetap dipakai untuk setup master data.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{!context?.stores.length && <BaseButton color="info" label="Tambah toko" href="/stores/stores-new" className="justify-center" />}
{!context?.registers.length && <BaseButton color="info" label="Tambah register" href="/registers/registers-new" className="justify-center" />}
{!context?.products.length && <BaseButton color="info" label="Tambah produk" href="/products/products-new" className="justify-center" />}
</div>
</div>
</CardBox>
)}
{!loading && !setupIncomplete && context && (
<div className="grid gap-6 xl:grid-cols-[1.55fr_1fr]">
<div className="space-y-6">
<CardBox className="border border-slate-200/70 bg-white/90 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">Katalog produk</div>
<h3 className="mt-2 text-2xl font-bold text-slate-900">Pilih item untuk checkout</h3>
</div>
<label className="relative block w-full md:max-w-sm">
<span className="sr-only">Cari produk</span>
<BaseIcon path={searchIcon} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-12 pr-4 text-sm text-slate-900 placeholder:text-slate-400 focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-4 focus:ring-sky-100"
placeholder="Cari nama, SKU, barcode..."
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</label>
</div>
<BaseDivider />
{filteredProducts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-6 py-16 text-center text-sm text-slate-500">
Tidak ada produk yang cocok dengan pencarian.
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
{filteredProducts.map((product) => {
const cartItem = cart.find((item) => item.productId === product.id);
const stockLabel = product.track_stock
? `${product.stockOnHand || 0} tersisa`
: 'Non-stok';
const lowStock = product.track_stock && Number(product.stockOnHand || 0) <= 3;
return (
<div
key={product.id}
className="group rounded-3xl border border-slate-200 bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:border-sky-200 hover:shadow-lg"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="rounded-2xl bg-sky-50 p-3 text-sky-700">
<BaseIcon path={productIcon} />
</div>
<div className="flex flex-wrap justify-end gap-2 text-[11px] font-semibold uppercase tracking-[0.16em]">
{product.category?.name && (
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-slate-600">{product.category.name}</span>
)}
{!product.is_active && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-amber-700">Nonaktif</span>
)}
{lowStock && (
<span className="rounded-full bg-red-100 px-2.5 py-1 text-red-600">Low stock</span>
)}
</div>
</div>
<div>
<h4 className="text-lg font-semibold text-slate-900">{product.name}</h4>
<div className="mt-1 text-sm text-slate-500">{product.sku || product.barcode || 'Tanpa SKU'}</div>
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<div>
<div className="text-2xl font-black tracking-tight text-slate-900">{formatCurrency(product.sell_price)}</div>
<div className={`mt-1 text-xs font-medium ${lowStock ? 'text-red-600' : 'text-slate-500'}`}>{stockLabel}</div>
</div>
<BaseButton
color="info"
label={cartItem ? `Tambah lagi (${cartItem.quantity})` : 'Tambah'}
disabled={Boolean(product.track_stock && Number(product.stockOnHand || 0) <= Number(cartItem?.quantity || 0))}
onClick={() => addProductToCart(product)}
/>
</div>
</div>
);
})}
</div>
)}
</CardBox>
<CardBox className="border border-slate-200/70 bg-white/90 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">Receipt center</div>
<h3 className="mt-2 text-2xl font-bold text-slate-900">Transaksi terbaru dari toko ini</h3>
</div>
<div className="text-sm text-slate-500">Klik salah satu struk untuk melihat detail dan print ulang.</div>
</div>
<BaseDivider />
{context.recentSales.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-6 py-12 text-center text-sm text-slate-500">
Belum ada transaksi hari ini. Checkout pertama akan langsung muncul di sini.
</div>
) : (
<div className="grid gap-3">
{context.recentSales.map((sale) => (
<button
key={sale.id}
type="button"
className={`rounded-2xl border px-4 py-4 text-left transition ${selectedReceipt?.id === sale.id ? 'border-sky-500 bg-sky-50 shadow-sm' : 'border-slate-200 bg-white hover:border-sky-200 hover:bg-slate-50'}`}
onClick={() => loadReceiptDetail(sale.id)}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{sale.receipt_number}</div>
<div className="mt-1 text-lg font-semibold text-slate-900">{sale.customer?.name || 'Transaksi umum'}</div>
<div className="mt-1 text-sm text-slate-500">
{formatDateTime(sale.sold_at)} {sale.quantityTotal || sale.itemCount} item {getCashierName(sale.cashier)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-lg font-bold text-slate-900">{formatCurrency(sale.total_amount)}</div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-emerald-600">{sale.payment_status}</div>
</div>
{loadingReceiptId === sale.id && <span className="text-xs text-slate-400">Memuat</span>}
</div>
</div>
</button>
))}
</div>
)}
</CardBox>
</div>
<div className="space-y-6">
<CardBox className="sticky top-6 border border-slate-200/70 bg-white/95 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">Keranjang</div>
<h3 className="mt-2 text-2xl font-bold text-slate-900">Checkout aktif</h3>
</div>
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-right">
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">Item</div>
<div className="text-lg font-bold text-slate-900">{cartSummary.quantity}</div>
</div>
</div>
<BaseDivider />
<div className="space-y-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Pelanggan</span>
<select
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-4 focus:ring-sky-100"
value={selectedCustomerId}
onChange={(event) => setSelectedCustomerId(event.target.value)}
>
<option value="">Umum / walk-in</option>
{context.customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}{customer.phone ? `${customer.phone}` : ''}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Metode bayar</span>
<select
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-4 focus:ring-sky-100"
value={paymentMethod}
onChange={(event) => setPaymentMethod(event.target.value)}
>
{Object.entries(paymentMethodLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<BaseDivider />
{cart.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-5 py-12 text-center text-sm text-slate-500">
Produk yang dipilih akan muncul di sini. Gunakan pencarian di kiri untuk transaksi yang lebih cepat.
</div>
) : (
<div className="space-y-3">
{cart.map((item) => (
<div key={item.productId} className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-slate-900">{item.name}</div>
<div className="mt-1 text-xs text-slate-500">{item.sku || 'Tanpa SKU'} {formatCurrency(item.unitPrice)}</div>
</div>
<button
type="button"
className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400 hover:text-red-500"
onClick={() => removeCartItem(item.productId)}
>
Hapus
</button>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="inline-flex items-center rounded-2xl border border-slate-200 bg-white">
<button
type="button"
className="px-3 py-2 text-lg font-semibold text-slate-500 hover:text-slate-900"
onClick={() => updateCartQuantity(item.productId, item.quantity - 1)}
>
</button>
<input
className="w-14 border-x border-slate-200 bg-white px-2 py-2 text-center text-sm font-semibold text-slate-900 focus:outline-none"
value={item.quantity}
inputMode="numeric"
onChange={(event) => updateCartQuantity(item.productId, Number(event.target.value || 1))}
/>
<button
type="button"
className="px-3 py-2 text-lg font-semibold text-slate-500 hover:text-slate-900"
onClick={() => updateCartQuantity(item.productId, item.quantity + 1)}
>
+
</button>
</div>
<div className="text-right">
<div className="text-sm text-slate-500">Subtotal baris</div>
<div className="text-lg font-bold text-slate-900">
{formatCurrency(item.quantity * item.unitPrice * (1 + item.taxRate / 100))}
</div>
</div>
</div>
{item.trackStock && item.stockOnHand !== null && (
<div className="mt-3 text-xs font-medium text-slate-500">Stok tersedia: {item.stockOnHand}</div>
)}
</div>
))}
</div>
)}
<BaseDivider />
<div className="space-y-3 rounded-3xl bg-slate-950 p-5 text-white shadow-inner">
<div className="flex items-center justify-between text-sm text-slate-300">
<span>Subtotal</span>
<span>{formatCurrency(cartSummary.subtotal)}</span>
</div>
<div className="flex items-center justify-between text-sm text-slate-300">
<span>Pajak</span>
<span>{formatCurrency(cartSummary.tax)}</span>
</div>
<div className="flex items-center justify-between border-t border-white/10 pt-3 text-xl font-black">
<span>Total</span>
<span>{formatCurrency(cartSummary.total)}</span>
</div>
</div>
<div className="mt-4 space-y-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Nominal bayar</span>
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<input
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-4 focus:ring-sky-100"
value={paidAmount}
inputMode="decimal"
onChange={(event) => setPaidAmount(event.target.value)}
/>
<BaseButton
color="whiteDark"
label="Bayar pas"
className="justify-center"
onClick={() => setPaidAmount(String(Number(cartSummary.total.toFixed(2))))}
/>
</div>
{Number(paidAmount || 0) < cartSummary.total && cart.length > 0 && (
<div className="mt-2 text-xs font-medium text-red-600">Nominal bayar masih kurang.</div>
)}
{Number(paidAmount || 0) >= cartSummary.total && cart.length > 0 && (
<div className="mt-2 text-xs font-medium text-emerald-600">
Estimasi kembalian: {formatCurrency(Number(paidAmount || 0) - cartSummary.total)}
</div>
)}
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Catatan transaksi</span>
<textarea
className="min-h-[92px] w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-4 focus:ring-sky-100"
placeholder="Contoh: pelanggan titip, promo toko, catatan kasir"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</label>
<label className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={printReceipt} onChange={(event) => setPrintReceipt(event.target.checked)} />
Buka print struk otomatis setelah checkout
</label>
</div>
<BaseButtons className="mt-6" type="justify-between">
<BaseButton color="whiteDark" label="Kosongkan" onClick={() => setCart([])} disabled={!cart.length || submitting} />
<BaseButton color="info" label={submitting ? 'Menyimpan...' : 'Simpan & cetak'} onClick={handleCheckout} disabled={submitting || !cart.length} />
</BaseButtons>
</CardBox>
<CardBox className="border border-slate-200/70 bg-white/95 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-600">Detail struk</div>
<h3 className="mt-2 text-2xl font-bold text-slate-900">Preview receipt</h3>
</div>
{selectedReceipt && (
<BaseButton color="whiteDark" label="Print ulang" onClick={() => openPrintWindow(selectedReceipt)} />
)}
</div>
<BaseDivider />
{!selectedReceipt ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-5 py-12 text-center text-sm text-slate-500">
Pilih transaksi terbaru atau selesaikan checkout untuk melihat receipt detail di sini.
</div>
) : (
<div className="space-y-4">
<div className="rounded-3xl bg-gradient-to-br from-slate-950 via-slate-900 to-sky-900 p-5 text-white shadow-lg">
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-200">{selectedReceipt.receipt_number}</div>
<div className="mt-2 text-2xl font-black">{formatCurrency(Number(selectedReceipt.total_amount || 0))}</div>
<div className="mt-2 text-sm text-slate-200">
{formatDateTime(selectedReceipt.sold_at)} {selectedReceipt.customer?.name || 'Transaksi umum'}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kasir</div>
<div className="mt-2 font-semibold text-slate-900">{getCashierName(selectedReceipt.cashier)}</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Metode</div>
<div className="mt-2 font-semibold text-slate-900">
{paymentMethodLabels[selectedReceipt.payments_sale?.[0]?.method || 'cash'] || selectedReceipt.payments_sale?.[0]?.method || '-'}
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200">
{(selectedReceipt.sale_items_sale || []).map((item, index) => (
<div key={item.id} className={`flex items-center justify-between gap-4 px-4 py-3 ${index !== 0 ? 'border-t border-slate-200' : ''}`}>
<div>
<div className="font-semibold text-slate-900">{item.product_name_snapshot}</div>
<div className="text-xs text-slate-500">{item.quantity} x {formatCurrency(Number(item.unit_price || 0))}</div>
</div>
<div className="font-semibold text-slate-900">{formatCurrency(Number(item.line_total || 0))}</div>
</div>
))}
</div>
<div className="space-y-2 rounded-2xl bg-slate-50 p-4 text-sm">
<div className="flex items-center justify-between text-slate-600"><span>Subtotal</span><span>{formatCurrency(Number(selectedReceipt.subtotal_amount || 0))}</span></div>
<div className="flex items-center justify-between text-slate-600"><span>Pajak</span><span>{formatCurrency(Number(selectedReceipt.tax_amount || 0))}</span></div>
<div className="flex items-center justify-between border-t border-slate-200 pt-2 text-base font-bold text-slate-900"><span>Total</span><span>{formatCurrency(Number(selectedReceipt.total_amount || 0))}</span></div>
<div className="flex items-center justify-between text-slate-600"><span>Bayar</span><span>{formatCurrency(Number(selectedReceipt.paid_amount || 0))}</span></div>
<div className="flex items-center justify-between text-slate-600"><span>Kembalian</span><span>{formatCurrency(Number(selectedReceipt.change_amount || 0))}</span></div>
</div>
{selectedReceipt.notes && (
<div className="rounded-2xl border border-slate-200 bg-white p-4 text-sm text-slate-600">
<div className="mb-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Catatan</div>
{selectedReceipt.notes}
</div>
)}
</div>
)}
</CardBox>
</div>
</div>
)}
</>
)}
</SectionMain>
</>
);
};
PosCheckoutPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default PosCheckoutPage;