343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
import React from 'react'
|
|
import axios from 'axios'
|
|
import {toast} from 'react-hot-toast'
|
|
import {StockBatch, StockEntry, StockPosition, StockSection} from '@/types'
|
|
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
|
|
import {faCheckCircle, faTimesCircle} from '@fortawesome/free-solid-svg-icons'
|
|
|
|
interface Props {
|
|
onClose: () => void
|
|
selectedSection: StockSection | null
|
|
selectedPosition: StockPosition | null
|
|
}
|
|
|
|
const SetStockModal: React.FC<Props> = ({onClose, selectedSection, selectedPosition}) => {
|
|
const [quantity, setQuantity] = React.useState<string>('')
|
|
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
|
|
const [selectedEntry, setSelectedEntry] = React.useState<StockEntry | null>(null)
|
|
const [loading, setLoading] = React.useState(false)
|
|
const [isSuggestionsOpen, setIsSuggestionsOpen] = React.useState(false)
|
|
const [activeTab, setActiveTab] = React.useState<'batch' | 'item'>('item')
|
|
const handleSelect = (entry: StockEntry) => {
|
|
setSelectedEntry(entry)
|
|
setIsDropdownOpen(false)
|
|
}
|
|
|
|
const parsedQty = parseInt(quantity, 10)
|
|
const isNumber = !isNaN(parsedQty)
|
|
const capacity = selectedSection ? selectedSection.capacity : 0
|
|
const exceedsItem = isNumber && parsedQty > selectedEntry?.count
|
|
const exceedsCapacity = isNumber && parsedQty > capacity
|
|
const hasError = exceedsItem || exceedsCapacity
|
|
|
|
const readyToScan =
|
|
selectedEntry !== null &&
|
|
isNumber &&
|
|
parsedQty >= 1 &&
|
|
!exceedsItem &&
|
|
!exceedsCapacity
|
|
|
|
// Called when “Simulate Scan (ID=3)” is clicked
|
|
const handleScan = async () => {
|
|
|
|
|
|
if (!selectedEntry || !selectedSection) return
|
|
if (!readyToScan) {
|
|
toast.error('Please fix validation errors before confirming.')
|
|
return
|
|
}
|
|
|
|
|
|
setLoading(true)
|
|
const toastId = toast.loading('Storing…')
|
|
|
|
try {
|
|
let response
|
|
|
|
response = await axios.post(
|
|
'/api/pdaView/storeStock',
|
|
{
|
|
entry_id: selectedEntry?.id,
|
|
section_id: selectedSection?.section_id,
|
|
current_section: selectedEntry?.sections[0].section_id,
|
|
count_to_be_stored: parsedQty,
|
|
},
|
|
{withCredentials: true}
|
|
)
|
|
|
|
const {data} = response
|
|
console.log('data', data)
|
|
|
|
|
|
if (!data.success) {
|
|
toast.dismiss(toastId)
|
|
switch (data.error) {
|
|
case 'validation_failed':
|
|
toast.error('Validation failed. Check inputs.')
|
|
break
|
|
case 'not_found':
|
|
toast.error('Entry or section not found.')
|
|
break
|
|
case 'section_occupied':
|
|
toast.error('That section is already occupied.')
|
|
break
|
|
case 'insufficient_capacity':
|
|
toast.error('Not enough capacity in this section.')
|
|
break
|
|
case 'server_error':
|
|
default:
|
|
toast.error(data.message ?? 'Server error during storing.')
|
|
break
|
|
}
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
toast.dismiss(toastId)
|
|
toast.success('Stored successfully.')
|
|
resetAndClose()
|
|
} catch (err: any) {
|
|
toast.dismiss()
|
|
if (err.response && err.response.data) {
|
|
const payload = err.response.data
|
|
if (payload.error === 'validation_failed') {
|
|
toast.error('Validation failed. Check inputs.')
|
|
} else if (payload.error === 'not_found') {
|
|
toast.error('Entry or section not found.')
|
|
} else if (payload.error === 'section_occupied') {
|
|
toast.error('That section is already occupied.')
|
|
} else if (payload.error === 'insufficient_capacity') {
|
|
toast.error('Not enough capacity in this section.')
|
|
} else {
|
|
toast.error(payload.message || 'Unknown error occurred.')
|
|
}
|
|
} else {
|
|
toast.error('Network error. Please try again.')
|
|
}
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const resetAndClose = () => {
|
|
setQuantity('')
|
|
setSelectedEntry(null)
|
|
setLoading(false)
|
|
setIsSuggestionsOpen(false)
|
|
setIsDropdownOpen(false)
|
|
onClose()
|
|
}
|
|
|
|
|
|
const sectionEntries = React.useMemo(() => {
|
|
// 1) Strip out the `entries` field from each section
|
|
const sectionsWithoutEntries =
|
|
selectedPosition?.sections.map(({entries, ...sec}) => sec) || []
|
|
|
|
// 2) Flatten all entries, and tag each with:
|
|
// - the cleaned-up sections array
|
|
// - its own sectionId / sectionAddress
|
|
return (
|
|
selectedPosition?.sections.flatMap((section) =>
|
|
section.entries.map((entry) => ({
|
|
...entry,
|
|
sections: sectionsWithoutEntries,
|
|
sectionId: section.section_id,
|
|
sectionAddress: section.storage_address,
|
|
}))
|
|
) || []
|
|
)
|
|
}, [selectedPosition])
|
|
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<h3 className="font-bold text-lg">Store Stock</h3>
|
|
|
|
<div className="bg-base-100 border-base-300 p-6">
|
|
{/* Product Dropdown */}
|
|
<div className="form-control w-full">
|
|
<label className="label">
|
|
<span className="label-text">Product</span>
|
|
</label>
|
|
<div className="dropdown w-full">
|
|
<button
|
|
type="button"
|
|
className="btn w-full justify-between"
|
|
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
|
>
|
|
{selectedEntry ? (
|
|
<div className="flex items-center space-x-2">
|
|
<img
|
|
src={selectedEntry.physical_item.image_url}
|
|
alt={selectedEntry.physical_item.name}
|
|
className="w-6 h-6 rounded-full"
|
|
/>
|
|
<span>
|
|
{selectedEntry.physical_item.name} ({selectedEntry.count_stocked} uskladneno | {selectedEntry.count - selectedEntry.count_stocked} zbyva) [{selectedEntry.sections[0].storage_address}]
|
|
</span>
|
|
</div>
|
|
) : (
|
|
'Select product ...'
|
|
)}
|
|
<svg
|
|
className="fill-current w-4 h-4"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
d="M5.516 7.548a.75.75 0 0 1 1.06 0L10 10.972l3.424-3.424a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 0 1 0-1.06z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<ul
|
|
tabIndex={0}
|
|
className={`dropdown-content menu p-2 shadow bg-base-100 rounded-box w-full mt-1 ${
|
|
isDropdownOpen ? 'block' : 'hidden'
|
|
}`}
|
|
>
|
|
{sectionEntries.map((entry) => {
|
|
const stocked = entry.count_stocked
|
|
const total = entry.count
|
|
const remaining = total - stocked
|
|
|
|
return (
|
|
<li
|
|
key={entry.id}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelect(entry)}
|
|
className="flex items-center px-2 py-1 rounded hover:border-gray-100"
|
|
>
|
|
<img
|
|
src={entry.physical_item.image_url}
|
|
alt={entry.physical_item.name}
|
|
className="w-6 h-6 rounded-full mr-2"
|
|
/>
|
|
<span>
|
|
{entry.physical_item.name} ({entry.count_stocked} uskladneno | {remaining} zbyva) [{entry.sections[0].storage_address}]
|
|
</span>
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
{sectionEntries.length === 0 && (
|
|
<li>
|
|
<span className="px-2 py-1 text-gray-500">No items available</span>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quantity Input */}
|
|
<div className="form-control w-full">
|
|
<label className="label">
|
|
<span className="label-text">Quantity to store</span>
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={quantity}
|
|
onChange={(e) => setQuantity(e.target.value)}
|
|
className={`input input-bordered w-full ${hasError ? 'border-red-500' : ''}`}
|
|
min="1"
|
|
max={selectedEntry?.count}
|
|
required
|
|
/>
|
|
{selectedEntry && (
|
|
<span className="text-sm text-gray-500 mt-1">
|
|
Max items: {selectedEntry?.count} | Capacity: {capacity}
|
|
</span>
|
|
)}
|
|
|
|
{/* Validation Messages */}
|
|
{(exceedsItem || exceedsCapacity) && (
|
|
<div className="mt-2 space-y-1">
|
|
{exceedsItem && (
|
|
<div className="flex items-center text-red-600">
|
|
<FontAwesomeIcon icon={faTimesCircle} className="mr-2"/>
|
|
<span>Item quantity exceeded</span>
|
|
</div>
|
|
)}
|
|
{exceedsCapacity && (
|
|
<div className="flex items-center text-red-600">
|
|
<FontAwesomeIcon icon={faTimesCircle} className="mr-2"/>
|
|
<span>Maximum capacity exceeded</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Success indicator if valid */}
|
|
{readyToScan && (
|
|
<div className="mt-2 flex items-center text-green-600">
|
|
<FontAwesomeIcon icon={faCheckCircle} className="mr-2"/>
|
|
<span>Ready to store</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selectedSection && (
|
|
<div className="mt-2 mb-2 flex items-center text-primary">
|
|
Selected section: {selectedSection.storage_address}
|
|
</div>
|
|
)}
|
|
|
|
{/* Buttons: Suggested Sections & Simulate Scan */}
|
|
<div className="flex space-x-2">
|
|
<button type="button" className="btn" onClick={() => setIsSuggestionsOpen(true)}>
|
|
Suggested Sections
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-primary ${loading ? 'loading' : ''}`}
|
|
onClick={handleScan}
|
|
disabled={!readyToScan || loading}
|
|
>
|
|
{loading ? 'Storing…' : selectedSection ? 'Confirm' : 'Waiting for section scan...'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Waiting for scan indicator */}
|
|
{readyToScan && !loading && (
|
|
<div className="flex items-center space-x-2 mt-2">
|
|
<span>{loading ? 'Storing…' : selectedSection ? 'Waiting for confirm' : 'Waiting for section scan...'}</span>
|
|
<span className="loading loading-spinner text-primary"></span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cancel Button */}
|
|
<div className="modal-action flex justify-end pt-2">
|
|
<button type="button" className="btn" onClick={resetAndClose}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
{/* Suggested Sections Modal */}
|
|
{isSuggestionsOpen && (
|
|
<div className="modal modal-open">
|
|
<div className="modal-box">
|
|
<h3 className="font-bold text-lg">Suggested Sections</h3>
|
|
<ul className="mt-4 space-y-2">
|
|
<li>Section A (ID: 1)</li>
|
|
<li>Section B (ID: 2)</li>
|
|
<li>Section C (ID: 3)</li>
|
|
<li>Section D (ID: 4)</li>
|
|
</ul>
|
|
<div className="modal-action">
|
|
<button className="btn" onClick={() => setIsSuggestionsOpen(false)}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default SetStockModal
|