import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Head } from '@inertiajs/react'; import AppLayout from '@/Layouts/AppLayout'; import axios from 'axios'; import { toast, Toaster } from 'react-hot-toast'; import { MaterialReactTable, type MRT_ColumnDef, type MRT_PaginationState, type MRT_SortingState, } from 'material-react-table'; import { Combobox } from '@headlessui/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import {faCheck, faQrcode, faListOl} from '@fortawesome/free-solid-svg-icons'; import {StockBatch, StockSection, Supplier} from '@/types'; import {size} from "lodash"; import { router } from "@inertiajs/react"; interface DropdownOption { id: number; name: string } interface FileWithType { file: File; fileType: 'invoice' | 'label' | 'other' } 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; physical_item?: DropdownOption; supplier?: DropdownOption; stock_position?: { id: number; line: string; rack: string; shelf: string; position: string }; sections?: (StockSection & { pivot: { section_id:number; count: number; created_at: string; updated_at: string | null } })[]; } const defaultBatchForm = { supplierId: null as number | null, tracking_number: '', arrival_date: '' }; const defaultEntryForm: Omit = { physical_item_id: null, supplier_id: null, count: 0, price: null, bought: null, description: null, note: null, country_of_origin_id: null, on_the_way: false, surplus_item: false, stock_batch_id: null, }; export default function StockBatches() { const [batches, setBatches] = useState([]); const [statuses, setStatuses] = useState<[]>([]); const [suppliers, setSuppliers] = useState([]); const [batchLoading, setBatchLoading] = useState(false); const [batchCount, setBatchCount] = useState(0); const [batchPagination, setBatchPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [batchSorting, setBatchSorting] = useState([{ id: 'updatedAt', desc: true }]); const [batchFilter, setBatchFilter] = useState(''); const createDialogRef = useRef(null); const [batchForm, setBatchForm] = useState(defaultBatchForm); const [batchFiles, setBatchFiles] = useState([]); const viewDialogRef = useRef(null); const [selectedBatch, setSelectedBatch] = useState(null); const [entries, setEntries] = useState([]); const [entriesLoading, setEntriesLoading] = useState(false); const [entriesCount, setEntriesCount] = useState(0); const [entriesPagination, setEntriesPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [entriesSorting, setEntriesSorting] = useState([{ id: 'id', desc: false }]); const [entriesFilter, setEntriesFilter] = useState(''); const [onTheWayEntries, setOnTheWayEntries] = useState([]); const [entriesOnTheWayLoading, setEntriesOnTheWayLoading] = useState(false); const [onTheWayEntriesCount, setOnTheWayEntriesCount] = useState(0); const [onTheWayEntriesPagination, setOnTheWayEntriesPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [onTheWayEntriesSorting, setOnTheWayEntriesSorting] = useState([{ id: 'id', desc: false }]); const [onTheWayEntriesFilter, setOnTheWayEntriesFilter] = useState(''); const [onTheWayEntriesSelections, setOnTheWayEntriesSelections] = useState([]); const entryDialogRef = useRef(null); const [editingEntry, setEditingEntry] = useState(null); const [entryForm, setEntryForm] = useState>({ ...defaultEntryForm }); const [physicalItems, setPhysicalItems] = useState([]); const [positions, setPositions] = useState([]); const [countries, setCountries] = useState([]); const [itemQuery, setItemQuery] = useState(''); const filteredItems = useMemo( () => itemQuery === '' ? physicalItems : physicalItems.filter(i => i.name.toLowerCase().includes(itemQuery.toLowerCase())), [itemQuery, physicalItems] ); // Add this state alongside your other hooks: const [positionRows, setPositionRows] = useState<{ stock_position_id: number | null; count: number | null; }[]>([{ stock_position_id: null, count: null }]); // Handler to update a row const handlePositionRowChange = (index: number, field: 'stock_position_id' | 'count', value: number | null) => { setPositionRows(prev => { const rows = prev.map((r, i) => i === index ? { ...r, [field]: value } : r); // if editing the last row's count, and it's a valid number, append a new empty row if (field === 'count' && index === prev.length - 1 && value && value > 0) { rows.push({ stock_position_id: null, count: null }); } return rows; }); }; const formatDate = (value: string, withTime = true) => { const d = new Date(value); const dd = String(d.getDate()).padStart(2, '0'); const mm = String(d.getMonth() + 1).padStart(2, '0'); const yyyy = d.getFullYear(); if (!withTime) return `${dd}-${mm}-${yyyy}`; const hh = String(d.getHours()).padStart(2, '0'); const mi = String(d.getMinutes()).padStart(2, '0'); return `${dd}-${mm}-${yyyy} ${hh}:${mi}`; }; const batchColumns = useMemo[]>(() => [ { accessorKey: 'id', header: 'ID' }, { accessorKey: 'supplier.name', header: 'Supplier' }, { accessorKey: 'tracking_number', header: 'Tracking #' }, { accessorKey: 'arrival_date', header: 'Arrival Date', Cell: ({ cell }) => formatDate(cell.getValue(), false) }, { accessorFn: r => r.files?.length ?? 0, id: 'files', header: 'Files' }, { accessorFn: r => r.stock_entries?.length ?? 0, id: 'items', header: 'Items' }, { accessorKey: 'created_at', header: 'Created', Cell: ({ cell }) => formatDate(cell.getValue()) }, { accessorKey: 'updated_at', header: 'Updated', Cell: ({ cell }) => formatDate(cell.getValue()) }, ], []); const entryColumns = useMemo[]>( () => [ { 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()?.toFixed(2) || '-' }, { accessorKey: 'bought', header: 'Bought Date', size: 120 }, { accessorKey: 'on_the_way', header: 'On The Way', size: 100, Cell: ({ cell }) => cell.getValue() ? '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 }, ], [], ); const entryOnTheWayColumns = useMemo[]>( () => [ { accessorKey: 'select', header: 'Select', Cell: ({ row }) => { const id = row.original.id; return ( setOnTheWayEntriesSelections(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' }, ], [onTheWayEntriesSelections] ); useEffect(() => { fetchOptions(); fetchEntriesOnTheWay(); fetchStatusList();}, []); useEffect(() => { fetchBatches(); }, [batchPagination, batchSorting, batchFilter]); useEffect(() => { if (selectedBatch) fetchEntries(selectedBatch.id); }, [entriesPagination, entriesSorting, entriesFilter]); async function fetchSuppliers() { try { const res = await axios.get('/api/stockBatches/options'); setSuppliers(res.data.suppliers); } catch { toast.error('Cannot load suppliers'); } } async function fetchOptions() { try { const res = await axios.get('/api/stockData/options'); setSuppliers(res.data.suppliers); setPositions(res.data.stockPositions); console.log(res.data.stockPositions); setCountries(res.data.countriesOrigin); } catch { toast.error('Cannot load entry options'); } } async function fetchBatches() { setBatchLoading(true); try { const res = await axios.get('/api/stockBatches', { params: { page: batchPagination.pageIndex+1, perPage: batchPagination.pageSize, sortField: batchSorting[0].id, sortOrder: batchSorting[0].desc?'desc':'asc', filter: batchFilter } }); console.log(res.data.data);setBatches(res.data.data); setBatchCount(res.data.meta.total); } catch { toast.error('Cannot fetch batches'); } finally { setBatchLoading(false); } } async function fetchEntries(batchId: number) { setEntriesLoading(true); console.log("fetching entries for batch ", batchId); try { const res = await axios.get(`/api/stockBatches/${batchId}/entries`, { params: { page: entriesPagination.pageIndex+1, perPage: entriesPagination.pageSize, sortField: entriesSorting[0].id, sortOrder: entriesSorting[0].desc?'desc':'asc', filter: entriesFilter } }); console.log(res.data.data);setEntries(res.data.data); setEntriesCount(size(res.data.data)); } catch (error) { toast.error('Cannot fetch entries'); console.error(error); } finally { setEntriesLoading(false); } } async function fetchEntriesOnTheWay() { setEntriesOnTheWayLoading(true); console.log("fetching entries on the way "); try { const res = await axios.get(`/api/stockDataOnTheWay`); console.log(res.data.data); setOnTheWayEntries(res.data.data); setOnTheWayEntriesCount(size(res.data.data)); } catch (error) { toast.error('Cannot fetch entries'); console.error(error); } finally { setEntriesOnTheWayLoading(false); } } const openCreate = () => createDialogRef.current?.showModal(); const closeCreate = () => { createDialogRef.current?.close(); setBatchForm(defaultBatchForm); setBatchFiles([]); }; const openView = (batch: StockBatch) => { setSelectedBatch(batch); fetchEntries(batch.id); viewDialogRef.current?.showModal(); }; const closeView = () => { viewDialogRef.current?.close(); setEntries([]); } const openEntry = (entry?: StockEntry) => { fetchEntriesOnTheWay(); if (entry) { setEditingEntry(entry); setEntryForm({ ...entry }); // Build rows from whatever pivot’d sections came back const rows = entry.sections?.map(sec => ({ stock_position_id: sec.pivot.section_id, count: sec.pivot.count, })) || []; console.log(entry.sections); // Append an empty row so the user can add more setPositionRows([...rows, { stock_position_id: null, count: null }]); } else { setEditingEntry(null); setEntryForm({ ...defaultEntryForm, stock_batch_id: selectedBatch!.id }); // brand-new: start with one empty row setPositionRows([{ stock_position_id: null, count: null }]); } entryDialogRef.current?.showModal(); }; const closeEntry = () => { entryDialogRef.current?.close(); setEditingEntry(null); }; const handleBatchInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setBatchForm(prev => ({ ...prev, [name]: name === 'supplierId' ? (value ? parseInt(value) : null) : value })); }; const handleBatchFileChange = (e: React.ChangeEvent) => { if (e.target.files) { setBatchFiles(Array.from(e.target.files).map(file => ({ file, fileType: 'other' }))); } }; const handleBatchFileTypeChange = (idx: number, type: FileWithType['fileType']) => { setBatchFiles(prev => prev.map((f, i) => i === idx ? { ...f, fileType: type } : f)); }; const removeBatchFile = (idx: number) => { setBatchFiles(prev => prev.filter((_, i) => i !== idx)); }; const handleBatchSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { const fd = new FormData(); if (batchForm.supplierId) fd.append('supplier_id', batchForm.supplierId.toString()); if (batchForm.tracking_number) fd.append('tracking_number', batchForm.tracking_number); if (batchForm.arrival_date) fd.append('arrival_date', batchForm.arrival_date); batchFiles.forEach(({ file, fileType }, i) => { fd.append(`files[${i}]`, file); fd.append(`file_types[${i}]`, fileType); }); await axios.post('/api/stockBatches', fd, { headers: { 'Content-Type': 'multipart/form-data' } }); toast.success('Batch created'); closeCreate(); fetchBatches(); } catch (err: any) { if (axios.isAxiosError(err) && err.response?.status === 422) { Object.values(err.response.data.errors).flat().forEach((m: string) => toast.error(m)); } else { toast.error('Failed to create batch'); } } }; const handleBatchAddEntries = async (e: React.FormEvent) => { e.preventDefault(); console.log(selectedBatch); try { await axios.put(`/api/stockBatches/${selectedBatch.id}/entries`, { ids: onTheWayEntriesSelections }); toast.success('Batch entries updated successfully'); closeEntry(); fetchBatches(); setSelectedBatch(null); setOnTheWayEntriesSelections([]); closeView(); } catch { toast.error('Batch entries update failed'); } }; const handleEntryInputChange = (e: React.ChangeEvent) => { const { name, value, type, checked } = e.target; setEntryForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : type === 'number' ? parseFloat(value) || null : value || null, } as any)); }; const handleEntrySubmit = async (e: React.FormEvent) => { e.preventDefault(); try { if (editingEntry) await axios.put(`/api/stockData/${editingEntry.id}`, { entryForm, sections: positionRows.filter(r => r.stock_position_id && r.count)}); else await axios.post(`/api/stockData`, entryForm); toast.success(editingEntry ? 'Entry updated' : 'Entry created'); fetchEntries(selectedBatch!.id); closeEntry(); } catch { toast.error('Cannot save entry'); } }; // Before your return JSX, replace the old flag with this: const hasNonCountedStatus = selectedBatch?.stock_entries?.some(stockEntry => { // 1. Only statuses with no section: const nullSectionStatuses = stockEntry.status_history?.filter(h => h.section_id === null) ?? []; if (nullSectionStatuses.length === 0) return true; // 2. Find the *latest* one by timestamp: const latest = nullSectionStatuses.reduce((prev, curr) => new Date(prev.created_at) > new Date(curr.created_at) ? prev : curr ); // 3. Check if that status isn’t “COUNTED” (id === 2) return latest.stock_entries_status_id !== 2; }); function calculateStatusRatio( entries: StockEntry[], statusId: number ): { count: number; total: number; ratio: number } { const total = entries.length; const count = entries.filter((entry) => entry.status_history?.some((h) => h.stock_entries_status_id === statusId) ).length; const ratio = total > 0 ? count / total : 0; return { count, total, ratio }; } async function fetchStatusList() { try { const res = await axios.get('/api/stockStatusList'); setStatuses(res.data.statuses); } catch { toast.error('Failed to load items'); } } 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]); const statusIds = [2, 6, 7, 8] return (

Stock Batches

}>

Batches

({ onClick: () => openView(row.original), style: { cursor: 'pointer' } })} />

New Stock Batch

{batchFiles.map((f, i) => (
{f.file.name}
))}

Batch Details

{selectedBatch &&
  • ID: {selectedBatch.id}
  • Supplier: {selectedBatch.supplier.name}
  • Tracking #: {selectedBatch.tracking_number}
  • Arrival: {formatDate(selectedBatch.arrival_date, false)}
}
{/* existing status-history list */} {/*{selectedBatch?.stock_entries?.map((entry) => (*/} {/*
*/} {/*

Stock Entry {entry.id}

*/} {/*
    */} {/* {entry.status_history?.map((history, idx) => (*/} {/*
  • {history.status.name}
  • */} {/* ))}*/} {/*
*/} {/*
*/} {/*))}*/} {/* ratio displays */}

Status Ratios

{selectedBatch && statusIds.map((id) => { const { count, total, ratio } = calculateStatusRatio(selectedBatch.stock_entries, id); const status_data = statuses.filter(s => s.id === id)[0]; return (

{status_data.name}: {count} / {total} ( {(ratio * 100).toFixed(1)}%)

); })}
{hasNonCountedStatus && (
)}

Stock Entries

({ onClick: () => openEntry(row.original), style: { cursor: 'pointer' }})} />
{!editingEntry &&

Select Incoming Items

e.on_the_way)} manualPagination manualSorting enableGlobalFilter onPaginationChange={setEntriesPagination} onSortingChange={setEntriesSorting} onGlobalFilterChange={setEntriesFilter} rowCount={entriesCount} state={{ isLoading: entriesOnTheWayLoading, pagination: onTheWayEntriesPagination, sorting: onTheWayEntriesSorting, globalFilter: onTheWayEntriesFilter }} />
}

{editingEntry ? 'Edit Entry' : 'New Entry'}

{!editingEntry &&
setEntryForm(prev => ({ ...prev, physical_item_id: val }))}> setItemQuery(e.target.value)} displayValue={id => physicalItems.find(i => i.id === id)?.name || ''} placeholder="Select item..." className="input" /> {filteredItems.map(item => (
{item.name}{entryForm.physical_item_id === item.id && }
))}
}