911 lines
40 KiB
TypeScript
911 lines
40 KiB
TypeScript
// StockBatches.tsx
|
||
|
||
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 {PhysicalItem, StockBatch, StockSection, Supplier} from '@/types';
|
||
import { size } from 'lodash';
|
||
import { router } from '@inertiajs/react';
|
||
import BatchInfoWindow from '@/Components/BatchInfoWindow';
|
||
|
||
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;
|
||
surplus_item: boolean;
|
||
stock_batch_id: number;
|
||
physical_item?: PhysicalItem;
|
||
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 [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<PhysicalItem[]>([]);
|
||
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 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();
|
||
}, []);
|
||
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);
|
||
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);
|
||
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,
|
||
},
|
||
});
|
||
setEntries(res.data.data);
|
||
setEntriesCount(size(res.data.data));
|
||
setSelectedBatch((b) =>
|
||
b && b.id === batchId
|
||
? { ...b, stock_entries: res.data.data }
|
||
: b
|
||
)
|
||
} catch (error) {
|
||
toast.error('Cannot fetch entries');
|
||
console.error(error);
|
||
} finally {
|
||
setEntriesLoading(false);
|
||
}
|
||
}
|
||
|
||
async function fetchEntriesOnTheWay() {
|
||
setEntriesOnTheWayLoading(true);
|
||
try {
|
||
const res = await axios.get(`/api/stockDataOnTheWay`);
|
||
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([]);
|
||
setSelectedBatch(null);
|
||
};
|
||
|
||
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,
|
||
})) || [];
|
||
setPositionRows([...rows, { stock_position_id: null, count: null }]);
|
||
} else {
|
||
setEditingEntry(null);
|
||
setEntryForm({ ...defaultEntryForm, stock_batch_id: selectedBatch!.id });
|
||
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 }) => {
|
||
fd.append('files[]', file); // note the []
|
||
fd.append('file_types[]', fileType); // note the []
|
||
});
|
||
// await axios.post('/api/stockBatches', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||
await axios.post('/api/stockBatches', fd);
|
||
|
||
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();
|
||
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 {
|
||
entryForm.supplier_id = selectedBatch?.supplier_id || null;
|
||
await axios.post(`/api/stockData`, entryForm);
|
||
}
|
||
toast.success(editingEntry ? 'Entry updated' : 'Entry created');
|
||
fetchEntries(selectedBatch!.id);
|
||
closeEntry();
|
||
} catch {
|
||
toast.error('Cannot save entry');
|
||
}
|
||
};
|
||
|
||
|
||
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]);
|
||
|
||
|
||
return (
|
||
<AppLayout title="Stock Batches" renderHeader={() => <h2 className="font-semibold text-xl">Stock Batches</h2>}>
|
||
<Head title="Stock Batches" />
|
||
<Toaster position="top-center" />
|
||
|
||
{/* ===== Main Batches Table ===== */}
|
||
<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>
|
||
|
||
{/* ===== Create New Batch Dialog ===== */}
|
||
<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"
|
||
name="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>
|
||
|
||
{/* ===== View Batch Details Dialog ===== */}
|
||
<dialog ref={viewDialogRef} className="modal">
|
||
<div className="modal-box max-w-6xl flex flex-col 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>
|
||
|
||
{/* Replace inline batch‐details/table with <BatchInfoWindow /> */}
|
||
{selectedBatch && (
|
||
<BatchInfoWindow
|
||
selectedBatch={selectedBatch}
|
||
formatDate={formatDate}
|
||
router={router}
|
||
route={(name, params) => route(name, params)}
|
||
entries={entries}
|
||
openEntry={openEntry}
|
||
recountEnabled={true}
|
||
/>
|
||
)}
|
||
|
||
<div className="mt-4 flex justify-between">
|
||
<div />
|
||
<div>
|
||
<button onClick={() => openEntry()} className="btn btn-secondary">
|
||
Edit Items
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
{/* ===== Entry Dialog (unchanged) ===== */}
|
||
<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 => {
|
||
const itm = physicalItems.find(i => i.id === id)
|
||
return itm ? `${itm.name} - ${itm.type._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) => {
|
||
return (
|
||
<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} - {item.type._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={selectedBatch?.supplier.id ?? ''}
|
||
disabled={true}
|
||
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>
|
||
|
||
<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>
|
||
{!selectedBatch && (
|
||
<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>
|
||
)}
|
||
|
||
{selectedBatch && (
|
||
<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) => {
|
||
const available = positions.filter(
|
||
(p) =>
|
||
(editingEntry ? true : !p.occupied) &&
|
||
!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"
|
||
disabled={true}
|
||
>
|
||
<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"
|
||
disabled={true}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|