712 lines
39 KiB
TypeScript
712 lines
39 KiB
TypeScript
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<StockEntry, 'id'> = {
|
||
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<StockBatch[]>([]);
|
||
const [statuses, setStatuses] = useState<[]>([]);
|
||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
const [batchCount, setBatchCount] = useState(0);
|
||
const [batchPagination, setBatchPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||
const [batchSorting, setBatchSorting] = useState<MRT_SortingState>([{ id: 'updatedAt', desc: true }]);
|
||
const [batchFilter, setBatchFilter] = useState('');
|
||
|
||
const createDialogRef = useRef<HTMLDialogElement>(null);
|
||
const [batchForm, setBatchForm] = useState(defaultBatchForm);
|
||
const [batchFiles, setBatchFiles] = useState<FileWithType[]>([]);
|
||
|
||
const viewDialogRef = useRef<HTMLDialogElement>(null);
|
||
const [selectedBatch, setSelectedBatch] = useState<StockBatch | null>(null);
|
||
const [entries, setEntries] = useState<StockEntry[]>([]);
|
||
const [entriesLoading, setEntriesLoading] = useState(false);
|
||
const [entriesCount, setEntriesCount] = useState(0);
|
||
const [entriesPagination, setEntriesPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||
const [entriesSorting, setEntriesSorting] = useState<MRT_SortingState>([{ id: 'id', desc: false }]);
|
||
const [entriesFilter, setEntriesFilter] = useState('');
|
||
|
||
const [onTheWayEntries, setOnTheWayEntries] = useState<StockEntry[]>([]);
|
||
const [entriesOnTheWayLoading, setEntriesOnTheWayLoading] = useState(false);
|
||
const [onTheWayEntriesCount, setOnTheWayEntriesCount] = useState(0);
|
||
const [onTheWayEntriesPagination, setOnTheWayEntriesPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||
const [onTheWayEntriesSorting, setOnTheWayEntriesSorting] = useState<MRT_SortingState>([{ id: 'id', desc: false }]);
|
||
const [onTheWayEntriesFilter, setOnTheWayEntriesFilter] = useState('');
|
||
|
||
const [onTheWayEntriesSelections, setOnTheWayEntriesSelections] = useState<number[]>([]);
|
||
|
||
const entryDialogRef = useRef<HTMLDialogElement>(null);
|
||
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
|
||
const [entryForm, setEntryForm] = useState<Omit<StockEntry, 'id'>>({ ...defaultEntryForm });
|
||
|
||
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
|
||
const [positions, setPositions] = useState<DropdownOption[]>([]);
|
||
const [countries, setCountries] = useState<DropdownOption[]>([]);
|
||
|
||
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<MRT_ColumnDef<StockBatch>[]>(() => [
|
||
{ 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<string>(), 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<string>()) },
|
||
{ accessorKey: 'updated_at', header: 'Updated', Cell: ({ cell }) => formatDate(cell.getValue<string>()) },
|
||
], []);
|
||
|
||
const entryColumns = 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 },
|
||
],
|
||
[],
|
||
);
|
||
|
||
const entryOnTheWayColumns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
||
() => [
|
||
{
|
||
accessorKey: 'select',
|
||
header: 'Select',
|
||
Cell: ({ row }) => {
|
||
const id = row.original.id;
|
||
return (
|
||
<input
|
||
type="checkbox"
|
||
checked={onTheWayEntriesSelections.includes(id)}
|
||
onChange={() => 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<HTMLInputElement | HTMLSelectElement>) => {
|
||
const { name, value } = e.target;
|
||
setBatchForm(prev => ({ ...prev, [name]: name === 'supplierId' ? (value ? parseInt(value) : null) : value }));
|
||
};
|
||
|
||
const handleBatchFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||
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 (
|
||
<AppLayout title="Stock Batches" renderHeader={() => <h2 className="font-semibold text-xl">Stock Batches</h2>}>
|
||
<Head title="Stock Batches" />
|
||
<Toaster position="top-center" />
|
||
|
||
<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 shadow-xl sm:rounded-lg p-6">
|
||
<div className="flex justify-between mb-4">
|
||
<h1 className="text-2xl font-bold">Batches</h1>
|
||
<button onClick={openCreate} className="btn btn-primary">Add New Batch</button>
|
||
</div>
|
||
<MaterialReactTable
|
||
columns={batchColumns}
|
||
data={batches}
|
||
manualPagination manualSorting enableGlobalFilter
|
||
onPaginationChange={setBatchPagination}
|
||
onSortingChange={setBatchSorting}
|
||
onGlobalFilterChange={setBatchFilter}
|
||
rowCount={batchCount}
|
||
state={{ isLoading: batchLoading, pagination: batchPagination, sorting: batchSorting, globalFilter: batchFilter }}
|
||
muiTableBodyRowProps={({ row }) => ({ onClick: () => openView(row.original), style: { cursor: 'pointer' } })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<dialog ref={createDialogRef} className="modal">
|
||
<form onSubmit={handleBatchSubmit} className="modal-box p-6">
|
||
<button type="button" onClick={closeCreate} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||
<h3 className="font-bold text-lg mb-4">New Stock Batch</h3>
|
||
|
||
<div className="form-control mb-4">
|
||
<label htmlFor="supplierId" className="label"><span className="label-text">Supplier</span></label>
|
||
<select id="supplierId" name="supplierId" value={batchForm.supplierId ?? ''} onChange={handleBatchInputChange} className="select">
|
||
<option value="">Select supplier...</option>
|
||
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="form-control mb-4">
|
||
<label htmlFor="tracking_number" className="label"><span className="label-text">Tracking Number</span></label>
|
||
<input id="tracking_number" name="tracking_number" type="text" value={batchForm.tracking_number} onChange={handleBatchInputChange} className="input" />
|
||
</div>
|
||
|
||
<div className="form-control mb-4">
|
||
<label htmlFor="arrival_date" className="label"><span className="label-text">Arrival Date</span></label>
|
||
<input id="arrival_date" name="arrival_date" type="date" value={batchForm.arrival_date} onChange={handleBatchInputChange} className="input" />
|
||
</div>
|
||
|
||
<div className="form-control mb-4">
|
||
<label htmlFor="files" className="label"><span className="label-text">Upload Files</span></label>
|
||
<input id="files" type="file" multiple onChange={handleBatchFileChange} className="file-input" />
|
||
</div>
|
||
|
||
{batchFiles.map((f, i) => (
|
||
<div key={i} className="flex items-center mb-2 space-x-2">
|
||
<span>{f.file.name}</span>
|
||
<select value={f.fileType} onChange={e => handleBatchFileTypeChange(i, e.target.value as any)} className="select select-sm">
|
||
<option value="invoice">Invoice</option><option value="label">Label</option><option value="other">Other</option>
|
||
</select>
|
||
<button type="button" onClick={() => removeBatchFile(i)} className="btn btn-sm btn-error">Remove</button>
|
||
</div>
|
||
))}
|
||
|
||
<div className="modal-action">
|
||
<button type="submit" className="btn btn-primary">Create Batch</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog ref={viewDialogRef} className="modal">
|
||
<div className="modal-box max-w-6xl flex space-x-6 p-6 relative">
|
||
<button type="button" onClick={closeView} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||
<div className="w-1/3 flex flex-col justify-between">
|
||
|
||
<div>
|
||
<h3 className="font-bold text-lg mb-2">Batch Details</h3>
|
||
{selectedBatch && <ul className="list-disc list-inside">
|
||
<li><strong>ID:</strong> {selectedBatch.id}</li>
|
||
<li><strong>Supplier:</strong> {selectedBatch.supplier.name}</li>
|
||
<li><strong>Tracking #:</strong> {selectedBatch.tracking_number}</li>
|
||
<li><strong>Arrival:</strong> {formatDate(selectedBatch.arrival_date, false)}</li>
|
||
</ul>}
|
||
<div>
|
||
{/* existing status-history list */}
|
||
{/*{selectedBatch?.stock_entries?.map((entry) => (*/}
|
||
{/* <div key={entry.id} style={{ marginBottom: 16 }}>*/}
|
||
{/* <h4>Stock Entry {entry.id}</h4>*/}
|
||
{/* <ul>*/}
|
||
{/* {entry.status_history?.map((history, idx) => (*/}
|
||
{/* <li key={idx}>{history.status.name}</li>*/}
|
||
{/* ))}*/}
|
||
{/* </ul>*/}
|
||
{/* </div>*/}
|
||
{/*))}*/}
|
||
|
||
{/* ratio displays */}
|
||
<div style={{ marginTop: 24 }}>
|
||
<h3>Status Ratios</h3>
|
||
{selectedBatch && statusIds.map((id) => {
|
||
const { count, total, ratio } = calculateStatusRatio(selectedBatch.stock_entries, id);
|
||
const status_data = statuses.filter(s => s.id === id)[0];
|
||
return (
|
||
<p key={id}>
|
||
{status_data.name}: {count} / {total} (
|
||
{(ratio * 100).toFixed(1)}%)
|
||
</p>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="tooltip" data-tip="batch QR kod">
|
||
<button className="btn bg-[#666666] text-[19px]">
|
||
<FontAwesomeIcon icon={faQrcode} />
|
||
</button>
|
||
</div>
|
||
{hasNonCountedStatus && (
|
||
<div className="tooltip" data-tip="Prepocitat">
|
||
<button
|
||
className="btn btn-warning text-[19px]"
|
||
onClick={() => {
|
||
router.get(route('batchCounting', { selectedBatch: selectedBatch.id }));
|
||
}}
|
||
>
|
||
<FontAwesomeIcon icon={faListOl} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
|
||
</div>
|
||
</div>
|
||
<div className="w-2/3 flex flex-col">
|
||
<h3 className="font-bold text-lg mb-2">Stock Entries</h3>
|
||
<div className="flex-1 overflow-auto">
|
||
<MaterialReactTable
|
||
columns={entryColumns}
|
||
data={entries}
|
||
manualPagination manualSorting enableGlobalFilter
|
||
onPaginationChange={setEntriesPagination}
|
||
onSortingChange={setEntriesSorting}
|
||
onGlobalFilterChange={setEntriesFilter}
|
||
rowCount={entriesCount}
|
||
state={{ isLoading: entriesLoading, pagination: entriesPagination, sorting: entriesSorting, globalFilter: entriesFilter }}
|
||
muiTableBodyRowProps={({ row }) => ({ onClick: () => openEntry(row.original), style: { cursor: 'pointer' }})}
|
||
/>
|
||
</div>
|
||
<div className="mt-4 flex justify-between">
|
||
<div>
|
||
|
||
</div>
|
||
<div>
|
||
<button onClick={() => openEntry()} className="btn btn-secondary">Edit Items</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog ref={entryDialogRef} className="modal">
|
||
<form onSubmit={handleEntrySubmit} className={`modal-box flex space-x-4 p-6 ${!editingEntry ? 'max-w-full' : ''}`}>
|
||
<button type="button" onClick={closeEntry} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||
|
||
{!editingEntry &&
|
||
<div className="w-2/3">
|
||
<h3 className="font-bold text-lg mb-2">Select Incoming Items</h3>
|
||
<MaterialReactTable
|
||
columns={entryOnTheWayColumns}
|
||
data={onTheWayEntries.filter(e => e.on_the_way)}
|
||
manualPagination manualSorting enableGlobalFilter
|
||
onPaginationChange={setEntriesPagination}
|
||
onSortingChange={setEntriesSorting}
|
||
onGlobalFilterChange={setEntriesFilter}
|
||
rowCount={entriesCount}
|
||
state={{ isLoading: entriesOnTheWayLoading, pagination: onTheWayEntriesPagination, sorting: onTheWayEntriesSorting, globalFilter: onTheWayEntriesFilter }}
|
||
/>
|
||
</div>
|
||
}
|
||
|
||
<div className={!editingEntry ? 'w-1/3' : 'w-full'}>
|
||
<h3 className="font-bold text-lg mb-4">{editingEntry ? 'Edit Entry' : 'New Entry'}</h3>
|
||
|
||
{!editingEntry &&
|
||
<div className="form-control mb-4">
|
||
<Combobox value={entryForm.physical_item_id} onChange={val => setEntryForm(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>{entryForm.physical_item_id === item.id && <FontAwesomeIcon icon={faCheck} />}
|
||
</div>
|
||
</Combobox.Option>
|
||
))}
|
||
</Combobox.Options>
|
||
</Combobox>
|
||
</div>
|
||
}
|
||
|
||
|
||
<div className="form-control mb-4">
|
||
<label htmlFor="supplier_id" className="label"><span className="label-text">Supplier</span></label>
|
||
<select id="supplier_id" name="supplier_id" value={entryForm.supplier_id ?? ''} onChange={handleEntryInputChange} className="select">
|
||
<option value="">Select supplier...</option>
|
||
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||
<div className="form-control">
|
||
<label htmlFor="count" className="label"><span className="label-text">Count</span></label>
|
||
<input id="count" name="count" type="number" value={entryForm.count} onChange={handleEntryInputChange} className="input" />
|
||
</div>
|
||
<div className="form-control">
|
||
<label htmlFor="price" className="label"><span className="label-text">Price</span></label>
|
||
<input id="price" name="price" type="number" step="0.01" value={entryForm.price || ''} onChange={handleEntryInputChange} className="input" />
|
||
</div>
|
||
<div className="form-control">
|
||
<label htmlFor="bought" className="label"><span className="label-text">Bought Date</span></label>
|
||
<input id="bought" name="bought" type="date" value={entryForm.bought || ''} onChange={handleEntryInputChange} className="input" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-control mb-4">
|
||
<label htmlFor="description" className="label"><span className="label-text">Description</span></label>
|
||
<textarea id="description" name="description" value={entryForm.description || ''} onChange={handleEntryInputChange} className="textarea" />
|
||
</div>
|
||
<div className="form-control mb-4">
|
||
<label htmlFor="note" className="label"><span className="label-text">Note</span></label>
|
||
<textarea id="note" name="note" value={entryForm.note || ''} onChange={handleEntryInputChange} className="textarea" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 items-end mb-4">
|
||
<div className="form-control">
|
||
<label htmlFor="country_of_origin_id" className="label"><span className="label-text">Country</span></label>
|
||
<select id="country_of_origin_id" name="country_of_origin_id" value={entryForm.country_of_origin_id || ''} onChange={handleEntryInputChange} className="select">
|
||
<option value="">Select country...</option>
|
||
{countries.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-control flex items-center">
|
||
<label className="label cursor-pointer"><input type="checkbox" name="on_the_way" checked={entryForm.on_the_way} onChange={handleEntryInputChange} className="checkbox mr-2" /><span className="label-text">On The Way</span></label>
|
||
</div>
|
||
<div className="form-control flex items-center">
|
||
<label className="label cursor-pointer"><input type="checkbox" name="surplus_item" checked={entryForm.surplus_item} onChange={handleEntryInputChange} className="checkbox mr-2" /><span className="label-text">Extra produkt</span></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 items-end mb-4 border-t-2 border-gray-600 pt-4">
|
||
{/* Dynamic stock position rows */}
|
||
<div>
|
||
Stock positions
|
||
</div>
|
||
<div className="space-y-4 mb-4">
|
||
{positionRows.map((row, idx) => {
|
||
console.log(row);
|
||
// filter out positions already used in other rows
|
||
|
||
const available = positions.filter(p =>
|
||
// if we’re editing, show everything; otherwise only un‐occupied slots
|
||
(editingEntry ? true : !p.occupied)
|
||
// then still exclude any that are already picked in other rows
|
||
&& !positionRows.some((r,i) => i !== idx && r.stock_position_id === p.id)
|
||
);
|
||
return available.length > 0 ? (
|
||
<div key={idx} className="grid grid-cols-2 gap-4 items-end">
|
||
<div className="form-control">
|
||
<label className="label"><span
|
||
className="label-text">Position</span></label>
|
||
<select
|
||
value={row.stock_position_id || ''}
|
||
onChange={e => handlePositionRowChange(idx, 'stock_position_id', e.target.value ? parseInt(e.target.value) : null)}
|
||
className="select"
|
||
>
|
||
<option value="">Select position...</option>
|
||
{available.map(p => <option key={p.id}
|
||
value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-control">
|
||
<label className="label"><span
|
||
className="label-text">Count</span></label>
|
||
<input
|
||
type="number"
|
||
value={row.count ?? ''}
|
||
onChange={e => handlePositionRowChange(idx, 'count', e.target.value ? parseInt(e.target.value) : null)}
|
||
className="input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
): null;
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="modal-action">
|
||
|
||
{!editingEntry && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
disabled={onTheWayEntriesSelections.length === 0}
|
||
onClick={handleBatchAddEntries}
|
||
>
|
||
Add to batch
|
||
</button>
|
||
)}
|
||
<button type="submit" className="btn btn-primary">{editingEntry ? 'Update Entry' : 'Create Entry'}</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
</AppLayout>
|
||
);
|
||
}
|