498 lines
15 KiB
TypeScript
498 lines
15 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { MapContainer, TileLayer, Marker, CircleMarker, Popup, Polyline, LayerGroup, GeoJSON, } from 'react-leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import L, { LatLngBounds } from 'leaflet';
|
|
import Contacts from './Contacts';
|
|
|
|
import { useQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query";
|
|
import axios from "axios";
|
|
|
|
import { Button, buttonVariants } from './ui/Button';
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog"
|
|
|
|
|
|
//Todo: Move types to own file
|
|
interface User {
|
|
name: string;
|
|
id: string;
|
|
email: string;
|
|
phoneNumber: string;
|
|
}
|
|
|
|
interface Node {
|
|
name: string;
|
|
id: string;
|
|
}
|
|
|
|
export interface Media {
|
|
id: string;
|
|
alt?: string;
|
|
updatedAt: string;
|
|
createdAt: string;
|
|
url?: string;
|
|
filename?: string;
|
|
mimeType?: string;
|
|
filesize?: number;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
interface Product extends Node {
|
|
id: string;
|
|
name: string;
|
|
weight?: number;
|
|
picture: Media;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
// interface Location = {
|
|
// latitude: number;
|
|
// longitude: number;
|
|
// }
|
|
|
|
interface Maker extends Node {
|
|
email: string;
|
|
phoneNumber?: string;
|
|
location: [number, number];
|
|
stock: Product[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
interface Retailer extends Node {
|
|
email: string;
|
|
phoneNumber?: string;
|
|
location: [number, number];
|
|
stock: Product[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
const DISPATCH_STATUS = ['requested', 'accepted', 'archived'] as const;
|
|
type DispatchStatus = typeof DISPATCH_STATUS[number];
|
|
|
|
interface Dispatch {
|
|
id: string;
|
|
dispatchesCode?: string; //Human readable id
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
|
|
maker: Maker;
|
|
retailer: Retailer;
|
|
products: Product[];
|
|
|
|
courier?: User;
|
|
|
|
timeSensitive: boolean;
|
|
status: DispatchStatus;
|
|
|
|
departureDate: string;
|
|
arrivalDate: string;
|
|
weightAllowance: number;
|
|
}
|
|
|
|
|
|
//Todo: update fetch url endpoints
|
|
//Todo: Move queryclient and hooks to own file
|
|
//Todo: Move axios stuff
|
|
const API_URL = "http://localhost:3001"
|
|
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
const getMakers = async () => {
|
|
const url = `${API_URL}/api/makers`
|
|
console.log("Fetching url:", url)
|
|
const response = await axios.get(url);
|
|
|
|
const makers: Maker[] = response.data.docs;
|
|
console.log(`Fetch result from ${url}`, makers)
|
|
return makers;
|
|
|
|
}
|
|
|
|
const useGetMakers = () => {
|
|
return useQuery<Maker[]>({
|
|
queryFn: () => getMakers(),
|
|
queryKey: ['makers'],
|
|
enabled: true
|
|
})
|
|
}
|
|
|
|
const getRetailers = async () => {
|
|
const url = `${API_URL}/api/retailers`
|
|
console.log("Fetching url:", url)
|
|
const response = await axios.get(url);
|
|
|
|
const retailers: Retailer[] = response.data.docs;
|
|
console.log(`Fetch result from ${url}`, retailers)
|
|
return retailers;
|
|
|
|
}
|
|
|
|
const useGetRetailers = () => {
|
|
return useQuery<Retailer[]>({
|
|
queryFn: () => getRetailers(),
|
|
queryKey: ['retailers'],
|
|
enabled: true
|
|
})
|
|
}
|
|
|
|
const getDispatches = async () => {
|
|
const url = `${API_URL}/api/dispatches`
|
|
console.log("Fetching url:", url)
|
|
const response = await axios.get(url);
|
|
|
|
const dispatches: Dispatch[] = response.data.docs;
|
|
|
|
console.log(`Fetch result from ${url}`, dispatches)
|
|
return dispatches;
|
|
|
|
}
|
|
|
|
const useGetDispatches = () => {
|
|
return useQuery<Dispatch[]>({
|
|
queryFn: () => getDispatches(),
|
|
queryKey: ['dispatches'],
|
|
enabled: true
|
|
})
|
|
}
|
|
|
|
//Payload longitude and latitude are mislabeled in payload (lol)
|
|
const locationSwitcharoo = (location: number[]) => {
|
|
if (location.length === 2) {
|
|
const correctedLocation = [location[1], location[0]]
|
|
return correctedLocation;
|
|
}
|
|
console.error("locationSwitcharoo: Location array malformed")
|
|
return location
|
|
}
|
|
|
|
|
|
const dashArrays: Record<DispatchStatus, string> = {
|
|
requested: '20, 10',
|
|
accepted: '0, 0',
|
|
archived: '1, 5',
|
|
}
|
|
|
|
const dashColors: Record<DispatchStatus, string> = {
|
|
requested: '#000',
|
|
accepted: '#000',
|
|
archived: '#000',
|
|
}
|
|
|
|
const dashColorSelected: string = '#f87171' //same as tw red 400
|
|
|
|
const dashOpacities: Record<DispatchStatus, number> = {
|
|
requested: 0.7,
|
|
accepted: 0.7,
|
|
archived: 0.5
|
|
}
|
|
|
|
interface NodeSelection {
|
|
id: string,
|
|
type: "maker" | "retailer" | "dispatch" | "none"
|
|
}
|
|
|
|
export const KiosMap = () => {
|
|
|
|
const { data: makers, isLoading: isLoadingMakers } = useGetMakers();
|
|
const { data: retailers, isLoading: isLoadingRetailers } = useGetRetailers();
|
|
const { data: dispatches, isLoading: isLoadingDispatches } = useGetDispatches();
|
|
|
|
const [selectedNode, setSelectedNode] = useState<NodeSelection>({ id: "", type: "none" })
|
|
|
|
let selectedMaker: Maker | undefined = undefined;
|
|
let selectedRetailer: Retailer | undefined = undefined;
|
|
let selectedDispatch: Dispatch | undefined = undefined;
|
|
|
|
if (selectedNode.type === "maker" && makers) {
|
|
selectedMaker = makers.find(maker => maker.id === selectedNode.id && selectedNode.type === "maker");
|
|
}
|
|
|
|
if (selectedNode.type === "retailer" && retailers) {
|
|
selectedRetailer = retailers.find(retailer => retailer.id === selectedNode.id && selectedNode.type === "retailer");
|
|
}
|
|
|
|
if (selectedNode.type === "dispatch" && dispatches) {
|
|
selectedDispatch = dispatches.find(dispatch => dispatch.id === selectedNode.id && selectedNode.type === "dispatch");
|
|
}
|
|
|
|
|
|
const handleSelectNode = (nodeId: string, typeParam: "maker" | "retailer" | "dispatch" | "none") => {
|
|
setSelectedNode({ id: nodeId, type: typeParam })
|
|
console.log("set id:", nodeId)
|
|
}
|
|
|
|
//params: dispatch: Dispatch, courier: User
|
|
const handleAcceptRoute = () => {
|
|
|
|
}
|
|
|
|
const handleOpenCatalogue = () => {
|
|
}
|
|
|
|
//params
|
|
const handleRequestProduct = () => {
|
|
|
|
}
|
|
|
|
const blackDotIcon = L.divIcon({
|
|
className: 'bg-gray-950 rounded-full',
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10]
|
|
});
|
|
|
|
const selectedDotIcon = L.divIcon({
|
|
className: 'bg-red-400 rounded-full ring-offset-2 ring-4 ring-red-400 ring-dashed',
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10]
|
|
});
|
|
|
|
|
|
|
|
const bounds = new LatLngBounds(
|
|
[-90, -180], // Southwest corner
|
|
[90, 180] // Northeast corner
|
|
);
|
|
|
|
return (
|
|
<div className='w-full flex justify-center align-middle'>
|
|
{
|
|
selectedNode.type !== 'none' && (
|
|
<div className='absolute bg-white border-gray-950 border-2 z-[998] left-10 top-1/5 mt-24 p-4'>
|
|
<div className='flex gap-8 flex-col'>
|
|
{selectedMaker !== undefined && (
|
|
<div className='flex gap-4 flex-col'>
|
|
<Contacts
|
|
name={selectedMaker.name}
|
|
email={selectedMaker.email}
|
|
phoneNumber={selectedMaker.phoneNumber}
|
|
role={'maker'}
|
|
/>
|
|
{(selectedMaker.stock !== undefined && selectedMaker.stock.length > 0) &&
|
|
<Dialog>
|
|
<DialogTrigger
|
|
className={buttonVariants({ variant: "kios" })}
|
|
>
|
|
See catalogue
|
|
</DialogTrigger>
|
|
|
|
<DialogContent className='lg:max-w-screen-lg overflow-y-scroll max-h-screen'>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-4xl underline underline-offset-2">{selectedMaker.name}'s stock</DialogTitle>
|
|
<DialogDescription>
|
|
<ul className='flex flex-col gap-4'>
|
|
{selectedMaker.stock.map((product, i) => {
|
|
return (
|
|
<li className="flex flex-row gap-4">
|
|
{product.picture.url &&
|
|
<img
|
|
width={160}
|
|
src={product.picture.url}
|
|
alt={product.picture.alt || ''} />
|
|
}
|
|
<div className="flex flex-col pb-4">
|
|
<h3 className='text-4xl text-black py-2'>
|
|
{product.name}
|
|
</h3>
|
|
{product.weight &&
|
|
<p className='text-black text-lg'
|
|
>Weight: {product.weight}</p>
|
|
}
|
|
<Button
|
|
variant={'kios'}
|
|
className='w-full mt-6'
|
|
onClick={() => handleRequestProduct()}
|
|
>
|
|
Request product
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
</DialogContent>
|
|
</Dialog>
|
|
}
|
|
</div>
|
|
)}
|
|
|
|
{selectedRetailer !== undefined && (
|
|
<>
|
|
<Contacts
|
|
name={selectedRetailer.name}
|
|
email={selectedRetailer.email}
|
|
phoneNumber={selectedRetailer.phoneNumber}
|
|
role={'retailer'}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{selectedDispatch !== undefined && (
|
|
<div className='flex flex-col gap-8'>
|
|
<div>
|
|
<h2 className='text-xl font-bold underline-offset-2 underline py-2'>
|
|
Product{selectedDispatch.products.length > 1 && 's'}
|
|
</h2>
|
|
{selectedDispatch.products.map((product, i) => {
|
|
return (
|
|
<div className='flex flex-row items-center gap-4'>
|
|
<img
|
|
src={product.picture.url}
|
|
alt={product.picture.alt}
|
|
className='border-2 border-black'
|
|
width={60}
|
|
/>
|
|
<h3 className='font-bold text-xl'>{product.name}</h3>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
|
|
<Contacts
|
|
name={selectedDispatch.maker.name}
|
|
email={selectedDispatch.maker.email}
|
|
phoneNumber={selectedDispatch.maker.phoneNumber}
|
|
role={'maker'}
|
|
/>
|
|
|
|
<Contacts
|
|
name={selectedDispatch.retailer.name}
|
|
email={selectedDispatch.retailer.email}
|
|
phoneNumber={selectedDispatch.retailer.phoneNumber}
|
|
role={'retailer'}
|
|
/>
|
|
|
|
{selectedDispatch.courier !== undefined ? (
|
|
|
|
<Contacts
|
|
name={selectedDispatch.courier.name}
|
|
email={selectedDispatch.courier.email}
|
|
phoneNumber={selectedDispatch.courier.phoneNumber}
|
|
role={'courier'}
|
|
/>
|
|
) :
|
|
<div>
|
|
<h2 className='text-xl font-bold underline-offset-2 underline py-2'>No courier!</h2>
|
|
<Button
|
|
variant={"kios"}
|
|
onClick={() => handleAcceptRoute()}
|
|
>
|
|
Accept route as courier
|
|
</Button>
|
|
</div>
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
<MapContainer
|
|
id="map"
|
|
center={[-6.1815, 106.8228]}
|
|
zoom={3}
|
|
maxBounds={bounds} // Restrict panning beyond these bounds
|
|
maxBoundsViscosity={0.9} // How strongly to snap the map's bounds to the restricted area
|
|
style={{ height: '800px', width: '100%' }}
|
|
>
|
|
<TileLayer url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
maxZoom={19}
|
|
eventHandlers={{
|
|
click: () => handleSelectNode("", "none")
|
|
}}
|
|
attribution='© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
/>
|
|
|
|
{(makers && !isLoadingMakers) &&
|
|
<LayerGroup>
|
|
{makers.map((maker: any, index: number) => (
|
|
<Marker
|
|
eventHandlers={{
|
|
click: () => handleSelectNode(maker.id, "maker")
|
|
}}
|
|
key={maker.id}
|
|
position={[locationSwitcharoo(maker.location)[0], locationSwitcharoo(maker.location)[1]]}
|
|
icon={selectedNode.id === maker.id ? selectedDotIcon : blackDotIcon}
|
|
>
|
|
{/* <Popup>{maker.name}</Popup> */}
|
|
</Marker>
|
|
))}
|
|
</LayerGroup>
|
|
}
|
|
|
|
{(retailers && !isLoadingRetailers) &&
|
|
<LayerGroup>
|
|
{retailers.map((retailer: any, index: number) => (
|
|
<Marker
|
|
eventHandlers={{
|
|
click: () => handleSelectNode(retailer.id, "retailer")
|
|
}}
|
|
key={retailer.id}
|
|
position={[locationSwitcharoo(retailer.location)[0], locationSwitcharoo(retailer.location)[1]]}
|
|
icon={selectedNode.id === retailer.id ? selectedDotIcon : blackDotIcon}
|
|
>
|
|
{/* <Popup>{retailer.name}</Popup> */}
|
|
</Marker>
|
|
))}
|
|
</LayerGroup>
|
|
}
|
|
|
|
{(dispatches && !isLoadingDispatches) &&
|
|
<LayerGroup>
|
|
{dispatches.map((dispatch: any, index: number) => {
|
|
|
|
if (dispatch.maker && dispatch.retailer) {
|
|
|
|
const start = locationSwitcharoo(dispatch.maker.location);
|
|
const end = locationSwitcharoo(dispatch.retailer.location);
|
|
|
|
|
|
let productsString = '';
|
|
dispatch.products.forEach((product: any, i: number) => {
|
|
productsString += product.productTitle + (i + 1 < dispatch.products.length ? ', ' : '');
|
|
});
|
|
|
|
//status type should already be inferred when list of dispatches is created, weird that is is required
|
|
const status: DispatchStatus = dispatch.status;
|
|
|
|
const dashArray: string = dashArrays[status]
|
|
const dashColor: string = dashColors[status]
|
|
const dashOpacity: number = dashOpacities[status]
|
|
|
|
return (
|
|
<Polyline
|
|
eventHandlers={{
|
|
click: () => handleSelectNode(dispatch.id, "dispatch")
|
|
}}
|
|
key={dispatch.id}
|
|
positions={[[start[0], start[1]], [end[0], end[1]]]}
|
|
pathOptions={{ color: selectedNode.id === dispatch.id ? dashColorSelected : dashColor }}
|
|
opacity={dashOpacity}
|
|
dashArray={dashArray} />
|
|
);
|
|
}
|
|
})}
|
|
</LayerGroup>
|
|
}
|
|
</MapContainer >
|
|
</div>
|
|
);
|
|
};
|
|
|