535 lines
22 KiB
TypeScript
535 lines
22 KiB
TypeScript
import React, {
|
||
useEffect,
|
||
useState,
|
||
useRef,
|
||
forwardRef,
|
||
useImperativeHandle,
|
||
} from "react";
|
||
import { usePage } from "@inertiajs/react";
|
||
import axios from "axios";
|
||
import {
|
||
faSquareCheck,
|
||
faTruck,
|
||
faXmark,
|
||
faBarcode, faArrowLeft, faQuestionCircle,
|
||
} from "@fortawesome/free-solid-svg-icons";
|
||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
import { Item, ShipmentRequest } from "@/interfaces/interfaces";
|
||
import WarehouseExpediceDialog from "@/Components/WarehouseExpediceDialog";
|
||
import PdaView from "@/Pages/PdaView";
|
||
|
||
// Define an interface for the imperative handle exposed by a row.
|
||
interface RowHandle {
|
||
triggerAnimation: () => void;
|
||
}
|
||
|
||
// ------------------- ParcelRow (for marking as processed) -------------------
|
||
interface ParcelRowProps {
|
||
shipment: ShipmentRequest;
|
||
onProcess: (shipment: ShipmentRequest) => void;
|
||
onOpenDialog: (shipment: ShipmentRequest, type: "parcels") => void;
|
||
}
|
||
|
||
const ParcelRow = forwardRef<RowHandle, ParcelRowProps>(
|
||
({ shipment, onProcess, onOpenDialog }, ref) => {
|
||
// "none" | "bounce" | "fade"
|
||
const [animationPhase, setAnimationPhase] = useState<
|
||
"none" | "bounce" | "fade"
|
||
>("none");
|
||
|
||
// Expose a method to trigger the animation from the parent (dialog)
|
||
useImperativeHandle(ref, () => ({
|
||
triggerAnimation: () => {
|
||
setAnimationPhase("bounce");
|
||
setTimeout(() => {
|
||
setAnimationPhase("fade");
|
||
}, 1000);
|
||
setTimeout(() => {
|
||
onProcess(shipment);
|
||
}, 2000);
|
||
},
|
||
}));
|
||
|
||
const handleRowClick = () => {
|
||
onOpenDialog(shipment, "parcels");
|
||
};
|
||
|
||
const handleProcess = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setAnimationPhase("bounce");
|
||
setTimeout(() => {
|
||
setAnimationPhase("fade");
|
||
}, 1000);
|
||
setTimeout(() => {
|
||
onProcess(shipment);
|
||
}, 2000);
|
||
};
|
||
|
||
/* Determine classes based on the animation phase:
|
||
- "none": border-base-300, bg-base-100, text-base-content
|
||
- "bounce": border-primary, bg-primary, text-primary-content
|
||
- "fade": border-primary, bg-primary, text-primary-content, opacity-0
|
||
*/
|
||
const containerClasses = `
|
||
p-4 border rounded-lg shadow flex flex-col space-y-3 transition-all duration-1000 cursor-pointer
|
||
${
|
||
animationPhase === "none"
|
||
? "border-base-300 bg-base-100 text-base-content opacity-100"
|
||
: animationPhase === "bounce"
|
||
? "border-primary bg-primary text-primary-content opacity-100"
|
||
: "border-primary bg-primary text-primary-content opacity-0"
|
||
}
|
||
`;
|
||
|
||
return (
|
||
<div onClick={handleRowClick} className={containerClasses}>
|
||
<div>
|
||
<p className="font-bold">{shipment.shipment_reference}</p>
|
||
<div className="mt-2 space-y-2">
|
||
{shipment.items.map((item) => (
|
||
<div key={item.id} className="border-t-2 border-warning pt-2 space-y-1">
|
||
<p className="text-base-content">
|
||
{item.quantity}× {item.name}
|
||
</p>
|
||
<div className="flex justify-between text-sm text-base-content">
|
||
<p>{item.model_number}</p>
|
||
<p>
|
||
{(item.price / item.quantity).toFixed(2)}{" "}
|
||
{shipment.currency}
|
||
</p>
|
||
</div>
|
||
<div
|
||
className={`mt-2 flex flex-wrap gap-2 p-2 border rounded-lg transition-colors duration-300 ${
|
||
animationPhase === "bounce"
|
||
? "bg-primary text-primary-content"
|
||
: "bg-base-200 text-base-content"
|
||
} border-base-300`}
|
||
>
|
||
{Object.entries(item.stockData).map(
|
||
([stockName, stockArray]) =>
|
||
stockArray.map((stock, idx) => (
|
||
<div key={`${stockName}-${idx}`} className="text-sm">
|
||
<p className="font-semibold">{stockName}</p>
|
||
<p>Sklad: {stock.location}</p>
|
||
<p>{stock.count} ks</p>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{animationPhase === "bounce" ? (
|
||
<FontAwesomeIcon
|
||
icon={faSquareCheck}
|
||
className="w-8 h-8 text-primary animate-bounce"
|
||
/>
|
||
) : (
|
||
<button
|
||
onClick={handleProcess}
|
||
className="btn btn-primary btn-sm self-end"
|
||
>
|
||
Process
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
);
|
||
|
||
// ------------------- ProcessedRow (for marking as unprocessed) -------------------
|
||
interface ProcessedRowProps {
|
||
shipment: ShipmentRequest;
|
||
onUnprocess: (shipment: ShipmentRequest) => void;
|
||
onOpenDialog: (shipment: ShipmentRequest, type: "processed") => void;
|
||
}
|
||
|
||
const ProcessedRow = forwardRef<RowHandle, ProcessedRowProps>(
|
||
({ shipment, onUnprocess, onOpenDialog }, ref) => {
|
||
const [animationPhase, setAnimationPhase] = useState<
|
||
"none" | "bounce" | "fade"
|
||
>("none");
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
triggerAnimation: () => {
|
||
setAnimationPhase("bounce");
|
||
setTimeout(() => {
|
||
setAnimationPhase("fade");
|
||
}, 1000);
|
||
setTimeout(() => {
|
||
onUnprocess(shipment);
|
||
}, 2000);
|
||
},
|
||
}));
|
||
|
||
const handleRowClick = () => {
|
||
onOpenDialog(shipment, "processed");
|
||
};
|
||
|
||
const handleUnprocess = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setAnimationPhase("bounce");
|
||
setTimeout(() => {
|
||
setAnimationPhase("fade");
|
||
}, 1000);
|
||
setTimeout(() => {
|
||
onUnprocess(shipment);
|
||
}, 2000);
|
||
};
|
||
|
||
/* Determine classes based on the animation phase:
|
||
- "none": border-base-300, bg-base-100, text-base-content
|
||
- "bounce": border-error, bg-error, text-error-content
|
||
- "fade": border-error, bg-error, text-error-content, opacity-0
|
||
*/
|
||
const containerClasses = `
|
||
p-4 border rounded-lg shadow flex flex-col space-y-3 transition-all duration-1000 cursor-pointer
|
||
${
|
||
animationPhase === "none"
|
||
? "border-base-300 bg-base-100 text-base-content opacity-100"
|
||
: animationPhase === "bounce"
|
||
? "border-error bg-error text-error-content opacity-100"
|
||
: "border-error bg-error text-error-content opacity-0"
|
||
}
|
||
`;
|
||
|
||
return (
|
||
<div onClick={handleRowClick} className={containerClasses}>
|
||
<div>
|
||
<p className="font-bold">{shipment.shipment_reference}</p>
|
||
<div className="mt-2 space-y-2">
|
||
{shipment.items.map((item) => (
|
||
<div key={item.id} className="border-t-2 border-warning pt-2 space-y-1">
|
||
<p className="text-base-content">
|
||
{item.quantity}× {item.name}
|
||
</p>
|
||
<div className="flex justify-between text-sm text-base-content">
|
||
<p>{item.model_number}</p>
|
||
<p>
|
||
{(item.price / item.quantity).toFixed(2)}{" "}
|
||
{shipment.currency}
|
||
</p>
|
||
</div>
|
||
<div
|
||
className={`mt-2 flex flex-wrap gap-2 p-2 border rounded-lg transition-colors duration-300 ${
|
||
animationPhase === "bounce"
|
||
? "bg-error text-error-content"
|
||
: "bg-base-200 text-base-content"
|
||
} border-base-300`}
|
||
>
|
||
{Object.entries(item.stockData).map(
|
||
([stockName, stockArray]) =>
|
||
stockArray.map((stock, idx) => (
|
||
<div key={`${stockName}-${idx}`} className="text-sm">
|
||
<p className="font-semibold">{stockName}</p>
|
||
<p>Sklad: {stock.location}</p>
|
||
<p>{stock.count} ks</p>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{animationPhase === "bounce" ? (
|
||
<FontAwesomeIcon
|
||
icon={faXmark}
|
||
className="w-8 h-8 text-error animate-bounce"
|
||
/>
|
||
) : (
|
||
<button
|
||
onClick={handleUnprocess}
|
||
className="btn btn-error btn-sm self-end"
|
||
>
|
||
Unprocess
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
);
|
||
|
||
type InertiaProps = {
|
||
auth: any; // adjust per your auth type
|
||
selectedBatchID: number;
|
||
};
|
||
|
||
// ------------------- Main Component -------------------
|
||
export default function WarehouseExpedicePage() {
|
||
const { selectedBatchID } = usePage<InertiaProps>().props;
|
||
|
||
// States for shipments
|
||
const [parcels, setParcels] = useState<ShipmentRequest[]>([]);
|
||
const [parcelsOther, setParcelsOther] = useState<ShipmentRequest[]>([]);
|
||
const [processed, setProcessed] = useState<ShipmentRequest[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// Refs to control individual row animations
|
||
const parcelRowRefs = useRef<{ [key: number]: RowHandle | null }>({});
|
||
const processedRowRefs = useRef<{ [key: number]: RowHandle | null }>({});
|
||
|
||
// Dialog state
|
||
const [selectedShipment, setSelectedShipment] =
|
||
useState<ShipmentRequest | null>(null);
|
||
const [selectedType, setSelectedType] = useState<
|
||
"parcels" | "processed" | null
|
||
>(null);
|
||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||
|
||
const handleCopyToClipboard = () => {
|
||
const copyString = parcels.map((p) => p.shipment_reference).join(",");
|
||
const copyStringOther = parcelsOther.map((p) => p.shipment_reference).join(
|
||
","
|
||
);
|
||
const copyStringProcessed = processed
|
||
.map((p) => p.shipment_reference)
|
||
.join(",");
|
||
|
||
navigator.clipboard
|
||
.writeText([copyString, copyStringOther, copyStringProcessed].filter(Boolean).join(","))
|
||
.then(() => {
|
||
alert("Parcels copied to clipboard!");
|
||
})
|
||
.catch((err) => {
|
||
console.error("Failed to copy:", err);
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
axios
|
||
.post("/api/expediceListWMS", { batch_id: selectedBatchID })
|
||
.then((response) => {
|
||
setParcels(response.data.batch_items.shipments);
|
||
setParcelsOther(response.data.batch_items.shipments_other);
|
||
setLoading(false);
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error fetching data:", error);
|
||
setLoading(false);
|
||
});
|
||
}, [selectedBatchID]);
|
||
|
||
// Move a shipment from Parcels to Processed
|
||
const markAsProcessed = (shipment: ShipmentRequest) => {
|
||
setParcels((prev) => prev.filter((s) => s.id !== shipment.id));
|
||
setProcessed((prev) => [...prev, shipment]);
|
||
};
|
||
|
||
// Move a shipment from Processed back to Parcels
|
||
const markAsUnprocessed = (shipment: ShipmentRequest) => {
|
||
setProcessed((prev) => prev.filter((s) => s.id !== shipment.id));
|
||
setParcels((prev) => [...prev, shipment]);
|
||
};
|
||
|
||
// Open the dialog for a shipment
|
||
const openDialog = (
|
||
shipment: ShipmentRequest,
|
||
type: "parcels" | "processed"
|
||
) => {
|
||
setSelectedShipment(shipment);
|
||
setSelectedType(type);
|
||
setIsDialogOpen(true);
|
||
};
|
||
|
||
// Close the dialog
|
||
const closeDialog = () => {
|
||
setIsDialogOpen(false);
|
||
setSelectedShipment(null);
|
||
setSelectedType(null);
|
||
};
|
||
|
||
const pdaDialogRef = useRef<HTMLDialogElement>(null);
|
||
const openPdaModal = () => {
|
||
pdaDialogRef.current?.showModal();
|
||
};
|
||
const closePdaModal = () => {
|
||
pdaDialogRef.current?.close();
|
||
};
|
||
|
||
const [buttonStates, setButtonStates] = useState<{ [itemId: number]: any }>(
|
||
{}
|
||
);
|
||
|
||
// Handle processing/unprocessing via the dialog action button
|
||
const handleDialogProcess = () => {
|
||
if (selectedShipment && selectedType === "parcels") {
|
||
const filteredButtonStates = selectedShipment.items.reduce(
|
||
(acc: any, item: Item) => {
|
||
if (buttonStates[item.id]) {
|
||
acc[item.id] = buttonStates[item.id];
|
||
}
|
||
return acc;
|
||
},
|
||
{}
|
||
);
|
||
|
||
axios
|
||
.post("/api/warehouseExpedice/passOptions", {
|
||
shipment_request_id: selectedShipment.id,
|
||
buttonStates: filteredButtonStates,
|
||
shipment_items: selectedShipment.items,
|
||
})
|
||
.catch((error) => {
|
||
console.error("Error sending button states:", error);
|
||
});
|
||
|
||
if (parcelRowRefs.current[selectedShipment.id]) {
|
||
parcelRowRefs.current[selectedShipment.id]?.triggerAnimation();
|
||
} else {
|
||
markAsProcessed(selectedShipment);
|
||
}
|
||
} else if (selectedShipment && selectedType === "processed") {
|
||
if (processedRowRefs.current[selectedShipment.id]) {
|
||
processedRowRefs.current[selectedShipment.id]?.triggerAnimation();
|
||
} else {
|
||
markAsUnprocessed(selectedShipment);
|
||
}
|
||
}
|
||
closeDialog();
|
||
};
|
||
|
||
if (loading) {
|
||
return <div className="p-4 text-base-content">Loading...</div>;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 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>
|
||
<button className="btn btn-ghost">
|
||
<FontAwesomeIcon icon={faQuestionCircle} /> Help
|
||
</button>
|
||
</div>
|
||
{/* Open the modal using document.getElementById('ID').showModal() method */}
|
||
<button className="btn" onClick={()=>openPdaModal()}>open modal</button>
|
||
{/* PDA VIEW DIALOG */}
|
||
<dialog id="pdaViewModal" className="modal" ref={pdaDialogRef}>
|
||
<div className="modal-box max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||
<PdaView closeParent={closePdaModal} />
|
||
</div>
|
||
<form method="dialog" className="modal-backdrop">
|
||
<button className="btn">Close</button>
|
||
</form>
|
||
</dialog>
|
||
{/* Tabs */}
|
||
<div className="tabs tabs-box w-full">
|
||
{/* Parcels Tab */}
|
||
<label className="tab flex-1">
|
||
<input
|
||
type="radio"
|
||
name="expedice_tabs"
|
||
className="tab"
|
||
aria-label="Parcels"
|
||
defaultChecked
|
||
/>
|
||
<div className="flex items-center justify-center gap-2 w-full">
|
||
<FontAwesomeIcon icon={faTruck} className="w-5 h-5" />
|
||
<span className="text-base-content">Parcels</span>
|
||
</div>
|
||
</label>
|
||
<div className="tab-content bg-base-100 border-base-300 p-6">
|
||
<div className="space-y-3">
|
||
{parcels.length === 0 ? (
|
||
<p className="text-base-content">No parcels available.</p>
|
||
) : (
|
||
parcels.map((shipment) => (
|
||
<ParcelRow
|
||
ref={(el) => {
|
||
parcelRowRefs.current[shipment.id] = el;
|
||
}}
|
||
key={shipment.id}
|
||
shipment={shipment}
|
||
onProcess={markAsProcessed}
|
||
onOpenDialog={openDialog}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Nahravacky Tab */}
|
||
<label className="tab flex-1">
|
||
<input
|
||
type="radio"
|
||
name="expedice_tabs"
|
||
className="tab"
|
||
aria-label="Nahravacky"
|
||
/>
|
||
<div className="flex items-center justify-center gap-2 w-full">
|
||
<FontAwesomeIcon icon={faBarcode} className="w-5 h-5" />
|
||
<span className="text-base-content">Nahravacky</span>
|
||
</div>
|
||
</label>
|
||
<div className="tab-content bg-base-100 border-base-300 p-6">
|
||
<div className="space-y-3">
|
||
{parcelsOther.length === 0 ? (
|
||
<p className="text-base-content">No parcels available.</p>
|
||
) : (
|
||
parcelsOther.map((shipment) => (
|
||
<ParcelRow
|
||
ref={(el) => {
|
||
parcelRowRefs.current[shipment.id] = el;
|
||
}}
|
||
key={shipment.id}
|
||
shipment={shipment}
|
||
onProcess={markAsProcessed}
|
||
onOpenDialog={openDialog}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Processed Tab (15% width) */}
|
||
<label className="tab w-[15%] flex-none">
|
||
<input
|
||
type="radio"
|
||
name="expedice_tabs"
|
||
className="tab"
|
||
aria-label="Processed"
|
||
/>
|
||
<div className="flex items-center justify-center w-full">
|
||
<FontAwesomeIcon icon={faSquareCheck} className="w-5 h-5" />
|
||
</div>
|
||
</label>
|
||
<div className="tab-content bg-base-100 border-base-300 p-6">
|
||
<div className="space-y-3">
|
||
{processed.length === 0 ? (
|
||
<p className="text-base-content">No processed shipments.</p>
|
||
) : (
|
||
processed.map((shipment) => (
|
||
<ProcessedRow
|
||
ref={(el) => {
|
||
processedRowRefs.current[shipment.id] = el;
|
||
}}
|
||
key={shipment.id}
|
||
shipment={shipment}
|
||
onUnprocess={markAsUnprocessed}
|
||
onOpenDialog={openDialog}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Shipment Details Dialog */}
|
||
{selectedShipment && (
|
||
<WarehouseExpediceDialog
|
||
selectedShipment={selectedShipment}
|
||
isDialogOpen={isDialogOpen}
|
||
closeDialog={closeDialog}
|
||
handleDialogProcess={handleDialogProcess}
|
||
selectedType={selectedType}
|
||
buttonStates={buttonStates}
|
||
setButtonStates={setButtonStates}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|