vat_wms/resources/js/Pages/StockEntries.tsx

718 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
type MRT_ColumnFiltersState,
} 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';
import {Autocomplete, TextField} from '@mui/material';
// --- 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 [manufacturers, setManufacturers] = 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: 50});
const [sorting, setSorting] = useState<MRT_SortingState>([{id: 'updated_at', desc: true}]);
const [globalFilter, setGlobalFilter] = useState('');
const [columnFilters, setColumnFilters] = useState<MRT_ColumnFiltersState>([])
// holds the full objects for whatever the user has chosen
const [physicalItemFilterOptions, setPhysicalItemFilterOptions] = useState<DropdownOption[]>([]);
// 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},
{
id: 'physical_item_id',
header: 'Physical Item',
// normal tablecell display
accessorFn: row => row.physical_item?.name ?? '-',
cell: ({row}) => row.original.physical_item?.name ?? '-',
enableColumnFilter: true,
Filter: ({column}) => {
// the numeric IDs MRT is storing under the hood
const selectedIds = (column.getFilterValue() as number[]) ?? [];
// 1) our “persistent” list of chosen DropdownOption objects
const selectedOptions = physicalItemFilterOptions;
// 2) the transient search results
// (you already have itemQuery & filteredItems above)
const suggestionOptions = filteredItems
.filter(item => !selectedIds.includes(item.id));
// merge them so selected stay in the list:
const options = [...selectedOptions, ...suggestionOptions];
return (
<div className="space-y-1">
{/* Show chosen items as chips */}
<div className="flex flex-wrap gap-1">
{selectedOptions.map(item => (
<span
key={item.id}
className="badge badge-sm badge-primary cursor-pointer"
onClick={() =>
// remove from both MRT and our state
{
const newIds = selectedIds.filter(id => id !== item.id);
column.setFilterValue(newIds);
setPhysicalItemFilterOptions(opts =>
opts.filter(o => o.id !== item.id)
);
}
}
>
{item.name} ×
</span>
))}
</div>
{/* The multiselect Combobox */}
<Combobox
multiple
value={selectedOptions}
onChange={(newSelection: DropdownOption[]) => {
// 1) tell MRT about the new array of IDs
column.setFilterValue(newSelection.map(i => i.id));
// 2) persist the objects for future renders
setPhysicalItemFilterOptions(newSelection);
}}
>
<div className="relative">
<Combobox.Input
className="input w-full"
placeholder="Type to search…"
onChange={e => setItemQuery(e.target.value)}
displayValue={() => ''} // keep the input box empty
/>
<Combobox.Options
className="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-h-60 overflow-auto">
{options.map(item => (
<Combobox.Option
key={item.id}
value={item}
className="cursor-pointer p-2 hover:bg-gray-200 flex justify-between items-center"
>
{item.name}
{selectedIds.includes(item.id) && <FontAwesomeIcon icon={faCheck}/>}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</Combobox>
</div>
);
},
filterFn: (row, _columnId, filterValue: number[]) =>
filterValue.length === 0 ||
filterValue.includes(row.original.physical_item_id!),
},
// 2) Supplier
{
id: 'supplier_id',
header: 'Supplier',
accessorFn: row => row.supplier?.name ?? '-',
cell: ({row}) => row.original.supplier?.name ?? '-',
enableColumnFilter: true,
Filter: ({column}) => (
<Autocomplete
multiple
size="small"
options={suppliers}
getOptionLabel={opt => opt.name}
isOptionEqualToValue={(opt, val) => opt.id === val.id}
value={
suppliers.filter(s =>
(column.getFilterValue() as number[] || []).includes(s.id)
)
}
onChange={(_, v) =>
column.setFilterValue(v.map(s => s.id))
}
renderInput={params => (
<TextField {...params} variant="standard" placeholder="Filter suppliers…"/>
)}
/>
),
filterFn: (row, _columnId, filterValue: number[]) =>
filterValue.length === 0 || filterValue.includes(row.original.supplier_id!),
},
// 3) Brand / Manufacturer
{
id: 'manufacturer_id',
header: 'Brand',
accessorFn: row => row.physical_item?.manufacturer?.name ?? '-',
cell: ({row}) => row.original.physical_item?.manufacturer?.name ?? '-',
enableColumnFilter: true,
Filter: ({column}) => (
<Autocomplete
multiple
size="small"
options={manufacturers}
getOptionLabel={opt => opt.name}
isOptionEqualToValue={(opt, val) => opt.id === val.id}
value={
manufacturers.filter(m =>
(column.getFilterValue() as number[] || []).includes(m.id)
)
}
onChange={(_, v) =>
column.setFilterValue(v.map(m => m.id))
}
renderInput={params => (
<TextField {...params} variant="standard" placeholder="Filter brands…"/>
)}
/>
),
filterFn: (row, _columnId, filterValue: number[]) =>
filterValue.length === 0 ||
filterValue.includes(row.original.physical_item?.manufacturer?.id!),
},
{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},
],
[suppliers, manufacturers, physicalItems, itemQuery, physicalItemFilterOptions],
);
// 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, columnFilters]);
useEffect(() => {
fetchOptions();
}, []);
useEffect(() => {
setPagination((old) => ({...old, pageIndex: 0}));
}, [globalFilter, columnFilters]);
async function fetchOptions() {
try {
const [
{data: opts},
{data: mfrData},
] = await Promise.all([
axios.get('/api/stockData/options'),
axios.get('/api/stockData/manufacturers'), // ← new endpoint
]);
setStockPositions(opts.stockPositions);
setSuppliers(opts.suppliers);
setOriginCountries(opts.countriesOrigin);
setManufacturers(mfrData.manufacturers); // ← set manufacturers
} catch {
toast.error('Failed to load form options');
}
}
async function fetchData() {
// trigger loading states
setIsLoading(!data.length);
setIsRefetching(!!data.length);
const supplierFilter = columnFilters.find(f => f.id === 'supplier_id')?.value
const brandFilter = columnFilters.find(f => f.id === 'manufacturer_id')?.value
const itemFilter = columnFilters.find(f => f.id === 'physical_item_id')?.value
console.log(columnFilters);
try {
const res = await axios.get('/api/stockData', {
params: {
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
sort_field: sorting[0]?.id || 'updated_at',
sort_direction: sorting[0]?.desc ? 'desc' : 'asc',
search: globalFilter,
supplier_id: supplierFilter ?? undefined,
manufacturer_id: brandFilter ?? undefined,
physical_item_id: itemFilter ?? undefined,
},
});
// update rows and total count
setData(res.data.data);
setRowCount(res.data.meta.total);
// (optional) keep your pagination widget in sync
// setPagination(old => ({
// ...old,
// pageIndex: res.data.meta.current_page - 1,
// }));
} catch (error) {
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({
physical_item_id: entry.physical_item_id,
supplier_id: entry.supplier_id,
count: entry.count,
price: entry.price,
bought: entry.bought,
description: entry.description,
note: entry.note,
stock_position_id: entry.stock_position_id,
country_of_origin_id: entry.country_of_origin_id,
on_the_way: entry.on_the_way,
stock_batch_id: entry.stock_batch_id,
});
// make sure our combobox can render the current item
if (entry.physical_item) {
setPhysicalItems(prev =>
prev.some(i => i.id === entry.physical_item!.id)
? prev
: [...prev, entry.physical_item!]
);
}
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">
<Head title="Stock Entries"/>
<div className="py-6">
<div className="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
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
onPaginationChange={setPagination}
onSortingChange={setSorting}
onGlobalFilterChange={setGlobalFilter}
manualPagination
rowCount={rowCount}
state={{
isLoading,
pagination,
showProgressBars: isRefetching,
sorting,
globalFilter,
columnFilters
}}
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>,
]}
muiTablePaginationProps={{
rowsPerPageOptions: [25, 50, 100, 200, 500, 1000, 2000, 5000],
rowsPerPage: pagination.pageSize,
}}
/>
</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>
);
}