524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
import React, {useEffect, useState} from 'react'
|
||
import axios from 'axios'
|
||
import {Head, usePage} from '@inertiajs/react'
|
||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
|
||
import {
|
||
faArrowLeft,
|
||
faQuestionCircle,
|
||
faBoxOpen,
|
||
faClipboardList,
|
||
faCubes,
|
||
faPlus, faBarcode,
|
||
} from '@fortawesome/free-solid-svg-icons'
|
||
import {toast, Toaster} from 'react-hot-toast'
|
||
|
||
import Tile from '../Components/Tile'
|
||
import TileLayout from '../Components/TileLayout'
|
||
import ModalPDA from '../Components/ModalPDA'
|
||
|
||
// your extracted modal forms:
|
||
import SetStockModal from '../Components/modals/SetStockModal'
|
||
import OtherReplacementModal from '../Components/modals/OtherReplacementModal'
|
||
import CountStockModal from '../Components/modals/CountStockModal'
|
||
import {Batch} from "@/interfaces/interfaces";
|
||
import {StockBatch, StockPosition, StockSection} from "@/types";
|
||
import BatchInfoModal from "@/Components/modals/BatchInfoModal";
|
||
import {downloadBatchBarcode} from "@/functions/functions"
|
||
import EditStockSections from "@/Components/modals/EditStockSections";
|
||
import MoveSectionModal from "@/Components/modals/MoveSectionModal";
|
||
import ChangeCountModal from "@/Components/modals/ChangeCountModal";
|
||
|
||
import error_scanner_sound from "@/sounds/error_scanner.mp3";
|
||
import SetStockModalFromTemp from "@/Components/modals/SetStockModalFromTemp";
|
||
|
||
type Role = 'Expedice' | 'Skladnik'
|
||
|
||
// actions per role
|
||
const roleActions: Record<Role, string[]> = {
|
||
Expedice: ['stockSectionScanned', 'labelScanned'],
|
||
Skladnik: ['batchScan', 'stockScan'],
|
||
}
|
||
|
||
// configuration for each tile: either opens a modal (modalKey),
|
||
// or performs an API call (onClick)
|
||
type TileConfig = {
|
||
title: string
|
||
icon: any
|
||
modalKey?: ModalKey
|
||
onClick?: () => void
|
||
}
|
||
|
||
|
||
type ModalKey =
|
||
'setStock'
|
||
| 'setStockFromTemp'
|
||
| 'otherReplacement'
|
||
| 'countStock'
|
||
| 'batchInfo'
|
||
| 'editStock'
|
||
| 'moveStock'
|
||
| 'changeCount'
|
||
| null
|
||
|
||
type PdaViewProps = { closeParent: () => void };
|
||
|
||
export default function PdaView({closeParent}: PdaViewProps) {
|
||
|
||
const tilesConfig: Record<Role, Record<string, TileConfig[]>> = {
|
||
Expedice: {
|
||
stockSectionScanned: [
|
||
{title: 'Not Present', icon: faBoxOpen, onClick: () => toast('Not Present clicked')},
|
||
{title: 'Na pozici je jiny ovladac', icon: faBoxOpen, onClick: () => toast('Not Present clicked')},
|
||
{
|
||
title: 'Present but Shouldn’t',
|
||
icon: faClipboardList,
|
||
onClick: async () => {
|
||
// example direct axios call
|
||
try {
|
||
await axios.post('/api/presence-error', {}, {withCredentials: true})
|
||
toast.success('Reported!')
|
||
} catch {
|
||
toast.error('Failed to report')
|
||
}
|
||
},
|
||
},
|
||
{title: 'Other Replacement (na test/one time)', icon: faPlus, modalKey: 'otherReplacement'},
|
||
{title: 'Pridej vazbu na jiny ovladac', icon: faPlus, onClick: () => toast('Batch Info clicked')},
|
||
{
|
||
title: 'Pridej docasnou vazbu (nez prijde jine zbozi, i casove omezene, nejnizsi priorita)',
|
||
icon: faPlus,
|
||
onClick: () => toast('Batch Info clicked')
|
||
},
|
||
{title: 'Doplnit zbozi / dej vic kusu', icon: faPlus, onClick: () => toast('Batch Info clicked')},
|
||
{
|
||
title: 'Report - chci zmenit pozici(bliz / dal od expedice)',
|
||
icon: faPlus,
|
||
onClick: () => toast('Batch Info clicked')
|
||
},
|
||
{
|
||
title: 'Pultovy prodej',
|
||
icon: faPlus,
|
||
onClick: () => toast('- naskenuju na webu / naskenuju na skladovem miste, obj. se udela automaticky, zakaznik si muze v rohu na PC vyplnit osobni udaje na special formulari - pak customera prida obsluha do obj')
|
||
},
|
||
],
|
||
labelScanned: [
|
||
{title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked')},
|
||
]
|
||
},
|
||
Skladnik: {
|
||
batchScan: [
|
||
{title: 'Batch Info', icon: faClipboardList, modalKey: 'batchInfo'},
|
||
{title: 'Set Stock', icon: faCubes, modalKey: 'setStock'},
|
||
{title: 'Count Stock', icon: faPlus, modalKey: 'countStock'},
|
||
{
|
||
title: 'Stitkovani (male stitky)',
|
||
icon: faClipboardList,
|
||
onClick: () => toast('Stitkovani (male stitky)')
|
||
},
|
||
{
|
||
title: 'Tisk QR kod na krabice',
|
||
icon: faClipboardList,
|
||
onClick: () => downloadBatchBarcode(selectedBatch?.id)
|
||
},
|
||
],
|
||
stockScan: [
|
||
{title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked')},
|
||
{title: 'Set Stock', icon: faCubes, modalKey: 'setStockFromTemp'},
|
||
{
|
||
title: 'Zmena skladoveho mista (i presun jen casti kusu)',
|
||
icon: faClipboardList,
|
||
modalKey: 'moveStock'
|
||
},
|
||
{title: 'Uprava skladoveho mista (mene sekci, apod)', icon: faClipboardList, modalKey: 'editStock'},
|
||
{title: 'Zmena poctu', icon: faClipboardList, modalKey: 'changeCount'},
|
||
{
|
||
title: 'Discard (odebrat ze skladoveho mista / posilame zpet)',
|
||
icon: faClipboardList,
|
||
onClick: () => toast('Batch Info clicked')
|
||
},
|
||
],
|
||
others: [
|
||
{title: 'Nove skladove misto', icon: faClipboardList, onClick: () => toast('Batch Info clicked')},
|
||
]
|
||
},
|
||
}
|
||
|
||
const {
|
||
auth: {user},
|
||
} = usePage<{ auth: { user: { role: string } } }>().props
|
||
|
||
const [role, setRole] = React.useState<Role>(
|
||
user.role === 'admin' ? 'Expedice' : (user.role as Role)
|
||
)
|
||
const [action, setAction] = React.useState<string>('')
|
||
const [activeModal, setActiveModal] = React.useState<ModalKey>(null)
|
||
|
||
const isAdmin = true
|
||
// const isAdmin = user.role === 'admin'
|
||
const tabs: Role[] = ['Expedice', 'Skladnik']
|
||
|
||
const [selectedSerial, setSelectedSerial] = useState(null);
|
||
|
||
const selectedSectionRef = React.useRef<StockSection | null>(null)
|
||
const selectedPositionRef = React.useRef<StockPosition | null>(null)
|
||
|
||
const closeModal = () => {
|
||
setActiveModal(null);
|
||
if(selectedBatch) {
|
||
refetchData();
|
||
}
|
||
|
||
}
|
||
|
||
|
||
const refetchData = async () => {
|
||
|
||
try {
|
||
const res = await axios.get('/api/stockBatches/' + selectedBatch?.id);
|
||
console.log(res.data);
|
||
setSelectedBatch(res.data.batch);
|
||
} catch {
|
||
toast.error('Unable to reload batch data');
|
||
}
|
||
|
||
}
|
||
|
||
// const closeModal = () => {
|
||
// setActiveModal(null)
|
||
// setPrevPosition(null)
|
||
// setPrevSection(null)
|
||
// setSelectedPosition(null)
|
||
// setSelectedSection(null)
|
||
// }
|
||
|
||
const wrapOnClick = (orig?: () => void) => {
|
||
return () => {
|
||
if (orig) orig();
|
||
// after the action, close the PDA modal:
|
||
closeParent();
|
||
};
|
||
};
|
||
|
||
const [selectedBatch, setSelectedBatch] = React.useState<StockBatch>();
|
||
const [selectedPosition, setSelectedPosition] = React.useState<StockPosition>();
|
||
const [selectedSection, setSelectedSection] = React.useState<StockSection>();
|
||
|
||
const [prevPosition, setPrevPosition] = useState<StockPosition | null>(null)
|
||
const [prevSection, setPrevSection] = useState<StockSection | null>(null)
|
||
|
||
useEffect(() => {
|
||
// fetchBatches();
|
||
// fetchPosition();
|
||
initScanner();
|
||
}, []);
|
||
|
||
const initScanner = () => {
|
||
// run once on mount
|
||
(async () => {
|
||
// see if we already have permission for any ports
|
||
const ports = await navigator.serial.getPorts();
|
||
console.log("ports", ports);
|
||
if (ports.length > 0) {
|
||
// pick the first (or filter by vendorId/productId)
|
||
const port = ports[0];
|
||
setSelectedSerial(port);
|
||
// now you can immediately open it:
|
||
connectToScanner(port);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const handlePortRequest = async () => {
|
||
try {
|
||
const port = await navigator.serial.requestPort(); // <-- user gesture!
|
||
setSelectedSerial(port);
|
||
await connectToScanner(port);
|
||
} catch (err) {
|
||
console.error("User cancelled or error:", err);
|
||
}
|
||
};
|
||
|
||
|
||
const connectToScanner = async (port) => {
|
||
try {
|
||
// Request a port and open a connection
|
||
console.log(selectedSerial);
|
||
await port.open({baudRate: 9600});
|
||
|
||
// setIsConnected(true);
|
||
|
||
const reader = port.readable.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = ""; // Buffer to accumulate chunks
|
||
|
||
while (true) {
|
||
const {value, done} = await reader.read();
|
||
if (done) {
|
||
// Close the stream
|
||
reader.releaseLock();
|
||
break;
|
||
}
|
||
if (value) {
|
||
// Decode and append to the buffer
|
||
buffer += decoder.decode(value, {stream: true});
|
||
// Process messages split by newline or other delimiter
|
||
let lines = buffer.split(/[\n\t]/);
|
||
buffer = lines.pop(); // Keep the last incomplete chunk
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
sendToBackend(trimmedLine);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error connecting to scanner:", error);
|
||
}
|
||
};
|
||
|
||
|
||
const sendToBackend = async (scannedData: string) => {
|
||
const audioError = document.getElementById("audio_error") as HTMLAudioElement;
|
||
audioError.volume = 1.0;
|
||
|
||
console.log("scannedData", scannedData);
|
||
|
||
// 1) Parse JSON
|
||
let payload: { barcode_type: string; payload: { id: number } };
|
||
try {
|
||
payload = JSON.parse(scannedData);
|
||
} catch (err) {
|
||
toast.error("Invalid barcode data");
|
||
audioError.play();
|
||
return;
|
||
}
|
||
|
||
const {barcode_type, payload: inner} = payload;
|
||
const {id} = inner;
|
||
|
||
// 2) Call your API
|
||
try {
|
||
const response = await axios.post("/api/pda/barcodeScan", {
|
||
barcode_type,
|
||
payload: {id},
|
||
});
|
||
|
||
const {success, data, message, error} = response.data;
|
||
|
||
console.log("response", data);
|
||
|
||
if (!success) {
|
||
// Laravel sends 200+ success=false for business errors, or 4xx/5xx for validation/not-found/etc
|
||
toast.error(
|
||
// Validation errors come back as an object of messages
|
||
Array.isArray(message)
|
||
? Object.values(message).flat().join("; ")
|
||
: message || "Scan failed"
|
||
);
|
||
audioError.play();
|
||
return;
|
||
}
|
||
|
||
// 3) On success, data will be the model (batch/section/position/label) or undefined for carrier_scanned
|
||
switch (barcode_type) {
|
||
case "stock_batch":
|
||
setAction("batchScan");
|
||
setSelectedBatch(data);
|
||
// you now have the full batch record in `data`
|
||
break;
|
||
|
||
case "stock_section":
|
||
setAction("stockScan");
|
||
|
||
// the “from” section is whatever was last in the ref
|
||
const oldSection = selectedSectionRef.current
|
||
// const oldPosition = selectedPositionRef.current
|
||
|
||
// the “to” section is the data you just fetched
|
||
const newSection = data
|
||
|
||
setPrevSection(oldSection)
|
||
// setPrevPosition(oldPosition)
|
||
|
||
setSelectedSection(newSection)
|
||
// setSelectedPosition(newSection.position)
|
||
|
||
console.log("FROM (prevSection):", oldSection)
|
||
console.log(" TO (selectedSection):", newSection)
|
||
break
|
||
|
||
case "stock_position":
|
||
|
||
setPrevPosition(selectedPosition || null);
|
||
|
||
setAction("stockScan");
|
||
setSelectedPosition(data);
|
||
break;
|
||
|
||
case "label_scanned":
|
||
setAction("labelScan");
|
||
// perhaps `data.product` or `data.batch` are in here if you need them
|
||
break;
|
||
|
||
case "carrier_scanned":
|
||
setAction("carrierScan");
|
||
// no data field returned, but success=true means it worked
|
||
break;
|
||
|
||
default:
|
||
// validator already prevents this, but just in case
|
||
toast.error(`Unknown barcode type: ${barcode_type}`);
|
||
audioError.play();
|
||
}
|
||
} catch (err: any) {
|
||
// 4xx / 5xx HTTP errors, or network failures
|
||
if (err.response) {
|
||
// Laravel ValidationException → 422
|
||
if (err.response.status === 422) {
|
||
const errors = err.response.data.message;
|
||
toast.error(
|
||
Array.isArray(errors)
|
||
? errors.join("; ")
|
||
: typeof errors === "object"
|
||
? Object.values(errors).flat().join("; ")
|
||
: errors
|
||
);
|
||
}
|
||
// ModelNotFound → 404
|
||
else if (err.response.status === 404) {
|
||
toast.error(err.response.data.message || "Record not found");
|
||
}
|
||
// Any other server error
|
||
else {
|
||
toast.error(err.response.data.message || "Server error");
|
||
}
|
||
} else {
|
||
// network / CORS / timeout
|
||
toast.error("Network error");
|
||
}
|
||
console.error("sendToBackend error:", err);
|
||
audioError.play();
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
selectedSectionRef.current = selectedSection ?? null
|
||
}, [selectedSection])
|
||
|
||
useEffect(() => {
|
||
selectedPositionRef.current = selectedPosition ?? null
|
||
}, [selectedPosition])
|
||
|
||
|
||
return (
|
||
<>
|
||
<Head title="PDA View"/>
|
||
<audio id="audio_error" src={error_scanner_sound}>
|
||
|
||
</audio>
|
||
|
||
{/* Top bar */}
|
||
<div className="flex justify-between items-center bg-base-100 p-4 shadow">
|
||
|
||
<a className="link" href={route('dashboard')}><FontAwesomeIcon icon={faArrowLeft}/> Back</a>
|
||
{!selectedSerial && (
|
||
<button className="btn btn-ghost" onClick={handlePortRequest}>
|
||
<FontAwesomeIcon icon={faBarcode}/>
|
||
</button>
|
||
)}
|
||
<button className="btn btn-ghost">
|
||
<FontAwesomeIcon icon={faQuestionCircle}/> Help
|
||
</button>
|
||
|
||
</div>
|
||
|
||
{/* Admin tabs */}
|
||
{isAdmin && (
|
||
<div className="tabs justify-center bg-base-100">
|
||
{tabs.map((r) => (
|
||
<button
|
||
key={r}
|
||
className={`tab ${role === r ? 'tab-active' : ''}`}
|
||
onClick={() => {
|
||
setRole(r)
|
||
setAction('')
|
||
}}
|
||
>
|
||
{r}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Action selectors */}
|
||
<div className="flex justify-center space-x-4 space-y-4 sm:space-y-0 p-4 sm:flex-nowrap flex-wrap">
|
||
{[...(roleActions[role] || []), 'clear'].map((act) => (
|
||
<button
|
||
key={act}
|
||
className="btn btn-outline"
|
||
onClick={() => {
|
||
const newAct = act === 'clear' ? '' : act
|
||
setAction(newAct)
|
||
toast(`Action set to ${newAct || 'none'}`)
|
||
}}
|
||
>
|
||
{act === 'clear' ? 'Clear Action' : act}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tiles */}
|
||
<TileLayout>
|
||
{action && (() => {
|
||
// grab the base array
|
||
let tiles = tilesConfig[role][action] || []
|
||
|
||
// if we're stock-scanning but the position isn’t temporary, hide “Set Stock”
|
||
if (action === 'stockScan' && !selectedPosition?.temporary) {
|
||
tiles = tiles.filter(tile => tile.modalKey !== 'setStockFromTemp')
|
||
}
|
||
|
||
// render the filtered list
|
||
return tiles.map(({ title, icon, onClick, modalKey }) => (
|
||
<Tile
|
||
key={title}
|
||
title={title}
|
||
icon={icon}
|
||
onClick={() => {
|
||
if (modalKey) setActiveModal(modalKey)
|
||
else if (onClick) onClick()
|
||
}}
|
||
/>
|
||
))
|
||
})()}
|
||
</TileLayout>
|
||
|
||
{/* Single Modal */}
|
||
<ModalPDA isOpen={activeModal !== null} onClose={closeModal}>
|
||
{activeModal === 'batchInfo' && <BatchInfoModal onClose={closeModal} selectedBatch={selectedBatch}/>}
|
||
{activeModal === 'setStock' &&
|
||
<SetStockModal onClose={closeModal} selectedBatch={selectedBatch} selectedSection={selectedSection}
|
||
selectedPosition={selectedPosition}/>}
|
||
{activeModal === 'setStockFromTemp' &&
|
||
<SetStockModalFromTemp onClose={closeModal} selectedSection={selectedSection}
|
||
selectedPosition={selectedPosition}/>}
|
||
{activeModal === 'otherReplacement' && <OtherReplacementModal onClose={closeModal}/>}
|
||
{activeModal === 'countStock' && <CountStockModal onClose={closeModal} selectedBatch={selectedBatch}/>}
|
||
{activeModal === 'editStock' &&
|
||
<EditStockSections onClose={closeModal} selectedPosition={selectedPosition}/>}
|
||
{activeModal === 'moveStock' &&
|
||
<MoveSectionModal onClose={closeModal}
|
||
fromPosition={prevPosition}
|
||
fromSection={prevSection}
|
||
toPosition={selectedPosition}
|
||
toSection={selectedSection}/>
|
||
}
|
||
{activeModal === 'changeCount' &&
|
||
<ChangeCountModal onClose={closeModal} selectedPosition={selectedPosition}/>}
|
||
</ModalPDA>
|
||
|
||
<Toaster position="top-right"/>
|
||
</>
|
||
)
|
||
}
|