454 lines
20 KiB
TypeScript
454 lines
20 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { Head } from '@inertiajs/react';
|
|
import AppLayout from '@/Layouts/AppLayout';
|
|
import {
|
|
MaterialReactTable,
|
|
type MRT_ColumnDef,
|
|
type MRT_PaginationState,
|
|
type MRT_SortingState,
|
|
} from 'material-react-table';
|
|
import axios from 'axios';
|
|
import { toast } from 'react-hot-toast';
|
|
import { Combobox } from '@headlessui/react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faChevronDown, faCheck } from '@fortawesome/free-solid-svg-icons';
|
|
|
|
// --- Interfaces ---
|
|
interface DropdownOption {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
interface StockEntry {
|
|
id: number;
|
|
physical_item_id: number | null;
|
|
supplier_id: number | null;
|
|
count: number;
|
|
price: number | null;
|
|
bought: string | null;
|
|
description: string | null;
|
|
note: string | null;
|
|
stock_position_id: number | null;
|
|
country_of_origin_id: number | null;
|
|
on_the_way: boolean;
|
|
stock_batch_id: number | null;
|
|
created_by?: number;
|
|
updated_by?: number;
|
|
physical_item?: DropdownOption;
|
|
supplier?: DropdownOption;
|
|
stock_position?: { id: number; line: string; rack: string; shelf: string; position: string };
|
|
}
|
|
|
|
interface StockEntryFormData {
|
|
physical_item_id: number | null;
|
|
supplier_id: number | null;
|
|
count: number;
|
|
price: number | null;
|
|
bought: string | null;
|
|
description: string | null;
|
|
note: string | null;
|
|
stock_position_id: number | null;
|
|
country_of_origin_id: number | null;
|
|
on_the_way: boolean;
|
|
stock_batch_id: number | null;
|
|
}
|
|
|
|
const defaultForm = {
|
|
physical_item_id: null,
|
|
supplier_id: null,
|
|
count: 0,
|
|
price: null,
|
|
bought: null,
|
|
description: null,
|
|
note: null,
|
|
stock_position_id: null,
|
|
country_of_origin_id: null,
|
|
on_the_way: false,
|
|
stock_batch_id: null,
|
|
};
|
|
|
|
export default function StockEntries() {
|
|
// Table state
|
|
const [data, setData] = useState<StockEntry[]>([]);
|
|
const [isError, setIsError] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isRefetching, setIsRefetching] = useState(false);
|
|
const [rowCount, setRowCount] = useState(0);
|
|
|
|
// Dropdowns
|
|
const [stockPositions, setStockPositions] = useState<DropdownOption[]>([]);
|
|
const [suppliers, setSuppliers] = useState<DropdownOption[]>([]);
|
|
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
|
|
const [originCountries, setOriginCountries] = useState<DropdownOption[]>([]);
|
|
|
|
// Modal/form state
|
|
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
|
|
const [formData, setFormData] = useState<StockEntryFormData>(defaultForm);
|
|
|
|
// Batch mode state
|
|
const [isBatchMode, setIsBatchMode] = useState(false);
|
|
const [batchSelections, setBatchSelections] = useState<number[]>([]);
|
|
|
|
// Combobox search
|
|
const [itemQuery, setItemQuery] = useState('');
|
|
const filteredItems = useMemo(
|
|
() => itemQuery === ''
|
|
? physicalItems
|
|
: physicalItems.filter(item =>
|
|
item.name.toLowerCase().includes(itemQuery.toLowerCase()),
|
|
),
|
|
[itemQuery, physicalItems],
|
|
);
|
|
|
|
// Pagination/sorting/filtering
|
|
const [pagination, setPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
|
const [sorting, setSorting] = useState<MRT_SortingState>([{ id: 'updated_at', desc: true }]);
|
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
|
|
// Modal ref
|
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
const openModal = () => dialogRef.current?.showModal();
|
|
const closeModal = () => dialogRef.current?.close();
|
|
|
|
// Columns
|
|
const columns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
|
() => [
|
|
{ accessorKey: 'id', header: 'ID', size: 80 },
|
|
{ accessorKey: 'physical_item.name', header: 'Physical Item', size: 200 },
|
|
{ accessorKey: 'supplier.name', header: 'Supplier', size: 150 },
|
|
{ accessorKey: 'physical_item.manufacturer.name', header: 'Brand', size: 150 },
|
|
{ accessorKey: 'count', header: 'Count', size: 100 },
|
|
{ accessorKey: 'price', header: 'Price', size: 100, Cell: ({ cell }) => cell.getValue<number>()?.toFixed(2) || '-' },
|
|
{ accessorKey: 'bought', header: 'Bought Date', size: 120 },
|
|
{ accessorKey: 'on_the_way', header: 'On The Way', size: 100, Cell: ({ cell }) => cell.getValue<boolean>() ? 'Yes' : 'No' },
|
|
{ accessorKey: 'stock_position', header: 'Position', size: 150, Cell: ({ row }) => {
|
|
const pos = row.original.stock_position;
|
|
return pos ? `${pos.line}-${pos.rack}-${pos.shelf}-${pos.position}` : '-';
|
|
}
|
|
},
|
|
{ accessorKey: 'updated_at', header: 'Last Updated', size: 150 },
|
|
],
|
|
[],
|
|
);
|
|
|
|
// Batch columns
|
|
const batchColumns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: 'select',
|
|
header: 'Select',
|
|
Cell: ({ row }) => {
|
|
const id = row.original.id;
|
|
return (
|
|
<input
|
|
type="checkbox"
|
|
checked={batchSelections.includes(id)}
|
|
onChange={() => setBatchSelections(prev =>
|
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
|
)}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{ accessorKey: 'id', header: 'ID' },
|
|
{ accessorKey: 'physical_item.name', header: 'Item' },
|
|
{ accessorKey: 'supplier.name', header: 'Supplier' },
|
|
],
|
|
[batchSelections],
|
|
);
|
|
|
|
// Fetch options & data
|
|
useEffect(() => { fetchData(); }, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
|
|
useEffect(() => { fetchOptions(); }, []);
|
|
|
|
async function fetchOptions() {
|
|
try {
|
|
const { data } = await axios.get('/api/stockData/options');
|
|
setStockPositions(data.stockPositions);
|
|
setSuppliers(data.suppliers);
|
|
setOriginCountries(data.countriesOrigin);
|
|
} catch {
|
|
toast.error('Failed to load form options');
|
|
}
|
|
}
|
|
|
|
async function fetchData() {
|
|
setIsLoading(!data.length);
|
|
setIsRefetching(!!data.length);
|
|
try {
|
|
const res = await axios.get('/api/stockData');
|
|
setData(res.data.data);
|
|
setRowCount(res.data.meta.total);
|
|
} catch {
|
|
setIsError(true);
|
|
toast.error('Failed to fetch stock entries');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefetching(false);
|
|
}
|
|
}
|
|
|
|
async function fetchPhysicalItems(query: string) {
|
|
try {
|
|
const res = await axios.get('/api/stockData/options/items', { params: { item_name: query } });
|
|
setPhysicalItems(res.data.physicalItems);
|
|
} catch {
|
|
toast.error('Failed to load items');
|
|
}
|
|
}
|
|
useEffect(() => {
|
|
const delay = setTimeout(() => itemQuery && fetchPhysicalItems(itemQuery), 500);
|
|
return () => clearTimeout(delay);
|
|
}, [itemQuery]);
|
|
|
|
// Input change
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
|
const { name, value, type } = e.target;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[name]: type === 'checkbox'
|
|
? (e.target as HTMLInputElement).checked
|
|
: type === 'number'
|
|
? parseFloat(value) || null
|
|
: value || null,
|
|
}));
|
|
};
|
|
|
|
// Submit
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
let res;
|
|
if (editingEntry) {
|
|
res = await axios.put(`/api/stockData/${editingEntry.id}`, formData);
|
|
toast.success('Stock entry updated');
|
|
} else {
|
|
res = await axios.post('/api/stockData', formData);
|
|
toast.success('Stock entry created');
|
|
}
|
|
const newEntry: StockEntry = res.data;
|
|
if (isBatchMode) {
|
|
// Add to batch and keep modal open
|
|
setBatchSelections(prev => [...prev, newEntry.id]);
|
|
setFormData({ ...defaultForm, on_the_way: true });
|
|
} else {
|
|
closeModal();
|
|
}
|
|
fetchData();
|
|
} catch {
|
|
toast.error('Failed to save');
|
|
}
|
|
};
|
|
|
|
// Batch submit
|
|
const handleBatchSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
await axios.post('/api/stockData/batch', { ids: batchSelections });
|
|
toast.success('Batch created');
|
|
closeModal();
|
|
fetchData();
|
|
} catch {
|
|
toast.error('Batch submit failed');
|
|
}
|
|
};
|
|
|
|
const handleEdit = (entry: StockEntry) => {
|
|
setEditingEntry(entry);
|
|
setFormData({ ...entry });
|
|
openModal();
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
if (!confirm('Are you sure?')) return;
|
|
try {
|
|
await axios.delete(`/api/stockData/${id}`);
|
|
toast.success('Deleted');
|
|
fetchData();
|
|
} catch {
|
|
toast.error('Failed to delete');
|
|
}
|
|
};
|
|
|
|
const handleAdd = () => {
|
|
setIsBatchMode(false);
|
|
setEditingEntry(null);
|
|
setFormData(defaultForm);
|
|
openModal();
|
|
};
|
|
|
|
const handleNewBatch = () => {
|
|
setIsBatchMode(true);
|
|
setEditingEntry(null);
|
|
setBatchSelections([]);
|
|
setFormData({ ...defaultForm, on_the_way: true });
|
|
openModal();
|
|
};
|
|
|
|
return (
|
|
<AppLayout title="Stock Entries" renderHeader={() => (
|
|
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
|
Stock Entries
|
|
</h2>
|
|
)}>
|
|
<Head title="Stock Entries" />
|
|
<div className="py-12">
|
|
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
|
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg p-6">
|
|
<div className="mb-4 flex justify-between items-center">
|
|
<h1 className="text-2xl font-bold">Stock Entries</h1>
|
|
<div>
|
|
<button className="btn mr-2" onClick={handleNewBatch}>New batch</button>
|
|
<button className="btn" onClick={handleAdd}>Add New Entry</button>
|
|
</div>
|
|
</div>
|
|
<MaterialReactTable
|
|
columns={columns}
|
|
data={data}
|
|
enableTopToolbar
|
|
manualPagination
|
|
manualSorting
|
|
manualFiltering
|
|
onPaginationChange={setPagination}
|
|
onSortingChange={setSorting}
|
|
onGlobalFilterChange={setGlobalFilter}
|
|
rowCount={rowCount}
|
|
state={{ isLoading, pagination, showProgressBars: isRefetching, sorting, globalFilter }}
|
|
enableRowActions
|
|
renderRowActionMenuItems={({ row }) => [
|
|
<button key="edit" onClick={() => handleEdit(row.original)} className="menu-item">Edit</button>,
|
|
<button key="delete" onClick={() => handleDelete(row.original.id)} className="menu-item text-red-600">Delete</button>,
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal (Add/Edit & Batch) */}
|
|
<dialog ref={dialogRef} className="modal">
|
|
<form onSubmit={handleSubmit} className={`modal-box flex space-x-4 p-6 ${isBatchMode ? 'max-w-full' : ''}`}>
|
|
<button type="button" onClick={closeModal} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
|
{isBatchMode && (
|
|
<div className="w-2/3">
|
|
<h3 className="font-bold text-lg mb-2">Select Incoming Items</h3>
|
|
<MaterialReactTable
|
|
columns={batchColumns}
|
|
data={data.filter(e => e.on_the_way)}
|
|
enablePagination={false}
|
|
enableSorting={false}
|
|
enableGlobalFilter={false}
|
|
getRowId={row => row.id.toString()}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={isBatchMode ? 'w-1/3' : 'w-full'}>
|
|
<h3 className="font-bold text-lg mb-4">{isBatchMode ? 'New Batch Entry' : (editingEntry ? 'Edit Stock Entry' : 'New Stock Entry')}</h3>
|
|
{/* Physical Item */}
|
|
<div className="form-control">
|
|
<Combobox value={formData.physical_item_id} onChange={val => setFormData(prev => ({ ...prev, physical_item_id: val }))}>
|
|
<Combobox.Input
|
|
onChange={e => setItemQuery(e.target.value)}
|
|
displayValue={id => physicalItems.find(i => i.id === id)?.name || ''}
|
|
placeholder="Select item..."
|
|
className="input"
|
|
/>
|
|
<Combobox.Options className="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-h-60 overflow-auto">
|
|
{filteredItems.map(item => (
|
|
<Combobox.Option key={item.id} value={item.id} className="cursor-pointer p-2 hover:bg-gray-200">
|
|
<div className="flex justify-between items-center">
|
|
<span>{item.name}</span>
|
|
{formData.physical_item_id === item.id && <FontAwesomeIcon icon={faCheck} />}
|
|
</div>
|
|
</Combobox.Option>
|
|
))}
|
|
</Combobox.Options>
|
|
</Combobox>
|
|
</div>
|
|
|
|
{/* Supplier */}
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="supplier_id">
|
|
<span className="label-text">Supplier</span>
|
|
</label>
|
|
<select id="supplier_id" name="supplier_id" value={formData.supplier_id || ''} onChange={handleInputChange} className="select">
|
|
<option value="">Select supplier...</option>
|
|
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Count, Price, Bought Date */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="count"><span className="label-text">Count</span></label>
|
|
<input id="count" name="count" type="number" value={formData.count} onChange={handleInputChange} className="input" />
|
|
</div>
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="price"><span className="label-text">Price</span></label>
|
|
<input id="price" name="price" type="number" step="0.01" value={formData.price || ''} onChange={handleInputChange} className="input" />
|
|
</div>
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="bought"><span className="label-text">Bought Date</span></label>
|
|
<input id="bought" name="bought" type="date" value={formData.bought || ''} onChange={handleInputChange} className="input" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description & Note */}
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="description"><span className="label-text">Description</span></label>
|
|
<textarea id="description" name="description" value={formData.description || ''} onChange={handleInputChange} className="textarea" />
|
|
</div>
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="note"><span className="label-text">Note</span></label>
|
|
<textarea id="note" name="note" value={formData.note || ''} onChange={handleInputChange} className="textarea" />
|
|
</div>
|
|
|
|
{/* Stock Position & Country & On The Way */}
|
|
<div className="grid grid-cols-3 gap-4 items-end">
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="stock_position_id"><span className="label-text">Position</span></label>
|
|
<select id="stock_position_id" name="stock_position_id" value={formData.stock_position_id || ''} onChange={handleInputChange} className="select">
|
|
<option value="">Select...</option>
|
|
{stockPositions.map(pos => <option key={pos.id} value={pos.id}>{pos.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="form-control">
|
|
<label className="label" htmlFor="supplier_id">
|
|
<span className="label-text">Country</span>
|
|
</label>
|
|
<select id="country_of_origin_id" name="country_of_origin_id" value={formData.country_of_origin_id || ''} onChange={handleInputChange} className="select">
|
|
<option value="">Select country...</option>
|
|
{originCountries.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="form-control flex items-center">
|
|
<label className="label cursor-pointer">
|
|
<input type="checkbox" name="on_the_way" checked={formData.on_the_way} onChange={handleInputChange} className="checkbox mr-2" />
|
|
<span className="label-text">On The Way</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<div className="modal-action flex justify-between">
|
|
{isBatchMode && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={batchSelections.length === 0}
|
|
onClick={handleBatchSubmit}
|
|
>
|
|
Create Batch
|
|
</button>
|
|
)}
|
|
|
|
<button type="submit" className="btn btn-primary">
|
|
{isBatchMode ? 'Create Entry' : (editingEntry ? 'Update Entry' : 'Create Entry')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</dialog>
|
|
</AppLayout>
|
|
);
|
|
}
|