317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { Item } from '@/lib/db';
|
|
import {
|
|
Plus,
|
|
Minus,
|
|
Trash2,
|
|
AlertTriangle,
|
|
X,
|
|
Edit2,
|
|
Camera
|
|
} from 'lucide-react';
|
|
import { toast } from 'react-hot-toast';
|
|
import { clsx, type ClassValue } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
|
|
function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
interface StockAdjustmentPanelProps {
|
|
selectedItem: Item | null;
|
|
isEditing: boolean;
|
|
editedItem: Partial<Item>;
|
|
adjustQty: number;
|
|
adjustType: 'ADD' | 'REMOVE' | 'TRASH' | null;
|
|
trashReason: string;
|
|
categories: any[];
|
|
fieldScanning: { active: boolean; field: string } | null;
|
|
onCancel: () => void;
|
|
onEdit: (item: Item) => void;
|
|
onEditChange: (item: Partial<Item>) => void;
|
|
onQuantityChange: (qty: number) => void;
|
|
onTypeChange: (type: 'ADD' | 'REMOVE' | 'TRASH') => void;
|
|
onReasonChange: (reason: string) => void;
|
|
onShowScanner: (active: boolean, field: string) => void;
|
|
onAdjustStock: () => void;
|
|
onUpdateItem: () => void;
|
|
onDeleteItem: () => void;
|
|
}
|
|
|
|
export default function StockAdjustmentPanel({
|
|
selectedItem,
|
|
isEditing,
|
|
editedItem,
|
|
adjustQty,
|
|
adjustType,
|
|
trashReason,
|
|
categories,
|
|
fieldScanning,
|
|
onCancel,
|
|
onEdit,
|
|
onEditChange,
|
|
onQuantityChange,
|
|
onTypeChange,
|
|
onReasonChange,
|
|
onShowScanner,
|
|
onAdjustStock,
|
|
onUpdateItem,
|
|
onDeleteItem,
|
|
}: StockAdjustmentPanelProps) {
|
|
if (!selectedItem) return null;
|
|
|
|
return (
|
|
<div data-testid="stock-adjustment-form" className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-background/80 animate-in fade-in duration-200">
|
|
<div className="w-full max-w-lg bg-surface border border-outline/30 ] shadow-2xl p-4 md:p-6 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
|
<div className="flex justify-between items-center mb-3 md:mb-4">
|
|
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2">
|
|
<span data-testid="adjustment-item-name">{isEditing ? "Edit Metadata" : selectedItem.name}</span>
|
|
{!isEditing && (
|
|
<span data-testid="current-quantity" className="text-[11px] bg-surface-container text-secondary px-2 py-0.5 font-normal tracking-tight shadow-sm border border-outline-variant/50">
|
|
In Stock: {selectedItem.quantity}
|
|
</span>
|
|
)}
|
|
</h3>
|
|
<div className="flex gap-2">
|
|
{!isEditing && (
|
|
<button
|
|
onClick={() => onEdit(selectedItem)}
|
|
className="p-2 hover:bg-surface-container text-secondary"
|
|
>
|
|
<Edit2 size={20} />
|
|
</button>
|
|
)}
|
|
{isEditing && (
|
|
<button
|
|
onClick={onDeleteItem}
|
|
className="p-2 hover:bg-error/20 text-error"
|
|
>
|
|
<Trash2 size={20} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onCancel}
|
|
data-testid="adjustment-cancel"
|
|
className="p-2 hover:bg-surface-container"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<div className="space-y-2 md:space-y-3 mb-4 md:mb-6">
|
|
<div>
|
|
<div className="bg-surface py-2.5 px-4 ] border border-outline/30 focus-within:border-primary/50 transition-colors group">
|
|
<label className="text-xs text-secondary font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item Name</label>
|
|
<textarea
|
|
value={editedItem.name || ''}
|
|
onChange={(e) => onEditChange({ ...editedItem, name: e.target.value })}
|
|
className="bg-transparent w-full text-lg font-normal outline-none text-foreground placeholder:text-secondary resize-none h-8 leading-tight selection:bg-primary/30 py-0"
|
|
placeholder="SSD, SFP, Cable..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Part Number</label>
|
|
<input
|
|
type="text"
|
|
value={editedItem.part_number || ''}
|
|
onChange={e => onEditChange({ ...editedItem, part_number: e.target.value })}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm font-mono outline-none text-secondary"
|
|
placeholder="e.g. PN-12345"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Category Group</label>
|
|
<input
|
|
type="text"
|
|
list="existing-categories"
|
|
value={editedItem.category || ''}
|
|
onChange={e => onEditChange({ ...editedItem, category: e.target.value })}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm outline-none text-secondary placeholder:text-secondary"
|
|
placeholder="e.g. storage"
|
|
/>
|
|
<datalist id="existing-categories">
|
|
{categories.map(c => (
|
|
<option key={c.id} value={c.name} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Item Type</label>
|
|
<input
|
|
type="text"
|
|
list="existing-types"
|
|
value={editedItem.type || ''}
|
|
onChange={e => onEditChange({...editedItem, type: e.target.value})}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm outline-none text-secondary"
|
|
placeholder="e.g. spare parts"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="text-sm font-normal text-secondary ml-1">Box / Container Label</label>
|
|
<div className="relative flex items-center">
|
|
<input
|
|
type="text"
|
|
list="existing-boxes"
|
|
value={editedItem.box_label || ''}
|
|
onChange={e => onEditChange({...editedItem, box_label: e.target.value})}
|
|
className="w-full bg-background border border-outline/30 py-3 pl-4 pr-12 text-sm outline-none text-secondary placeholder:text-secondary focus:border-primary transition-colors"
|
|
placeholder="e.g. SFPs 40G Cisco"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onShowScanner(true, 'box_label');
|
|
toast.success("Scanning for BOX label...");
|
|
}}
|
|
className={cn(
|
|
"absolute right-2 p-2 transition-all",
|
|
fieldScanning?.active ? "bg-primary text-foreground animate-pulse" : "text-secondary hover:bg-surface-container"
|
|
)}
|
|
>
|
|
<Camera size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Connector</label>
|
|
<input
|
|
type="text"
|
|
value={editedItem.connector || ''}
|
|
onChange={e => onEditChange({...editedItem, connector: e.target.value})}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm outline-none text-secondary"
|
|
placeholder="e.g. LC/UPC"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Size / Length</label>
|
|
<input
|
|
type="text"
|
|
value={editedItem.size || ''}
|
|
onChange={e => onEditChange({...editedItem, size: e.target.value})}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm outline-none text-secondary"
|
|
placeholder="e.g. 5m / 1600GB"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="text-sm font-normal text-secondary ml-1 tracking-tight">Item Color</label>
|
|
<input
|
|
type="text"
|
|
value={editedItem.color || ''}
|
|
onChange={e => onEditChange({...editedItem, color: e.target.value})}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm outline-none text-secondary"
|
|
placeholder="e.g. Black"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="text-sm font-normal text-secondary ml-1">Description</label>
|
|
<textarea
|
|
value={editedItem.description || ''}
|
|
onChange={e => onEditChange({ ...editedItem, description: e.target.value })}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm outline-none text-secondary resize-none h-20"
|
|
placeholder="Item description..."
|
|
/>
|
|
</div>
|
|
<div className="bg-surface py-2.5 px-4 ] border border-outline/30 focus-within:border-primary/50 transition-colors group">
|
|
<label className="text-xs text-secondary font-normal mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight">Item ID or Code</label>
|
|
<textarea
|
|
value={editedItem.ocr_text || ''}
|
|
onChange={e => onEditChange({ ...editedItem, ocr_text: e.target.value })}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm font-normal outline-none text-secondary resize-none h-12"
|
|
placeholder="e.g., SKU-12345 or barcode text..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex p-1 bg-background mb-4 md:mb-6">
|
|
{[
|
|
{ id: 'ADD', label: 'Buy More', icon: Plus, color: 'text-primary' },
|
|
{ id: 'REMOVE', label: 'Subtract', icon: Minus, color: 'text-warning' },
|
|
{ id: 'TRASH', label: 'Discard', icon: Trash2, color: 'text-error' }
|
|
].map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => onTypeChange(t.id as 'ADD' | 'REMOVE' | 'TRASH')}
|
|
className={cn(
|
|
"flex-1 flex flex-col items-center py-3 transition-all",
|
|
adjustType === t.id ? "bg-surface-container shadow-lg" : "text-secondary"
|
|
)}
|
|
>
|
|
<t.icon size={20} className={adjustType === t.id ? t.color : ""} />
|
|
<span className="text-xs font-normal mt-1">{t.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center gap-3 md:gap-4 mb-4 md:mb-6">
|
|
<div className="flex items-center gap-2 md:gap-4">
|
|
<button
|
|
onClick={() => onQuantityChange(Math.max(1, adjustQty - 1))}
|
|
className="w-12 h-12 border border-outline/30 flex items-center justify-center text-secondary active:bg-surface-container"
|
|
>
|
|
<Minus size={24} />
|
|
</button>
|
|
<div className="text-center" data-testid="adjustment-quantity-input">
|
|
<span className="text-xs font-normal tabular-nums">{adjustQty}</span>
|
|
<span className="text-[10px] text-primary/80 font-normal tracking-tight">Units</span>
|
|
</div>
|
|
<button
|
|
onClick={() => onQuantityChange(adjustQty + 1)}
|
|
className="w-12 h-12 border border-outline/30 flex items-center justify-center text-secondary active:bg-surface-container"
|
|
>
|
|
<Plus size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
{adjustType === 'TRASH' && (
|
|
<div className="w-full bg-error/5 border border-error/20 p-4 animate-in shake duration-500">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<AlertTriangle size={16} className="text-error" />
|
|
<span className="text-sm font-normal text-error">Waste Declaration</span>
|
|
</div>
|
|
<select
|
|
value={trashReason}
|
|
onChange={(e) => onReasonChange(e.target.value)}
|
|
className="w-full bg-background border border-outline/30 py-3 px-4 text-sm outline-none text-secondary"
|
|
>
|
|
<option>Damaged</option>
|
|
<option>Expired</option>
|
|
<option>Lost</option>
|
|
<option>Technical Failure</option>
|
|
<option>Other</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<button
|
|
onClick={isEditing ? onUpdateItem : onAdjustStock}
|
|
data-testid="adjustment-submit"
|
|
className={cn(
|
|
"w-full py-5 ] font-normal text-lg transition-all active:scale-[0.98] shadow-2xl",
|
|
isEditing ? "bg-foreground text-surface-container" : (
|
|
adjustType === 'ADD' ? "bg-primary shadow-primary/20 text-foreground" :
|
|
adjustType === 'REMOVE' ? "bg-warning shadow-warning/20 text-foreground" :
|
|
"bg-error shadow-error/20 text-foreground"
|
|
)
|
|
)}
|
|
>
|
|
{isEditing ? "Save Changes" : (
|
|
adjustType === 'ADD' ? `Add ${adjustQty} to Stock` :
|
|
adjustType === 'REMOVE' ? `Subtract ${adjustQty} from Stock` :
|
|
`Discard ${adjustQty} items`
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|