747 lines
31 KiB
TypeScript
747 lines
31 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Plus,
|
|
Search,
|
|
RefreshCw,
|
|
Package,
|
|
TrendingUp,
|
|
AlertTriangle,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { catalogService, PartResponse, SellableSkuRow, StockMovementResponse } from '@/services/api/catalogService';
|
|
import { SlideOver } from '@/components/ui/SlideOver';
|
|
import { CustomSelect } from '@/components/ui/CustomSelect';
|
|
import { TablePagination, TABLE_PAGE_SIZE, useClientPagination } from '@/components/ui/TablePagination';
|
|
|
|
type LedgerTab = 'skus' | 'parts' | 'movements';
|
|
|
|
export default function InventoryPage() {
|
|
const [activeTab, setActiveTab] = useState<LedgerTab>('skus');
|
|
const [parts, setParts] = useState<PartResponse[]>([]);
|
|
const [movements, setMovements] = useState<StockMovementResponse[]>([]);
|
|
const [skus, setSkus] = useState<SellableSkuRow[]>([]);
|
|
const [skuTotal, setSkuTotal] = useState(0);
|
|
const [skuPage, setSkuPage] = useState(1);
|
|
const skuLimit = TABLE_PAGE_SIZE;
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [debouncedSkuSearch, setDebouncedSkuSearch] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const [showPartModal, setShowPartModal] = useState(false);
|
|
const [partSku, setPartSku] = useState('');
|
|
const [partName, setPartName] = useState('');
|
|
const [partCost, setPartCost] = useState('');
|
|
const [partLowStock, setPartLowStock] = useState('3');
|
|
const [partSupplier, setPartSupplier] = useState('');
|
|
const [partBarcode, setPartBarcode] = useState('');
|
|
|
|
const [showAdjustModal, setShowAdjustModal] = useState(false);
|
|
const [adjustPartId, setAdjustPartId] = useState('');
|
|
const [adjustQty, setAdjustQty] = useState('');
|
|
const [adjustType, setAdjustType] = useState<'Adjustment' | 'Damage'>('Adjustment');
|
|
const [adjustReason, setAdjustReason] = useState('');
|
|
|
|
const [showSkuAdjustModal, setShowSkuAdjustModal] = useState(false);
|
|
const [skuAdjustRow, setSkuAdjustRow] = useState<SellableSkuRow | null>(null);
|
|
const [skuAdjustQty, setSkuAdjustQty] = useState('');
|
|
const [skuAdjustType, setSkuAdjustType] = useState<'RECEIPT' | 'ADJUSTMENT'>('RECEIPT');
|
|
const [skuAdjustNotes, setSkuAdjustNotes] = useState('');
|
|
|
|
const fetchPartsData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [fetchedParts, fetchedMovements] = await Promise.all([
|
|
catalogService.getParts(),
|
|
catalogService.getStockHistory(),
|
|
]);
|
|
setParts(fetchedParts);
|
|
setMovements(fetchedMovements);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to load inventory ledger');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchSkus = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await catalogService.getSellableSkus({
|
|
page: skuPage,
|
|
limit: skuLimit,
|
|
q: debouncedSkuSearch || undefined,
|
|
});
|
|
setSkus(result.items);
|
|
setSkuTotal(result.total);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to load sellable SKUs');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchData = async () => {
|
|
if (activeTab === 'skus') {
|
|
await fetchSkus();
|
|
return;
|
|
}
|
|
await fetchPartsData();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedSkuSearch(searchQuery);
|
|
setSkuPage(1);
|
|
}, 400);
|
|
return () => clearTimeout(timer);
|
|
}, [searchQuery]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [activeTab, skuPage, debouncedSkuSearch]);
|
|
|
|
const handleCreatePart = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!partSku.trim() || !partName.trim() || !partCost) {
|
|
toast.error('SKU, name, and cost price are required');
|
|
return;
|
|
}
|
|
setIsSubmitting(true);
|
|
try {
|
|
const created = await catalogService.createPart({
|
|
sku: partSku.trim(),
|
|
name: partName.trim(),
|
|
cost_price: parseFloat(partCost),
|
|
low_stock_alert: parseInt(partLowStock) || 3,
|
|
supplier: partSupplier.trim() || undefined,
|
|
barcode: partBarcode.trim() || undefined,
|
|
});
|
|
setParts([...parts, created]);
|
|
toast.success(`Part "${created.sku}" added to inventory`);
|
|
setPartSku('');
|
|
setPartName('');
|
|
setPartCost('');
|
|
setPartLowStock('3');
|
|
setPartSupplier('');
|
|
setPartBarcode('');
|
|
setShowPartModal(false);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to create inventory part');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleAdjustStock = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!adjustPartId || !adjustQty || !adjustReason.trim()) {
|
|
toast.error('Part, quantity, and reason are required');
|
|
return;
|
|
}
|
|
setIsSubmitting(true);
|
|
try {
|
|
const qtyVal = parseInt(adjustQty);
|
|
const payloadQty = adjustType === 'Damage' ? -Math.abs(qtyVal) : qtyVal;
|
|
await catalogService.adjustStock({
|
|
entity_type: 'part',
|
|
entity_id: adjustPartId,
|
|
movement_type: adjustType,
|
|
quantity: payloadQty,
|
|
reference_type: 'ManualAdjustment',
|
|
reference_id: adjustReason.trim(),
|
|
});
|
|
toast.success('Stock movement recorded');
|
|
setAdjustPartId('');
|
|
setAdjustQty('');
|
|
setAdjustReason('');
|
|
setShowAdjustModal(false);
|
|
fetchData();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to log stock movement');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleSkuAdjust = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!skuAdjustRow || !skuAdjustQty) {
|
|
toast.error('Quantity is required');
|
|
return;
|
|
}
|
|
const qtyVal = parseInt(skuAdjustQty, 10);
|
|
if (!Number.isFinite(qtyVal) || qtyVal === 0) {
|
|
toast.error('Enter a non-zero quantity');
|
|
return;
|
|
}
|
|
setIsSubmitting(true);
|
|
try {
|
|
const qty = skuAdjustType === 'ADJUSTMENT' ? -Math.abs(qtyVal) : Math.abs(qtyVal);
|
|
await catalogService.adjustSellableStock({
|
|
variant_id: skuAdjustRow.variant_id,
|
|
event_type: skuAdjustType,
|
|
qty,
|
|
notes: skuAdjustNotes.trim() || undefined,
|
|
});
|
|
toast.success(`Stock quantity updated for ${skuAdjustRow.sku}`);
|
|
setShowSkuAdjustModal(false);
|
|
setSkuAdjustRow(null);
|
|
setSkuAdjustQty('');
|
|
setSkuAdjustNotes('');
|
|
fetchSkus();
|
|
} catch (err: any) {
|
|
toast.error(err?.message || 'Failed to adjust sellable stock');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const openLogMovement = async () => {
|
|
if (parts.length === 0) {
|
|
try {
|
|
const fetchedParts = await catalogService.getParts();
|
|
setParts(fetchedParts);
|
|
} catch {
|
|
toast.error('Failed to load parts');
|
|
}
|
|
}
|
|
setShowAdjustModal(true);
|
|
};
|
|
|
|
const skuPages = Math.max(1, Math.ceil(skuTotal / skuLimit));
|
|
|
|
const filteredParts = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
if (!q) return parts;
|
|
return parts.filter(
|
|
(p) =>
|
|
p.sku.toLowerCase().includes(q) ||
|
|
p.name.toLowerCase().includes(q) ||
|
|
(p.supplier || '').toLowerCase().includes(q) ||
|
|
(p.barcode || '').toLowerCase().includes(q)
|
|
);
|
|
}, [parts, searchQuery]);
|
|
|
|
const filteredMovements = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
if (!q) return movements;
|
|
return movements.filter((m) => {
|
|
const partObj = parts.find((p) => p.part_id === m.entity_id);
|
|
return (
|
|
(partObj?.name || '').toLowerCase().includes(q) ||
|
|
(partObj?.sku || '').toLowerCase().includes(q) ||
|
|
(m.movement_type || '').toLowerCase().includes(q) ||
|
|
(m.reference_id || '').toLowerCase().includes(q)
|
|
);
|
|
});
|
|
}, [movements, parts, searchQuery]);
|
|
|
|
const partsPager = useClientPagination(filteredParts);
|
|
const movementsPager = useClientPagination(filteredMovements);
|
|
|
|
const countBadge =
|
|
activeTab === 'skus' ? skuTotal : activeTab === 'parts' ? parts.length : movements.length;
|
|
|
|
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 changeTab = (tab: LedgerTab) => {
|
|
setActiveTab(tab);
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const renderStockBadge = (isLow: boolean, label?: string) => (
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${
|
|
isLow ? 'bg-warning text-white' : 'bg-success text-white'
|
|
}`}
|
|
>
|
|
{isLow && <AlertTriangle className="w-3 h-3" />}
|
|
{label || (isLow ? 'Low stock' : 'In stock')}
|
|
</span>
|
|
);
|
|
|
|
const renderMovementBadge = (type: string) => {
|
|
const value = type.toLowerCase();
|
|
const cls =
|
|
value === 'purchase' || value === 'receipt'
|
|
? 'bg-success text-white'
|
|
: value === 'damage'
|
|
? 'bg-destructive text-white'
|
|
: 'bg-muted text-muted-foreground';
|
|
return (
|
|
<span className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${cls}`}>
|
|
{type}
|
|
</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">Stock Ledger</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">
|
|
{countBadge}
|
|
</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={openLogMovement} className={secondaryButton}>
|
|
<TrendingUp className="w-3.5 h-3.5" />
|
|
Log movement
|
|
</button>
|
|
<button type="button" onClick={() => setShowPartModal(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>
|
|
Register part
|
|
</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: 'skus', label: `SKUs (${skuTotal})` },
|
|
{ id: 'parts', label: `Parts (${parts.length})` },
|
|
{ id: 'movements', label: `History (${movements.length})` },
|
|
] as { id: LedgerTab; label: string }[]).map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => changeTab(tab.id)}
|
|
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
|
|
activeTab === 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 stock ledger...</span>
|
|
</div>
|
|
) : activeTab === 'skus' ? (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="crm-data-table min-w-[900px]">
|
|
<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">Product</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">SKU</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Price</th>
|
|
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">Pending</th>
|
|
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">Held</th>
|
|
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">Available</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">
|
|
{skus.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="text-center py-12 text-[13px] text-muted-foreground">
|
|
{searchQuery ? 'No SKUs match your search.' : 'No sellable SKUs found.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
skus.map((row) => (
|
|
<tr key={row.variant_id} className="border-t border-border hover:bg-muted/10">
|
|
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground">{row.product_name}</td>
|
|
<td className="px-5 py-3.5 font-mono text-muted-foreground">
|
|
<span className="crm-cell-clip" title={row.sku}>{row.sku}</span>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">₹{row.price}</td>
|
|
<td className="px-5 py-3.5 text-center text-[13px] text-info">
|
|
{row.pending_confirmation_units || 0}
|
|
</td>
|
|
<td className="px-5 py-3.5 text-center text-[13px] text-warning">
|
|
{row.confirmed_units || 0}
|
|
</td>
|
|
<td className="px-5 py-3.5 text-center text-[13px] font-semibold text-foreground">
|
|
{row.available_stock}
|
|
</td>
|
|
<td className="px-5 py-3.5">{renderStockBadge(row.is_low)}</td>
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex justify-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSkuAdjustRow(row);
|
|
setSkuAdjustType('RECEIPT');
|
|
setSkuAdjustQty('');
|
|
setSkuAdjustNotes('');
|
|
setShowSkuAdjustModal(true);
|
|
}}
|
|
className={secondaryButton.replace('h-9 px-4', 'h-8 px-3') + ' text-[12px]'}
|
|
>
|
|
Adjust
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<TablePagination
|
|
page={skuPage}
|
|
totalPages={skuPages}
|
|
total={skuTotal}
|
|
pageSize={skuLimit}
|
|
onPageChange={setSkuPage}
|
|
/>
|
|
</>
|
|
) : activeTab === 'parts' ? (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="crm-data-table min-w-[760px]">
|
|
<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">Part</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">SKU / Barcode</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Unit cost</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Stock</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-card">
|
|
{filteredParts.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="text-center py-12 text-[13px] text-muted-foreground">
|
|
{searchQuery ? 'No parts match your search.' : 'No inventory parts registered.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
partsPager.items.map((p) => {
|
|
const isLow = p.stock <= p.low_stock_alert;
|
|
return (
|
|
<tr key={p.part_id} className="border-t border-border hover:bg-muted/10">
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-9 h-9 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<Package className="w-4 h-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] font-semibold text-foreground truncate">{p.name}</p>
|
|
<p className="text-[11px] text-muted-foreground truncate">
|
|
{p.supplier || 'No supplier'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-[13px] font-mono text-muted-foreground">
|
|
{p.sku}
|
|
{p.barcode && <span className="block text-[11px]">{p.barcode}</span>}
|
|
</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">₹{p.cost_price}</td>
|
|
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground">{p.stock}</td>
|
|
<td className="px-5 py-3.5">{renderStockBadge(isLow, isLow ? 'Reorder' : 'In stock')}</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<TablePagination {...partsPager} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="crm-data-table min-w-[760px]">
|
|
<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">Date</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Item</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Type</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Quantity</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Reference</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-card">
|
|
{filteredMovements.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="text-center py-12 text-[13px] text-muted-foreground">
|
|
{searchQuery ? 'No movements match your search.' : 'No stock movements recorded.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
movementsPager.items.map((m) => {
|
|
const partObj = parts.find((p) => p.part_id === m.entity_id);
|
|
const isNeg = m.quantity < 0;
|
|
return (
|
|
<tr key={m.movement_id} className="border-t border-border hover:bg-muted/10">
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{new Date(m.created_at).toLocaleString()}
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
<p className="text-[13px] font-semibold text-foreground">{partObj?.name || 'Deleted part'}</p>
|
|
<p className="text-[11px] font-mono text-muted-foreground">{partObj?.sku || '—'}</p>
|
|
</td>
|
|
<td className="px-5 py-3.5">{renderMovementBadge(m.movement_type)}</td>
|
|
<td className="px-5 py-3.5 text-[13px] font-semibold">
|
|
<span className={isNeg ? 'text-destructive' : 'text-success'}>
|
|
{isNeg ? '' : '+'}
|
|
{m.quantity}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3.5 font-mono text-muted-foreground">
|
|
<span className="crm-cell-clip" title={m.reference_id || ''}>{m.reference_id}</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<TablePagination {...movementsPager} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<SlideOver
|
|
open={showSkuAdjustModal && !!skuAdjustRow}
|
|
onClose={() => {
|
|
if (isSubmitting) return;
|
|
setShowSkuAdjustModal(false);
|
|
}}
|
|
title={skuAdjustRow ? `Adjust ${skuAdjustRow.sku}` : 'Adjust SKU'}
|
|
icon={<TrendingUp className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
{skuAdjustRow && (
|
|
<form onSubmit={handleSkuAdjust} 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">
|
|
Current available: <span className="font-semibold text-foreground">{skuAdjustRow.available_stock}</span>
|
|
</p>
|
|
<div>
|
|
<label className={labelClass}>Type</label>
|
|
<CustomSelect
|
|
value={skuAdjustType}
|
|
onChange={(value) => setSkuAdjustType(value as 'RECEIPT' | 'ADJUSTMENT')}
|
|
options={[
|
|
{ value: 'RECEIPT', label: 'Receive (add)' },
|
|
{ value: 'ADJUSTMENT', label: 'Adjustment (remove)' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Quantity</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={skuAdjustQty}
|
|
onChange={(e) => setSkuAdjustQty(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Notes</label>
|
|
<input
|
|
type="text"
|
|
value={skuAdjustNotes}
|
|
onChange={(e) => setSkuAdjustNotes(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</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={() => setShowSkuAdjustModal(false)}
|
|
disabled={isSubmitting}
|
|
className={secondaryButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={isSubmitting} className={primaryButton}>
|
|
{isSubmitting ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</SlideOver>
|
|
|
|
<SlideOver
|
|
open={showPartModal}
|
|
onClose={() => {
|
|
if (isSubmitting) return;
|
|
setShowPartModal(false);
|
|
}}
|
|
title="Register part"
|
|
icon={<Package className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
<form onSubmit={handleCreatePart} 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}>
|
|
Part SKU <span className="text-primary">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. PART-IP16PM-SCR-ORG"
|
|
value={partSku}
|
|
onChange={(e) => setPartSku(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Part name <span className="text-primary">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. iPhone 16 Pro Max Original Screen"
|
|
value={partName}
|
|
onChange={(e) => setPartName(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>
|
|
Cost price (₹) <span className="text-primary">*</span>
|
|
</label>
|
|
<input type="number" placeholder="12000" value={partCost} onChange={(e) => setPartCost(e.target.value)} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Low stock alert</label>
|
|
<input type="number" value={partLowStock} onChange={(e) => setPartLowStock(e.target.value)} className={inputClass} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Supplier</label>
|
|
<input type="text" placeholder="e.g. Apple Inc" value={partSupplier} onChange={(e) => setPartSupplier(e.target.value)} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Barcode</label>
|
|
<input type="text" placeholder="e.g. 789123456" value={partBarcode} onChange={(e) => setPartBarcode(e.target.value)} className={inputClass} />
|
|
</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={() => setShowPartModal(false)} disabled={isSubmitting} className={secondaryButton}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={isSubmitting} className={primaryButton}>
|
|
{isSubmitting ? 'Saving...' : 'Register'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SlideOver>
|
|
|
|
<SlideOver
|
|
open={showAdjustModal}
|
|
onClose={() => {
|
|
if (isSubmitting) return;
|
|
setShowAdjustModal(false);
|
|
}}
|
|
title="Log stock movement"
|
|
icon={<TrendingUp className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
<form onSubmit={handleAdjustStock} 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}>
|
|
Part <span className="text-primary">*</span>
|
|
</label>
|
|
<CustomSelect
|
|
value={adjustPartId}
|
|
onChange={setAdjustPartId}
|
|
options={[
|
|
{ value: '', label: 'Select part' },
|
|
...parts.map((p) => ({
|
|
value: p.part_id,
|
|
label: `${p.name} (${p.sku}) — ${p.stock}`,
|
|
})),
|
|
]}
|
|
placeholder="Select part"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Movement type</label>
|
|
<CustomSelect
|
|
value={adjustType}
|
|
onChange={(value) => setAdjustType(value as 'Adjustment' | 'Damage')}
|
|
options={[
|
|
{ value: 'Adjustment', label: 'Stock adjustment' },
|
|
{ value: 'Damage', label: 'Damage (reduces stock)' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Quantity <span className="text-primary">*</span>
|
|
</label>
|
|
<input type="number" placeholder="10" value={adjustQty} onChange={(e) => setAdjustQty(e.target.value)} className={inputClass} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>
|
|
Reason / reference <span className="text-primary">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. Audit adjustment, damaged in transit"
|
|
value={adjustReason}
|
|
onChange={(e) => setAdjustReason(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</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={() => setShowAdjustModal(false)} disabled={isSubmitting} className={secondaryButton}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={isSubmitting} className={primaryButton}>
|
|
{isSubmitting ? 'Saving...' : 'Apply'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SlideOver>
|
|
</div>
|
|
);
|
|
}
|