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

478 lines
21 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { Plus, RefreshCw, Search, ClipboardCheck, CheckCircle2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { catalogService, PartResponse, PurchaseOrderResponse } from '@/services/api/catalogService';
import { SlideOver } from '@/components/ui/SlideOver';
import { CustomSelect } from '@/components/ui/CustomSelect';
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
type StatusFilter = 'all' | 'ordered' | 'received';
const EMPTY_ITEM = { part_id: '', quantity_ordered: 1, unit_price: '' };
export default function PurchasesPage() {
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrderResponse[]>([]);
const [parts, setParts] = useState<PartResponse[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPOModal, setShowPOModal] = useState(false);
const [supplierName, setSupplierName] = useState('');
const [poItems, setPoItems] = useState<Array<{ part_id: string; quantity_ordered: number; unit_price: string }>>([]);
const [showReceiveModal, setShowReceiveModal] = useState(false);
const [selectedPO, setSelectedPO] = useState<PurchaseOrderResponse | null>(null);
const [receiveQuantities, setReceiveQuantities] = useState<Record<string, number>>({});
const fetchData = async () => {
setLoading(true);
try {
const [fetchedPOs, fetchedParts] = await Promise.all([
catalogService.getPurchaseOrders(),
catalogService.getParts(),
]);
setPurchaseOrders(fetchedPOs);
setParts(fetchedParts);
} catch (err: any) {
toast.error(err?.message || 'Failed to load purchase orders');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleCreatePO = async (e: React.FormEvent) => {
e.preventDefault();
if (!supplierName.trim() || poItems.length === 0) {
toast.error('Supplier name and at least one item are required');
return;
}
const payloadItems = poItems
.filter((item) => item.part_id !== '' && parseInt(String(item.quantity_ordered), 10) > 0)
.map((item) => ({
part_id: item.part_id,
quantity_ordered: parseInt(String(item.quantity_ordered), 10),
unit_price: parseFloat(item.unit_price) || 0,
}));
if (payloadItems.length === 0) {
toast.error('Please specify valid items and quantities');
return;
}
setIsSubmitting(true);
try {
const created = await catalogService.createPurchaseOrder({
supplier_name: supplierName.trim(),
items: payloadItems,
});
setPurchaseOrders([...purchaseOrders, created]);
toast.success(`Purchase order ${created.po_number} created`);
setSupplierName('');
setPoItems([]);
setShowPOModal(false);
} catch (err: any) {
toast.error(err?.message || 'Failed to create purchase order');
} finally {
setIsSubmitting(false);
}
};
const handleReceivePO = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedPO) return;
setIsSubmitting(true);
try {
const itemsReceivedPayload = Object.entries(receiveQuantities).map(([itemId, qty]) => ({
id: itemId,
quantity_received: qty,
}));
const updated = await catalogService.receivePurchaseOrder(
selectedPO.purchase_order_id,
itemsReceivedPayload
);
toast.success(`Purchase order ${updated.po_number} marked as received`);
setShowReceiveModal(false);
setSelectedPO(null);
setReceiveQuantities({});
fetchData();
} catch (err: any) {
toast.error(err?.message || 'Failed to receive purchase order items');
} finally {
setIsSubmitting(false);
}
};
const addPOItemRow = () => {
setPoItems((prev) => [...prev, { ...EMPTY_ITEM }]);
};
const updatePOItemRow = (index: number, field: 'part_id' | 'quantity_ordered' | 'unit_price', value: string) => {
const updated = [...poItems];
if (field === 'part_id') {
updated[index].part_id = value;
const partObj = parts.find((p) => p.part_id === value);
if (partObj) updated[index].unit_price = partObj.cost_price.toString();
} else if (field === 'quantity_ordered') {
updated[index].quantity_ordered = parseInt(value, 10) || 1;
} else {
updated[index].unit_price = value;
}
setPoItems(updated);
};
const removePOItemRow = (index: number) => {
setPoItems(poItems.filter((_, i) => i !== index));
};
const openCreateModal = () => {
setSupplierName('');
setPoItems([{ ...EMPTY_ITEM }]);
setShowPOModal(true);
};
const openReceiveModal = (po: PurchaseOrderResponse) => {
setSelectedPO(po);
const initialQtys: Record<string, number> = {};
po.items.forEach((item) => {
initialQtys[item.id] = Math.max(0, item.quantity_ordered - item.quantity_received);
});
setReceiveQuantities(initialQtys);
setShowReceiveModal(true);
};
const closeReceive = () => {
if (isSubmitting) return;
setShowReceiveModal(false);
setSelectedPO(null);
};
const isReceived = (status?: string) => (status || '').toLowerCase() === 'received';
const isOrdered = (status?: string) => (status || '').toLowerCase() === 'ordered';
const orderedCount = purchaseOrders.filter((po) => isOrdered(po.status)).length;
const receivedCount = purchaseOrders.filter((po) => isReceived(po.status)).length;
const filteredOrders = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return purchaseOrders.filter((po) => {
const statusMatch =
statusFilter === 'all' ||
(statusFilter === 'ordered' && isOrdered(po.status)) ||
(statusFilter === 'received' && isReceived(po.status));
const searchMatch =
!q ||
(po.po_number || '').toLowerCase().includes(q) ||
(po.supplier_name || '').toLowerCase().includes(q) ||
(po.status || '').toLowerCase().includes(q);
return statusMatch && searchMatch;
});
}, [purchaseOrders, 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) => {
const received = isReceived(status);
const ordered = isOrdered(status);
return (
<span
className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${
received ? 'bg-success text-white' : ordered ? 'bg-warning text-white' : 'bg-muted text-muted-foreground'
}`}
>
{status || 'Draft'}
</span>
);
};
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">Supplier Purchase 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">
{purchaseOrders.length}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={fetchData}
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={openCreateModal} 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 PO
</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">
{([
{ id: 'all', label: `All (${purchaseOrders.length})` },
{ id: 'ordered', label: `Ordered (${orderedCount})` },
{ id: 'received', label: `Received (${receivedCount})` },
] 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 ${
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 purchase orders...</span>
</div>
) : (
<div className="overflow-x-auto">
<table className="crm-data-table min-w-[800px]">
<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">PO number</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Supplier</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-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">Created</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 purchase orders match your search.'
: 'No purchase orders found.'}
</td>
</tr>
) : (
pager.items.map((po) => (
<tr key={po.purchase_order_id} className="border-t border-border hover:bg-muted/10">
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground font-mono">{po.po_number}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{po.supplier_name}</td>
<td className="px-5 py-3.5">{renderStatusBadge(po.status)}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">โ‚น{po.total_amount}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
{po.created_at ? new Date(po.created_at).toLocaleDateString() : 'โ€”'}
</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-center">
{!isReceived(po.status) ? (
<button
type="button"
onClick={() => openReceiveModal(po)}
className="inline-flex items-center h-8 px-3 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[12px] font-semibold cursor-pointer"
>
Receive
</button>
) : (
<span className="inline-flex items-center gap-1 text-[12px] text-success font-medium">
<CheckCircle2 className="w-3.5 h-3.5" />
Received
</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<TablePagination {...pager} />
</div>
<SlideOver
open={showPOModal}
onClose={() => {
if (isSubmitting) return;
setShowPOModal(false);
}}
title="Create purchase order"
icon={<ClipboardCheck className="w-4 h-4 text-primary shrink-0" />}
>
<form onSubmit={handleCreatePO} 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-4">
<div>
<label className={labelClass}>
Supplier name <span className="text-primary">*</span>
</label>
<input
type="text"
placeholder="e.g. Apple Wholesale Inc."
value={supplierName}
onChange={(e) => setSupplierName(e.target.value)}
className={inputClass}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[11px] font-semibold text-muted-foreground uppercase">Ordered parts</label>
<button type="button" onClick={addPOItemRow} className="inline-flex items-center gap-1 text-[12px] font-semibold text-primary cursor-pointer">
<Plus className="w-3.5 h-3.5" />
Add part
</button>
</div>
<div className="space-y-2">
{poItems.map((row, idx) => (
<div key={idx} className="flex flex-wrap items-center gap-2 p-3 border border-border crm-radius-card bg-muted/10">
<CustomSelect
value={row.part_id}
onChange={(value) => updatePOItemRow(idx, 'part_id', value)}
options={[
{ value: '', label: 'Select part' },
...parts.map((p) => ({
value: p.part_id,
label: `${p.name} (${p.sku})`,
})),
]}
placeholder="Select part"
className="flex-1 min-w-[160px]"
/>
<input
type="number"
placeholder="Qty"
value={row.quantity_ordered}
onChange={(e) => updatePOItemRow(idx, 'quantity_ordered', e.target.value)}
className={`${inputClass} w-20`}
/>
<input
type="number"
placeholder="Price"
value={row.unit_price}
onChange={(e) => updatePOItemRow(idx, 'unit_price', e.target.value)}
className={`${inputClass} w-24`}
/>
<button
type="button"
onClick={() => removePOItemRow(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 line"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{poItems.length === 0 && (
<p className="text-[12px] text-muted-foreground">No parts added yet.</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={() => setShowPOModal(false)} disabled={isSubmitting} className={secondaryButton}>
Cancel
</button>
<button type="submit" disabled={isSubmitting} className={primaryButton}>
{isSubmitting ? 'Saving...' : 'Place order'}
</button>
</div>
</form>
</SlideOver>
<SlideOver
open={showReceiveModal && !!selectedPO}
onClose={closeReceive}
title={selectedPO ? `Receive ${selectedPO.po_number}` : 'Receive order'}
icon={<ClipboardCheck className="w-4 h-4 text-primary shrink-0" />}
>
{selectedPO && (
<form onSubmit={handleReceivePO} 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-4">
<p className="text-[13px] text-muted-foreground">
Log quantities received from{' '}
<span className="font-semibold text-foreground">{selectedPO.supplier_name}</span>.
</p>
<div className="space-y-3">
{selectedPO.items.map((item) => {
const partObj = parts.find((p) => p.part_id === item.part_id);
return (
<div key={item.id} className="p-3 border border-border crm-radius-card bg-muted/10 space-y-2">
<p className="text-[13px] font-semibold text-foreground">{partObj?.name || 'Unknown part'}</p>
<p className="text-[12px] text-muted-foreground">
Ordered {item.quantity_ordered} ยท Received {item.quantity_received}
</p>
<div>
<label className={labelClass}>Qty received now</label>
<input
type="number"
value={receiveQuantities[item.id] || 0}
onChange={(e) =>
setReceiveQuantities({
...receiveQuantities,
[item.id]: parseInt(e.target.value, 10) || 0,
})
}
className={inputClass}
/>
</div>
</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={closeReceive} disabled={isSubmitting} className={secondaryButton}>
Cancel
</button>
<button type="submit" disabled={isSubmitting} className={primaryButton}>
{isSubmitting ? 'Saving...' : 'Confirm receipt'}
</button>
</div>
</form>
)}
</SlideOver>
</div>
);
}