1103 lines
50 KiB
TypeScript
1103 lines
50 KiB
TypeScript
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;
|