ifixkart-admin/app/(admin)/orders/page.tsx

1411 lines
57 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
Plus,
RefreshCw,
Search,
Eye,
CheckCircle2,
Trash2,
ShoppingCart,
Barcode,
Printer,
FileText,
User,
MapPin,
} from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
import { apiFetch } from '@/services/api/client';
import { SlideOver } from '@/components/ui/SlideOver';
import { CustomSelect } from '@/components/ui/CustomSelect';
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
interface OrderItemDetail {
product_id: string;
variant_id: string;
product_name: string;
sku: string;
unit_price: number;
quantity: number;
total_price: number;
}
interface StatusHistorySchema {
previous_status: string | null;
new_status: string;
changed_by: string;
reason: string | null;
created_at: string;
}
interface AdminOrderDetailResponse {
order_id: string;
order_no: string;
customer_id: string | null;
customer_email: string | null;
customer_name: string | null;
total_amount: number;
discount_amount: number;
tax_amount: number;
shipping_cost: number;
final_amount: number;
status: string;
payment_status: string;
fulfillment_status: string;
shipping_address_json: string | null;
billing_address_json: string | null;
tracking_number: string | null;
courier_name: string | null;
created_at: string;
items: OrderItemDetail[];
history: StatusHistorySchema[];
}
function renderFormattedAddress(addressJsonStr: string | null | undefined): ReactNode {
if (!addressJsonStr) return null;
const str = String(addressJsonStr).trim();
if (str.startsWith('{') && str.endsWith('}')) {
try {
const parsed = JSON.parse(str);
if (parsed && typeof parsed === 'object') {
const { full_name, phone, street_address, address_line1, address_line2, city, state, pincode, postal_code, country, address_type } = parsed;
const street = street_address || address_line1 || address_line2 || '';
const zip = pincode || postal_code || '';
const name = full_name || '';
const location = [street, city, state, zip, country].filter(Boolean).join(', ');
return (
<div className="space-y-0.5 text-[12px] leading-snug text-foreground">
{name && <p className="font-semibold text-foreground">{name}</p>}
{phone && <p className="text-muted-foreground text-[11px]">Phone: {phone}</p>}
{location && <p className="text-muted-foreground">{location}</p>}
{address_type && (
<span className="inline-block mt-1 px-1.5 py-0.5 text-[9px] font-semibold uppercase bg-muted text-muted-foreground crm-radius-badge">
{address_type}
</span>
)}
</div>
);
}
} catch {
// fallback
}
}
return <p className="text-[12px] text-muted-foreground whitespace-pre-wrap">{str}</p>;
}
interface AdminOrderListResponse {
order_id: string;
order_no: string;
customer_email: string | null;
customer_name?: string | null;
customer_phone?: string | null;
final_amount: number;
status: string;
payment_status: string;
created_at: string;
}
type StatusFilter = 'all' | 'open' | 'shipped' | 'delivered' | 'cancelled';
function formatOrderDate(iso: string) {
const normalized = iso.endsWith('Z') || iso.includes('+') ? iso : `${iso}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return iso;
return format(date, 'dd-MMM-yyyy HH:mm');
}
function statusKey(status?: string) {
return (status || '').toUpperCase();
}
function isOpenStatus(status?: string) {
const s = statusKey(status);
return s === 'PENDING' || s === 'CONFIRMED' || s === 'PROCESSING';
}
function isShippedStatus(status?: string) {
const s = statusKey(status);
return s === 'SHIPPED' || s === 'OUT_FOR_DELIVERY';
}
function isDeliveredStatus(status?: string) {
return statusKey(status) === 'DELIVERED';
}
function isCancelledStatus(status?: string) {
return statusKey(status) === 'CANCELLED';
}
function statusBadgeClass(status?: string) {
if (isDeliveredStatus(status)) return 'bg-success text-white';
if (isCancelledStatus(status)) return 'bg-destructive text-white';
if (isShippedStatus(status)) return 'bg-primary text-white';
return 'bg-warning text-white';
}
export default function OrderIntelligencePage() {
const [orders, setOrders] = useState<AdminOrderListResponse[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null);
const [detailData, setDetailData] = useState<AdminOrderDetailResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [barcodeInput, setBarcodeInput] = useState('');
const [verifiedItems, setVerifiedItems] = useState<string[]>([]);
const [showStatusForm, setShowStatusForm] = useState(false);
const [newStatus, setNewStatus] = useState('PROCESSING');
const [trackingNo, setTrackingNo] = useState('');
const [courier, setCourier] = useState('');
const [changeReason, setChangeReason] = useState('');
const [updating, setUpdating] = useState(false);
const [showCreateOrderModal, setShowCreateOrderModal] = useState(false);
const [buyerName, setBuyerName] = useState('');
const [buyerEmail, setBuyerEmail] = useState('');
const [buyerPhone, setBuyerPhone] = useState('');
const [shippingAddress, setShippingAddress] = useState('');
const [productsList, setProductsList] = useState<any[]>([]);
const [categoriesList, setCategoriesList] = useState<any[]>([]);
const [brandsList, setBrandsList] = useState<any[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState('');
const [selectedBrandId, setSelectedBrandId] = useState('');
const [selectedProductId, setSelectedProductId] = useState('');
const [skuOptions, setSkuOptions] = useState<any[]>([]);
const [skuLoading, setSkuLoading] = useState(false);
const [tempSkuId, setTempSkuId] = useState('');
const [tempQty, setTempQty] = useState(1);
const [tempPrice, setTempPrice] = useState(0);
const [selectedItems, setSelectedItems] = useState<any[]>([]);
const [paymentStatus, setPaymentStatus] = useState('PAID');
const [paymentMethod, setPaymentMethod] = useState('CASH');
const [digitalMethod, setDigitalMethod] = useState<'UPI' | 'CARD'>('UPI');
const [cashPaidAmount, setCashPaidAmount] = useState<number>(0);
const [posSuccessData, setPosSuccessData] = useState<any | null>(null);
const effectiveItems = useMemo(() => {
if (selectedItems.length > 0) return selectedItems;
if (selectedProductId && tempSkuId) {
const itemOpt = skuOptions.find((opt) => opt.variant_id === tempSkuId);
if (itemOpt) {
const prodObj = productsList.find((p) => p.product_id === selectedProductId);
return [
{
variant_id: itemOpt.variant_id,
product_name: prodObj?.name || itemOpt.product_name || 'Product item',
sku: itemOpt.sku,
quantity: Number(tempQty || 1),
unit_price: Number(tempPrice || itemOpt.price || 0),
},
];
}
}
return [];
}, [selectedItems, selectedProductId, tempSkuId, skuOptions, tempQty, tempPrice, productsList]);
const calculateOrderTotalWithTax = useMemo(() => {
const subtotal = effectiveItems.reduce((acc, item) => acc + item.quantity * item.unit_price, 0);
const tax = (subtotal * 18) / 100;
return subtotal + tax;
}, [effectiveItems]);
const calculatedDigitalAmount = useMemo(() => {
if (paymentMethod === 'MIXED_PAYMENT') {
return Math.max(0, calculateOrderTotalWithTax - Number(cashPaidAmount || 0));
}
if (paymentMethod === 'UPI' || paymentMethod === 'CARD') {
return calculateOrderTotalWithTax;
}
return 0;
}, [paymentMethod, calculateOrderTotalWithTax, cashPaidAmount]);
const loadRazorpayScript = (): Promise<boolean> => {
return new Promise((resolve) => {
if ((window as any).Razorpay) {
resolve(true);
return;
}
const script = document.createElement('script');
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
script.onload = () => resolve(true);
script.onerror = () => resolve(false);
document.body.appendChild(script);
});
};
const fetchCatalogProductsAndSkus = async () => {
setSkuLoading(true);
try {
const [catRes, skuRes, categoriesRes, brandsRes] = await Promise.all([
apiFetch<any>('/api/v1/catalog/products/all?limit=200').catch(() => []),
apiFetch<{ items: any[] }>('/api/v1/inventory/skus?limit=150').catch(() => ({ items: [] })),
apiFetch<any>('/api/v1/catalog/categories/all').catch(() => []),
apiFetch<any>('/api/v1/catalog/brands/all').catch(() => []),
]);
const prodsList = Array.isArray(catRes) ? catRes : catRes?.products || [];
setProductsList(prodsList);
const catsList = Array.isArray(categoriesRes) ? categoriesRes : categoriesRes?.items || [];
setCategoriesList(catsList);
const brsList = Array.isArray(brandsRes) ? brandsRes : brandsRes?.items || [];
setBrandsList(brsList);
const itemsMap = new Map<string, any>();
if (skuRes && skuRes.items) {
skuRes.items.forEach((item: any) => {
itemsMap.set(item.variant_id, item);
});
}
prodsList.forEach((prod: any) => {
if (prod.variants && prod.variants.length > 0) {
prod.variants.forEach((v: any) => {
if (!itemsMap.has(v.variant_id)) {
const attrVals = (v.attributes || []).map((a: any) => a.attribute_value).join(' / ');
itemsMap.set(v.variant_id, {
variant_id: v.variant_id,
product_id: prod.product_id,
product_name: prod.name,
variant_name: attrVals || v.sku,
sku: v.sku,
barcode: v.barcode || '',
price: Number(v.price),
available_stock: v.available_stock || 0,
});
}
});
}
});
setSkuOptions(Array.from(itemsMap.values()));
} catch {
toast.error('Failed to load sellable products catalog');
} finally {
setSkuLoading(false);
}
};
const availableCategoriesOptions = useMemo(() => {
if (categoriesList.length > 0) {
return categoriesList.map((c) => ({
value: c.category_id,
label: c.name,
}));
}
const map = new Map<string, string>();
productsList.forEach((p) => {
if (p.category_id) {
map.set(p.category_id, p.category_name || p.category?.name || 'Category');
}
});
return Array.from(map.entries()).map(([id, name]) => ({ value: id, label: name }));
}, [categoriesList, productsList]);
const availableBrandsOptions = useMemo(() => {
const filteredProducts = selectedCategoryId
? productsList.filter((p) => p.category_id === selectedCategoryId)
: productsList;
const brandIdsInFilteredProducts = new Set(filteredProducts.map((p) => p.brand_id).filter(Boolean));
if (brandsList.length > 0) {
return brandsList
.filter((b) => !selectedCategoryId || brandIdsInFilteredProducts.has(b.brand_id))
.map((b) => ({
value: b.brand_id,
label: b.name,
}));
}
const map = new Map<string, string>();
filteredProducts.forEach((p) => {
if (p.brand_id) {
map.set(p.brand_id, p.brand_name || p.brand?.name || 'Brand');
}
});
return Array.from(map.entries()).map(([id, name]) => ({ value: id, label: name }));
}, [brandsList, productsList, selectedCategoryId]);
const availableProductsOptions = useMemo(() => {
let filtered = productsList;
if (selectedCategoryId) {
filtered = filtered.filter((p) => p.category_id === selectedCategoryId);
}
if (selectedBrandId) {
filtered = filtered.filter((p) => p.brand_id === selectedBrandId);
}
return filtered.map((p) => ({
value: p.product_id,
label: p.name,
}));
}, [productsList, selectedCategoryId, selectedBrandId]);
const availableVariantsForProduct = useMemo(() => {
if (!selectedProductId) return [];
return skuOptions.filter((opt) => opt.product_id === selectedProductId);
}, [skuOptions, selectedProductId]);
const handleAddOrderItem = () => {
if (!selectedProductId) {
toast.warning('Select a product first');
return;
}
if (!tempSkuId) {
toast.warning('Select a product variant');
return;
}
const itemOpt = skuOptions.find((opt) => opt.variant_id === tempSkuId);
if (!itemOpt) return;
const existing = selectedItems.find((item) => item.variant_id === tempSkuId);
if (existing) {
toast.error('This variant is already on the order. Change the quantity instead.');
return;
}
const prodObj = productsList.find((p) => p.product_id === selectedProductId);
const prodName = prodObj?.name || itemOpt.product_name || 'Product item';
setSelectedItems([
...selectedItems,
{
variant_id: itemOpt.variant_id,
product_name: prodName,
sku: itemOpt.sku,
quantity: Number(tempQty),
unit_price: Number(tempPrice),
available_stock: itemOpt.available_stock,
},
]);
setTempSkuId('');
setTempQty(1);
setTempPrice(0);
};
const handleCreateOrderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let finalOrderItems = [...selectedItems];
if (finalOrderItems.length === 0) {
if (selectedProductId && tempSkuId) {
const itemOpt = skuOptions.find((opt) => opt.variant_id === tempSkuId);
if (itemOpt) {
const prodObj = productsList.find((p) => p.product_id === selectedProductId);
finalOrderItems = [
{
variant_id: itemOpt.variant_id,
product_name: prodObj?.name || itemOpt.product_name || 'Product item',
sku: itemOpt.sku,
quantity: Number(tempQty || 1),
unit_price: Number(tempPrice || itemOpt.price || 0),
available_stock: itemOpt.available_stock,
},
];
setSelectedItems(finalOrderItems);
}
}
}
if (finalOrderItems.length === 0) {
toast.error('Select a product and variant or click "+ Add item"');
return;
}
setUpdating(true);
try {
const digitalAmt = calculatedDigitalAmount;
const requiresDigitalRzp = (paymentMethod === 'UPI' || paymentMethod === 'CARD' || paymentMethod === 'MIXED_PAYMENT') && digitalAmt > 0;
if (requiresDigitalRzp) {
const loaded = await loadRazorpayScript();
if (!loaded) {
toast.error('Failed to load Razorpay checkout SDK');
setUpdating(false);
return;
}
// 1. Create Razorpay POS Order
const rzpOrderRes = await apiFetch<any>('/api/v1/admin/orders/create-pos-razorpay-order', {
method: 'POST',
body: JSON.stringify({ amount: digitalAmt }),
});
// 2. Launch Razorpay Modal
const options = {
key: rzpOrderRes.key_id,
amount: rzpOrderRes.amount_paise,
currency: 'INR',
name: 'iFixKart Walk-in Counter',
description: `POS Store Order (${paymentMethod})`,
order_id: rzpOrderRes.razorpay_order_id,
prefill: {
name: buyerName || 'Walk-in Guest',
email: buyerEmail || 'guest@store.local',
contact: buyerPhone || '',
},
handler: async function (response: any) {
try {
const res = await apiFetch<any>('/api/v1/admin/orders/create', {
method: 'POST',
body: JSON.stringify({
customer_name: buyerName || undefined,
customer_email: buyerEmail || undefined,
customer_phone: buyerPhone || undefined,
items: finalOrderItems.map((item) => ({
variant_id: item.variant_id,
quantity: item.quantity,
unit_price: item.unit_price,
})),
shipping_address: shippingAddress || undefined,
payment_status: 'PAID',
payment_method: paymentMethod,
cash_amount: paymentMethod === 'MIXED_PAYMENT' ? Number(cashPaidAmount || 0) : 0,
digital_amount: digitalAmt,
digital_method: paymentMethod === 'MIXED_PAYMENT' ? digitalMethod : paymentMethod,
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
}),
});
toast.success('Walk-in store order created with Razorpay payment');
setPosSuccessData(res);
fetchOrders();
} catch (err: any) {
toast.error(err.message || 'Failed to submit order after digital payment');
} finally {
setUpdating(false);
}
},
modal: {
ondismiss: function () {
toast.info('Razorpay payment cancelled by cashier/customer');
setUpdating(false);
},
},
theme: { color: '#0f172a' },
};
const rzp = new (window as any).Razorpay(options);
rzp.open();
return;
}
// Pure Cash Order
const res = await apiFetch<any>('/api/v1/admin/orders/create', {
method: 'POST',
body: JSON.stringify({
customer_name: buyerName || undefined,
customer_email: buyerEmail || undefined,
customer_phone: buyerPhone || undefined,
items: finalOrderItems.map((item) => ({
variant_id: item.variant_id,
quantity: item.quantity,
unit_price: item.unit_price,
})),
shipping_address: shippingAddress || undefined,
payment_status: paymentStatus,
payment_method: paymentMethod,
cash_amount: calculateOrderTotalWithTax,
digital_amount: 0,
}),
});
toast.success('Walk-in store order created');
setPosSuccessData(res);
fetchOrders();
} catch (err: any) {
toast.error(err.message || 'Failed to create offline store order');
} finally {
setUpdating(false);
}
};
const clearCreateFormFields = () => {
setBuyerName('');
setBuyerEmail('');
setBuyerPhone('');
setShippingAddress('');
setSelectedCategoryId('');
setSelectedBrandId('');
setSelectedProductId('');
setTempSkuId('');
setTempQty(1);
setTempPrice(0);
setSelectedItems([]);
setPaymentStatus('PAID');
setPaymentMethod('CASH');
setDigitalMethod('UPI');
setCashPaidAmount(0);
setPosSuccessData(null);
};
const handleResetCreateOrderForm = () => {
if (updating) return;
clearCreateFormFields();
setShowCreateOrderModal(false);
};
const fetchOrders = async () => {
setLoading(true);
try {
const data = await apiFetch<AdminOrderListResponse[]>('/api/v1/admin/orders');
setOrders(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch customer orders');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOrders();
fetchCatalogProductsAndSkus();
}, []);
const handleInspectOrder = async (id: string) => {
setSelectedOrderId(id);
setDetailLoading(true);
setDetailData(null);
setVerifiedItems([]);
setBarcodeInput('');
setShowStatusForm(false);
try {
const data = await apiFetch<AdminOrderDetailResponse>(`/api/v1/admin/orders/${id}`);
setDetailData(data);
} catch (err: any) {
toast.error(err.message || 'Failed to load order detailed view');
setSelectedOrderId(null);
} finally {
setDetailLoading(false);
}
};
const closeDetails = () => {
if (updating) return;
setSelectedOrderId(null);
setDetailData(null);
setShowStatusForm(false);
};
const handleOpenStatusForm = () => {
if (!detailData) return;
setNewStatus(detailData.status);
setTrackingNo(detailData.tracking_number || '');
setCourier(detailData.courier_name || '');
setChangeReason('');
setShowStatusForm(true);
};
const handleStatusSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!detailData) return;
setUpdating(true);
try {
await apiFetch(`/api/v1/admin/orders/${detailData.order_id}/status`, {
method: 'PUT',
body: JSON.stringify({
status: newStatus,
tracking_number: trackingNo || null,
courier_name: courier || null,
reason: changeReason || null,
}),
});
toast.success('Order status saved');
setShowStatusForm(false);
handleInspectOrder(detailData.order_id);
fetchOrders();
} catch (err: any) {
toast.error(err.message || 'Failed to update order status');
} finally {
setUpdating(false);
}
};
const handleBarcodeSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!detailData || !barcodeInput.trim()) return;
const matchedItem = detailData.items.find(
(item) => item.sku.toLowerCase() === barcodeInput.trim().toLowerCase()
);
if (matchedItem) {
if (!verifiedItems.includes(matchedItem.sku)) {
setVerifiedItems([...verifiedItems, matchedItem.sku]);
toast.success(`Verified: ${matchedItem.product_name} (${matchedItem.sku})`);
} else {
toast.info(`Already verified: ${matchedItem.sku}`);
}
} else {
toast.error(`SKU "${barcodeInput}" is not on this order`);
}
setBarcodeInput('');
};
const openCount = orders.filter((o) => isOpenStatus(o.status)).length;
const shippedCount = orders.filter((o) => isShippedStatus(o.status)).length;
const deliveredCount = orders.filter((o) => isDeliveredStatus(o.status)).length;
const cancelledCount = orders.filter((o) => isCancelledStatus(o.status)).length;
const filteredOrders = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return orders.filter((o) => {
const statusMatch =
statusFilter === 'all' ||
(statusFilter === 'open' && isOpenStatus(o.status)) ||
(statusFilter === 'shipped' && isShippedStatus(o.status)) ||
(statusFilter === 'delivered' && isDeliveredStatus(o.status)) ||
(statusFilter === 'cancelled' && isCancelledStatus(o.status));
const searchMatch =
!q ||
o.order_no.toLowerCase().includes(q) ||
(o.customer_email && o.customer_email.toLowerCase().includes(q)) ||
(o.customer_name && o.customer_name.toLowerCase().includes(q)) ||
(o.customer_phone && o.customer_phone.toLowerCase().includes(q)) ||
o.status.toLowerCase().includes(q);
return statusMatch && searchMatch;
});
}, [orders, searchQuery, statusFilter]);
const pager = useClientPagination(filteredOrders);
const dataCardShell =
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
const inputClass =
'w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary';
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
const primaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
const secondaryButton =
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50';
const renderStatusBadge = (status: string) => (
<span
className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${statusBadgeClass(status)}`}
>
{(status || 'Draft').replace('_', ' ')}
</span>
);
const backendUrl = process.env.NEXT_PUBLIC_API_URL || '';
return (
<div className="flex flex-col gap-5 min-w-0">
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-foreground">Customer Store Orders</h1>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
{orders.length}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={fetchOrders}
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
title="Reload"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => {
fetchCatalogProductsAndSkus();
clearCreateFormFields();
setShowCreateOrderModal(true);
}}
className={primaryButton}
>
<span className="w-[18px] h-[18px] rounded-full bg-white text-primary flex items-center justify-center">
<Plus className="w-3 h-3" strokeWidth={3} />
</span>
Create order
</button>
</div>
</div>
<div className={dataCardShell}>
<div className="px-5 pt-5">
<div className="-mx-5 px-5 flex flex-wrap items-center justify-between gap-3 min-w-0 pb-4 border-b border-border">
<div className="relative flex-1 min-w-[180px] max-w-[480px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="text"
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1 shrink-0 overflow-x-auto max-w-full">
{(
[
{ id: 'all', label: `All (${orders.length})` },
{ id: 'open', label: `Open (${openCount})` },
{ id: 'shipped', label: `Shipped (${shippedCount})` },
{ id: 'delivered', label: `Delivered (${deliveredCount})` },
{ id: 'cancelled', label: `Cancelled (${cancelledCount})` },
] as { id: StatusFilter; label: string }[]
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setStatusFilter(tab.id)}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap ${
statusFilter === tab.id ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<span className="text-[13px] text-muted-foreground">Loading orders...</span>
</div>
) : (
<div className="overflow-x-auto">
<table className="crm-data-table min-w-[860px]">
<thead>
<tr className="bg-gray-50 border-y border-gray-200">
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Order no.</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Customer</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Total</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Date</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Status</th>
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">Action</th>
</tr>
</thead>
<tbody className="bg-card">
{filteredOrders.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-[13px] text-muted-foreground">
{searchQuery || statusFilter !== 'all' ? 'No orders match your search.' : 'No orders found.'}
</td>
</tr>
) : (
pager.items.map((order) => (
<tr
key={order.order_id}
className={`border-t border-border hover:bg-muted/10 cursor-pointer ${
selectedOrderId === order.order_id ? 'bg-muted/20' : ''
}`}
onClick={() => handleInspectOrder(order.order_id)}
>
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground font-mono">{order.order_no}</td>
<td className="px-5 py-3.5">
<p className="text-[13px] font-medium text-foreground">{order.customer_name || 'Walk-in guest'}</p>
<p className="text-[12px] text-muted-foreground mt-0.5">
{order.customer_phone && order.customer_phone !== 'N/A'
? order.customer_phone
: order.customer_email && order.customer_email !== 'N/A'
? order.customer_email
: '—'}
</p>
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{order.final_amount.toFixed(2)}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{formatOrderDate(order.created_at)}</td>
<td className="px-5 py-3.5">{renderStatusBadge(order.status)}</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleInspectOrder(order.order_id);
}}
className="w-8 h-8 crm-radius-control border border-border text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer flex items-center justify-center"
aria-label="View order"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<TablePagination {...pager} />
</div>
<SlideOver
open={!!selectedOrderId}
onClose={closeDetails}
title={detailData ? `Order ${detailData.order_no}` : 'Order details'}
icon={<ShoppingCart className="w-4 h-4 text-primary shrink-0" />}
>
{detailLoading ? (
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
<span className="text-[13px] text-muted-foreground">Loading order...</span>
</div>
) : !detailData ? (
<div className="px-5 py-12 text-center text-[13px] text-muted-foreground">Failed to load order details.</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[12px] text-muted-foreground">{formatOrderDate(detailData.created_at)}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{renderStatusBadge(detailData.status)}
<span
className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${
detailData.payment_status === 'PAID' || detailData.payment_status === 'PAYMENT_CAPTURED'
? 'bg-success text-white'
: 'bg-warning text-white'
}`}
>
{(detailData.payment_status || 'UNPAID').replace('_', ' ')}
</span>
</div>
</div>
{!showStatusForm && (
<button type="button" onClick={handleOpenStatusForm} className={primaryButton}>
Update status
</button>
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
<User className="w-4 h-4 text-primary" />
{detailData.customer_name || 'Walk-in guest'}
</div>
{detailData.customer_email && (
<p className="text-[13px] text-muted-foreground pl-6">{detailData.customer_email}</p>
)}
{detailData.shipping_address_json && (
<div className="flex items-start gap-2 text-[13px] text-muted-foreground">
<MapPin className="w-4 h-4 text-primary shrink-0 mt-0.5" />
<div>
<p className="text-[11px] font-semibold uppercase text-muted-foreground mb-0.5">Shipping address</p>
{renderFormattedAddress(detailData.shipping_address_json)}
</div>
</div>
)}
</div>
<div className="p-3 border border-border crm-radius-card bg-muted/10 space-y-1.5 text-[13px]">
<div className="flex justify-between text-muted-foreground">
<span>Subtotal</span>
<span>{detailData.total_amount.toFixed(2)}</span>
</div>
{detailData.discount_amount > 0 && (
<div className="flex justify-between text-success">
<span>Discount</span>
<span>-{detailData.discount_amount.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-muted-foreground">
<span>GST</span>
<span>{detailData.tax_amount.toFixed(2)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Shipping</span>
<span>{detailData.shipping_cost.toFixed(2)}</span>
</div>
<div className="flex justify-between font-semibold text-foreground border-t border-border pt-1.5">
<span>Final amount</span>
<span>{detailData.final_amount.toFixed(2)}</span>
</div>
</div>
{showStatusForm && (
<form onSubmit={handleStatusSubmit} className="p-3 border border-border crm-radius-card space-y-3">
<p className="text-[13px] font-semibold text-foreground">Update status</p>
<div>
<label className={labelClass}>Status</label>
<CustomSelect
value={newStatus}
onChange={setNewStatus}
options={[
{ value: 'PENDING', label: 'Pending' },
{ value: 'CONFIRMED', label: 'Confirmed' },
{ value: 'PROCESSING', label: 'Processing' },
{ value: 'SHIPPED', label: 'Shipped' },
{ value: 'OUT_FOR_DELIVERY', label: 'Out for delivery' },
{ value: 'DELIVERED', label: 'Delivered' },
{ value: 'CANCELLED', label: 'Cancelled' },
]}
/>
</div>
{newStatus === 'SHIPPED' && (
<>
<div>
<label className={labelClass}>Courier</label>
<input
type="text"
placeholder="e.g. BlueDart / Delhivery"
value={courier}
onChange={(e) => setCourier(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Tracking number</label>
<input
type="text"
placeholder="AWB number"
value={trackingNo}
onChange={(e) => setTrackingNo(e.target.value)}
className={inputClass}
/>
</div>
</>
)}
<div>
<label className={labelClass}>Reason</label>
<textarea
placeholder="Optional note"
value={changeReason}
onChange={(e) => setChangeReason(e.target.value)}
className={`${inputClass} h-20 py-2 resize-none`}
rows={2}
/>
</div>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setShowStatusForm(false)}
disabled={updating}
className={secondaryButton}
>
Cancel
</button>
<button type="submit" disabled={updating} className={primaryButton}>
{updating ? 'Saving...' : 'Save status'}
</button>
</div>
</form>
)}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">
Items ({detailData.items.length})
</p>
<span className="text-[12px] text-muted-foreground">
Verified {verifiedItems.length} / {detailData.items.length}
</span>
</div>
<div className="space-y-2">
{detailData.items.map((item, idx) => {
const isVerified = verifiedItems.includes(item.sku);
return (
<div
key={`${item.sku}-${idx}`}
className="flex items-start justify-between gap-3 p-3 border border-border crm-radius-card"
>
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">{item.product_name}</p>
<p className="text-[12px] text-muted-foreground font-mono mt-0.5">
{item.sku} · Qty {item.quantity}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-[13px] font-medium text-foreground">{item.total_price.toFixed(2)}</p>
{isVerified && (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-success mt-1">
<CheckCircle2 className="w-3 h-3" />
Verified
</span>
)}
</div>
</div>
);
})}
</div>
</div>
<form onSubmit={handleBarcodeSubmit} className="space-y-2">
<label className={labelClass}>
<span className="inline-flex items-center gap-1.5">
<Barcode className="w-3.5 h-3.5" />
Verify barcode / SKU
</span>
</label>
<div className="flex gap-2">
<input
type="text"
placeholder="Scan or type SKU"
value={barcodeInput}
onChange={(e) => setBarcodeInput(e.target.value)}
className={inputClass}
/>
<button type="submit" className={secondaryButton}>
Verify
</button>
</div>
</form>
</div>
</div>
)}
</SlideOver>
<SlideOver
open={showCreateOrderModal}
onClose={handleResetCreateOrderForm}
title={posSuccessData ? 'Order created' : 'Create store order'}
icon={<ShoppingCart className="w-4 h-4 text-primary shrink-0" />}
>
{posSuccessData ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6 space-y-4">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="w-5 h-5" />
<p className="text-[15px] font-semibold text-foreground">Order and GST invoice created</p>
</div>
<div className="text-[13px] text-muted-foreground space-y-1">
<p>
Order: <span className="font-semibold text-foreground font-mono">{posSuccessData.order_no}</span>
</p>
<p>
Invoice: <span className="font-semibold text-foreground">{posSuccessData.invoice_no}</span>
</p>
<p>
Customer: {posSuccessData.customer_name || 'Walk-in guest'} ·
{Number(posSuccessData.final_amount).toFixed(2)}
</p>
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex flex-wrap items-center justify-end gap-2 bg-card">
<button
type="button"
onClick={() => window.open(`${backendUrl}/api/v1/admin/invoices/${posSuccessData.invoice_id}/download`, '_blank')}
className={secondaryButton}
>
<FileText className="w-3.5 h-3.5" />
View invoice
</button>
<button
type="button"
onClick={() =>
window.open(`${backendUrl}/api/v1/admin/invoices/${posSuccessData.invoice_id}/thermal-download`, '_blank')
}
className={secondaryButton}
>
<Printer className="w-3.5 h-3.5" />
Print receipt
</button>
<button type="button" onClick={clearCreateFormFields} className={primaryButton}>
<Plus className="w-3.5 h-3.5" />
New order
</button>
</div>
</div>
) : (
<form onSubmit={handleCreateOrderSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Customer</p>
<button
type="button"
onClick={() => {
setBuyerName('Walk-in Guest');
setBuyerEmail('guest@store.local');
setBuyerPhone('');
}}
className="text-[12px] font-semibold text-primary cursor-pointer"
>
Fill walk-in guest
</button>
</div>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={buyerName}
onChange={(e) => setBuyerName(e.target.value)}
placeholder="e.g. Rahul Sharma"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Email</label>
<input
type="email"
value={buyerEmail}
onChange={(e) => setBuyerEmail(e.target.value)}
placeholder="e.g. rahul@example.com"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Phone</label>
<input
type="tel"
value={buyerPhone}
onChange={(e) => setBuyerPhone(e.target.value)}
placeholder="e.g. 9876543210"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Store / shipping location</label>
<input
type="text"
value={shippingAddress}
onChange={(e) => setShippingAddress(e.target.value)}
placeholder="e.g. Offline main store counter"
className={inputClass}
/>
</div>
</div>
<div className="space-y-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Items</p>
{skuLoading && <p className="text-[12px] text-muted-foreground">Loading catalog...</p>}
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Sales Category</label>
<CustomSelect
value={selectedCategoryId}
onChange={(value) => {
setSelectedCategoryId(value);
setSelectedBrandId('');
setSelectedProductId('');
setTempSkuId('');
setTempPrice(0);
}}
options={[
{ value: '', label: 'Select Category' },
...availableCategoriesOptions,
]}
placeholder="Select Category"
/>
</div>
<div>
<label className={labelClass}>Product Brand</label>
<CustomSelect
value={selectedBrandId}
disabled={!selectedCategoryId}
onChange={(value) => {
setSelectedBrandId(value);
setSelectedProductId('');
setTempSkuId('');
setTempPrice(0);
}}
options={[
{ value: '', label: !selectedCategoryId ? 'Select Category first' : 'Select Brand' },
...availableBrandsOptions,
]}
placeholder={!selectedCategoryId ? 'Select Category first' : 'Select Brand'}
/>
</div>
</div>
<div>
<label className={labelClass}>Product</label>
<CustomSelect
value={selectedProductId}
disabled={!selectedCategoryId || !selectedBrandId}
onChange={(value) => {
setSelectedProductId(value);
setTempSkuId('');
setTempPrice(0);
}}
options={[
{
value: '',
label: !selectedCategoryId
? 'Select Category first'
: !selectedBrandId
? 'Select Brand first'
: 'Select Product',
},
...availableProductsOptions,
]}
placeholder={
!selectedCategoryId
? 'Select Category first'
: !selectedBrandId
? 'Select Brand first'
: 'Select Product'
}
/>
</div>
<div>
<label className={labelClass}>Variant</label>
<CustomSelect
value={tempSkuId}
disabled={!selectedProductId}
onChange={(value) => {
setTempSkuId(value);
const opt = availableVariantsForProduct.find((o) => o.variant_id === value);
if (opt) setTempPrice(opt.price);
}}
options={[
{ value: '', label: !selectedProductId ? 'Select a product first' : 'Select variant' },
...availableVariantsForProduct.map((opt) => ({
value: opt.variant_id,
label: `${opt.sku} ${opt.variant_name ? `(${opt.variant_name})` : ''} — ₹${opt.price} (Stock: ${opt.available_stock})`,
})),
]}
placeholder={!selectedProductId ? 'Select a product first' : 'Select variant'}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Qty</label>
<input
type="number"
min={1}
value={tempQty}
onChange={(e) => setTempQty(Math.max(1, Number(e.target.value)))}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Unit price ()</label>
<input
type="number"
step="0.01"
value={tempPrice}
onChange={(e) => setTempPrice(Number(e.target.value))}
className={inputClass}
/>
</div>
</div>
<button type="button" onClick={handleAddOrderItem} className={secondaryButton}>
<Plus className="w-3.5 h-3.5" />
Add item
</button>
{selectedItems.length > 0 ? (
<div className="space-y-2">
{selectedItems.map((item, idx) => (
<div key={`${item.variant_id}-${idx}`} className="flex items-start justify-between gap-2 p-3 border border-border crm-radius-card">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-foreground">{item.product_name}</p>
<p className="text-[12px] text-muted-foreground font-mono mt-0.5">
{item.sku} · Qty {item.quantity} · {item.unit_price}
</p>
<p className="text-[12px] text-foreground mt-0.5">
{(item.quantity * item.unit_price).toFixed(2)}
</p>
</div>
<button
type="button"
onClick={() => setSelectedItems((prev) => prev.filter((_, i) => i !== idx))}
className="w-8 h-8 crm-radius-control border border-border text-muted-foreground hover:text-destructive hover:bg-muted cursor-pointer flex items-center justify-center"
aria-label="Remove item"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
) : (
<p className="text-[12px] text-muted-foreground">No items added yet.</p>
)}
</div>
<div className="space-y-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Payment</p>
<div>
<label className={labelClass}>Payment status</label>
<CustomSelect
value={paymentStatus}
onChange={setPaymentStatus}
options={[
{ value: 'PAID', label: 'Paid' },
{ value: 'PENDING', label: 'Pending' },
]}
/>
</div>
<div>
<label className={labelClass}>Payment method</label>
<CustomSelect
value={paymentMethod}
onChange={setPaymentMethod}
options={[
{ value: 'CASH', label: 'Cash' },
{ value: 'UPI', label: 'UPI (Razorpay QR / App)' },
{ value: 'CARD', label: 'Card (Razorpay Online / POS)' },
{ value: 'MIXED_PAYMENT', label: 'Mixed Payment (Cash + Digital)' },
]}
/>
</div>
{paymentMethod === 'MIXED_PAYMENT' && (
<div className="p-3 border border-border crm-radius-card bg-muted/30 space-y-3">
<p className="text-[12px] font-semibold text-foreground">Mixed Payment Breakdown</p>
<div>
<label className={labelClass}>Digital Payment Method</label>
<CustomSelect
value={digitalMethod}
onChange={(val) => setDigitalMethod(val as any)}
options={[
{ value: 'UPI', label: 'UPI' },
{ value: 'CARD', label: 'Card' },
]}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Cash Paid Amount ()</label>
<input
type="number"
min={0}
step="0.01"
value={cashPaidAmount}
onChange={(e) => setCashPaidAmount(Math.max(0, Number(e.target.value)))}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Digital Amount (Razorpay) ()</label>
<div className="h-9 px-3 border border-border crm-radius-control bg-muted flex items-center text-[13px] font-semibold text-foreground font-mono">
{calculatedDigitalAmount.toFixed(2)}
</div>
</div>
</div>
</div>
)}
{(paymentMethod === 'UPI' || paymentMethod === 'CARD' || paymentMethod === 'MIXED_PAYMENT') && calculatedDigitalAmount > 0 && (
<div className="p-3 border border-primary/20 bg-primary/5 crm-radius-card text-[12px] text-primary space-y-1">
<p className="font-semibold">Razorpay Digital Collection</p>
<p className="text-[11px] opacity-90">
Clicking <strong>Create order</strong> will launch the Razorpay Checkout popup for digital amount
{calculatedDigitalAmount.toFixed(2)}.
</p>
</div>
)}
</div>
</div>
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
<button type="button" onClick={handleResetCreateOrderForm} disabled={updating} className={secondaryButton}>
Cancel
</button>
<button type="submit" disabled={updating || (selectedItems.length === 0 && (!selectedProductId || !tempSkuId))} className={primaryButton}>
{updating ? 'Saving...' : 'Create order'}
</button>
</div>
</form>
)}
</SlideOver>
</div>
);
}