frontend/src/grain/GrainTransaction.tsx

558 lines
19 KiB
TypeScript

import {
Box,
Button,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
InputAdornment,
Radio,
RadioGroup,
TextField,
Typography
} from "@mui/material";
import FileUploader from "common/FileUploads/FileUploader";
import ResponsiveDialog from "common/ResponsiveDialog";
import SearchSelect, { Option } from "common/SearchSelect";
import { useMobile } from "hooks";
import { Bin, Field, Contract, GrainBag } from "models";
// import { Contract } from "models/Contract";
// import { GrainBag } from "models/GrainBag";
import moment from "moment";
import { pond } from "protobuf-ts/pond";
import {
useBinAPI,
useFieldAPI,
useGlobalState,
useGrainBagAPI,
useSnackbar,
useTransactionAPI
} from "providers";
import { useState, useEffect } from "react";
import { getGrainUnit } from "utils";
interface Props {
mainObject: Bin | GrainBag | Field | Contract;
grainAdjustment?: number;
hideFields?: boolean;
asDestination?: boolean;
restrictMatching?: boolean;
open: boolean;
close: () => void;
callback?: (newTransaction?: pond.Transaction) => void;
allowAttachmentUploads?: boolean;
}
export default function GrainTransaction(props: Props) {
const {
open,
mainObject,
close,
grainAdjustment,
callback,
restrictMatching,
hideFields,
asDestination,
allowAttachmentUploads
} = props;
const [isObject, setIsObject] = useState<"source" | "destination" | "">("");
const [selectedSource, setSelectedSource] = useState<Option | null>(null);
const [selectedDestination, setSelectedDestination] = useState<Option | null>(null);
const [binOptions, setBinOptions] = useState<Option[]>([]);
const [bagOptions, setBagOptions] = useState<Option[]>([]);
const [fieldOptions, setFieldOptions] = useState<Option[]>([]);
const [destinationOptions, setDestinationOptions] = useState<Option[]>([]);
const [sourceOptions, setSourceOptions] = useState<Option[]>([]);
const binAPI = useBinAPI();
const grainBagAPI = useGrainBagAPI();
const fieldAPI = useFieldAPI();
const [{ as }] = useGlobalState();
const { openSnack } = useSnackbar();
const [grainChangeDialog, setGrainChangeDialog] = useState(false);
const [grainEntry, setGrainEntry] = useState("0");
const [grainChangeVal, setGrainChangeVal] = useState("0");
const transactionAPI = useTransactionAPI();
const [grainMessage, setGrainMessage] = useState("");
const isMobile = useMobile();
const [binsLoading, setBinsLoading] = useState(false);
const [bagsLoading, setBagsLoading] = useState(false);
const [fieldsLoading, setFieldsLoading] = useState(false);
const [uploadingFile, setUploadingFile] = useState(false);
const [imageIDs, setImageIDs] = useState<string[]>([]);
const closeDialogs = (confirmed: boolean, newTransaction?: pond.Transaction) => {
close();
setGrainChangeVal("0");
setGrainEntry("0");
setIsObject("");
setGrainChangeDialog(false);
if (callback && confirmed) {
callback(newTransaction);
}
};
//get the possible destinations/options (grainbags and bins)
useEffect(() => {
if (open) {
if (grainAdjustment) {
setGrainChangeVal(grainAdjustment.toString());
} else {
setGrainChangeVal("0");
}
if ((grainAdjustment && grainAdjustment > 0) || asDestination) {
setIsObject("destination");
setSelectedDestination({
label: mainObject.name(),
value: mainObject,
group: mainObject.objectTypeString()
});
setSelectedSource(null);
} else if (grainAdjustment && grainAdjustment < 0) {
setIsObject("source");
setSelectedSource({
label: mainObject.name(),
value: mainObject,
group: mainObject.objectTypeString()
});
setSelectedDestination(null);
}
//list bins and add to both options
if (!binsLoading) {
setBinsLoading(true);
binAPI
.listBins(0, 0, undefined, undefined, undefined, as)
.then(resp => {
//let sourceOps: Option[] = sourceOptions
let binOps: Option[] = [];
if(resp.data.bins){
resp.data.bins.forEach(bin => {
let b = Bin.create(bin);
if (mainObject.key() !== b.key()) {
let op: Option = {
label: b.name(),
value: b,
group: "Bins"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && binOps.push(op);
}
});
}
setBinOptions([...binOps]);
})
.finally(() => {
setBinsLoading(false);
});
}
//list bags and add to both options
if (!bagsLoading) {
setBagsLoading(true);
grainBagAPI
.listGrainBags(0, 0, undefined, undefined, undefined, undefined, undefined, undefined, as)
.then(resp => {
//let sourceOps: Option[] = sourceOptions
let bagOps: Option[] = [];
if(resp.data.grainBags){
resp.data.grainBags.forEach(bag => {
let b = GrainBag.create(bag);
if (mainObject.key() !== b.key()) {
let op: Option = {
label: b.name(),
value: b,
group: "Grain Bags"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && bagOps.push(op);
}
});
}
setBagOptions([...bagOps]);
})
.finally(() => {
setBagsLoading(false);
});
}
//list fields and add to source options
if (!fieldsLoading && !hideFields) {
setFieldsLoading(true);
fieldAPI
.listFields(0, 0, undefined, undefined, undefined, as)
.then(resp => {
//let sourceOps: Option[] = sourceOptions
let fieldOps: Option[] = [];
resp.data.fields.forEach(field => {
let f = Field.create(field);
let op: Option = {
label: f.name(),
value: f,
group: "Fields"
};
(!restrictMatching || grainTypesMatch(op.value, mainObject) || op.value.grainName() === mainObject.grainName()) && fieldOps.push(op);
});
setFieldOptions([...fieldOps]);
})
.finally(() => {
setFieldsLoading(false);
});
}
}
}, [binAPI, fieldAPI, grainBagAPI, as, grainAdjustment, mainObject, open, asDestination]); //eslint-disable-line react-hooks/exhaustive-deps
//watches the object options arrays and build new options when they change
useEffect(() => {
setSourceOptions([...binOptions.concat(bagOptions, fieldOptions)]);
setDestinationOptions([...binOptions.concat(bagOptions)]);
}, [binOptions, bagOptions, fieldOptions]);
//create a transaction to enter into the table
const createTransaction = (finalVal: number) => {
let dest = selectedDestination;
let source = selectedSource;
let bpt = 1;
//use the sources bushel per tonne if one was selected otherwise use the destination
if (source) {
bpt = source.value.bushelsPerTonne();
} else if (dest) {
bpt = dest.value.bushelsPerTonne();
}
let newTransaction = pond.Transaction.create({
timestamp: moment().toISOString(),
transaction: pond.TransactionData.create({
grainTransaction: pond.GrainTransaction.create({
bushels: finalVal,
message: grainMessage,
bushelsPerTonne: bpt
})
})
});
if (dest) {
let obj: Contract | Bin | GrainBag = dest.value;
newTransaction.toKey = obj.key();
newTransaction.toObject = obj.objectType();
let grainTransaction = newTransaction.transaction?.grainTransaction;
//set the grain in the transaction to use the destination by default
if (grainTransaction) {
grainTransaction.grainType = obj.grain();
grainTransaction.customGrain = obj.customType();
grainTransaction.subtype = obj.subtype();
}
}
if (source) {
let obj: Field | Bin | GrainBag = source.value;
newTransaction.fromKey = obj.key();
newTransaction.fromObject = obj.objectType();
let grainTransaction = newTransaction.transaction?.grainTransaction;
//over write the grain type to be what the source has if there was a source
if (grainTransaction) {
grainTransaction.grainType = obj.grain();
grainTransaction.customGrain = obj.customType();
grainTransaction.subtype = obj.subtype();
}
}
transactionAPI.addTransaction(newTransaction, imageIDs, as).then(resp => {
newTransaction.key = resp.data.key;
closeDialogs(true, newTransaction);
});
};
const updateInventory = () => {
//this is the amount to add to the destination
let destinationAdd = Math.abs(
isNaN(parseFloat(grainChangeVal)) ? 1 : parseFloat(grainChangeVal)
);
//check the source to see how much can actually be moved
if (selectedSource) {
if (selectedSource?.value.objectTypeString() === "Bin") {
let bin: Bin = selectedSource.value as Bin;
if (bin.settings.inventory) {
//if the difference is less than 0 that means there werent enough bushels in the source
if (bin.settings.inventory.grainBushels - destinationAdd < 0) {
//set the amount to add to the destination to be what is in the source
destinationAdd = bin.settings.inventory.grainBushels;
}
}
} else if (selectedSource?.value.objectTypeString() === "Grain Bag") {
let bag: GrainBag = selectedSource.value as GrainBag;
if (bag.bushels() - destinationAdd < 0) {
destinationAdd = bag.bushels();
}
}
}
//check the destination to see if there is enough room to move from the source
if (selectedDestination) {
if (selectedDestination?.value.objectTypeString() === "Bin") {
let bin: Bin = selectedDestination.value as Bin;
if (bin.settings.inventory && bin.settings.specs) {
if (
bin.settings.inventory.grainBushels + destinationAdd >
bin.settings.specs.bushelCapacity
) {
openSnack("Destination does not have enough room in bin for selected amount");
return;
}
}
} else if (selectedDestination?.value.objectTypeString() === "Grain Bag") {
let bag: GrainBag = selectedDestination.value as GrainBag;
if (bag.bushels() + destinationAdd > bag.capacity()) {
openSnack("Destination does not have enough room in bag for selected amount");
return;
}
} else if (selectedDestination?.value.objectTypeString() === "Contract") {
let contract: Contract = selectedDestination.value as Contract;
if (contract.settings.delivered + destinationAdd > contract.settings.size) {
openSnack("Selected value will overdeliver on the contract");
return;
}
}
}
//---!!moved the update functionality for updating objects to the backend inside the add transaction call!!---
createTransaction(destinationAdd);
};
//select for sources (fields, bins, bags)
const sourceSelect = () => {
return (
<Box>
<SearchSelect
label="Grain Source"
selected={selectedSource}
changeSelection={option => {
setSelectedSource(option);
}}
group
//disabled={!canEdit}
options={sourceOptions}
/>
</Box>
);
};
//select for destinations (bins,bags)
const destinationSelect = () => {
return (
<Box>
<SearchSelect
label="Grain Destination"
selected={selectedDestination}
changeSelection={option => {
setSelectedDestination(option);
}}
group
options={destinationOptions}
/>
</Box>
);
};
const grainTypesMatch = (
source: Bin | GrainBag | Field | Contract,
destination: Bin | GrainBag | Field | Contract
) => {
let matching = false;
//check if the storage types are the same between the two
if (source.storage() === destination.storage()) {
if (source.storage() === pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN) {
if (source.grain() === destination.grain()) {
matching = true;
}
}
}
return matching;
};
const confirm = () => {
if (selectedSource && selectedDestination) {
if (grainTypesMatch(selectedSource.value, selectedDestination.value)) {
updateInventory();
} else {
//open dialog saying that the grain types do not match or because it is a custom type
setGrainChangeDialog(true);
}
} else {
updateInventory();
}
};
/**
* the function is used to determine what text to display to the user when the grain types dont match
* or when it sees any of the objects involved as having a custom grain type
* @param source the source of the grain
* @param destination the destination of the grain
* @returns the text describing the situation
*/
const confirmationText = () => {
if(selectedSource && selectedDestination){
let source = selectedSource?.value
let destination = selectedDestination?.value
//both are custom
if(source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN && destination.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "Both source and destination are using custom grain types, they may not match. Would you like to continue with the transaction?"
}
//source is custom destination is not
if(source.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "The source is using a custom grain type and may not match the destination. Would you like to continue with the transaction?"
}
//destination is custom source is not
if(destination.storage() === pond.BinStorage.BIN_STORAGE_UNSUPPORTED_GRAIN){
return "The destination is using a custom grain type and may not match the source. Would you like to continue with the transaction?"
}
}
//neither are custom
return "The Grain types do not match. Are you sure you would like to perform this transaction?"
}
const grainChange = () => {
return (
<ResponsiveDialog
open={grainChangeDialog}
onClose={() => {
setGrainChangeDialog(false);
}}>
<DialogContent>
{confirmationText()}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setGrainChangeDialog(false);
}}>
Cancel
</Button>
<Button onClick={updateInventory} variant="contained" color="primary">
Confirm
</Button>
</DialogActions>
</ResponsiveDialog>
);
};
return (
<ResponsiveDialog
open={open}
onClose={() => {
close();
}}>
{grainChange()}
<DialogTitle>New Grain Transaction</DialogTitle>
<DialogContent style={{ minWidth: isMobile ? "100%" : 500 }}>
{/* radio button to determine if the object you are making the adjustment from is the source/destination */}
{!grainAdjustment && !asDestination && (
<RadioGroup>
<FormControlLabel
value="source"
control={<Radio />}
label="Load Out"
onChange={() => {
setIsObject("source");
let option: Option = {
value: mainObject,
label: mainObject.name(),
group: mainObject.objectTypeString()
};
setSelectedSource(option);
setSelectedDestination(null);
}}
/>
<FormControlLabel
value="destination"
control={<Radio />}
label="Load In"
onChange={() => {
setIsObject("destination");
let option: Option = {
value: mainObject,
label: mainObject.name(),
group: mainObject.objectTypeString()
};
setSelectedSource(null);
setSelectedDestination(option);
}}
/>
</RadioGroup>
)}
{!grainAdjustment && (
<TextField
label="Adjustment"
margin="normal"
fullWidth
type="number"
variant="outlined"
value={grainEntry}
error={isNaN(parseFloat(grainEntry))}
helperText={isNaN(parseFloat(grainEntry)) ? "must be a valid number" : ""}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT ? "mT" : "bu"}
</InputAdornment>
)
}}
onChange={e => {
//if the user is viewing the grain in weight
let grainVal = e.target.value;
if (getGrainUnit() === pond.GrainUnit.GRAIN_UNIT_WEIGHT) {
//need to convert what the user input (weight) into what is actually stored (bushels)
//use source of the conversion value if it was selected, otherwise use the destination
if (!isNaN(parseFloat(e.target.value))) {
if (selectedSource) {
grainVal = (+grainVal * selectedSource.value.bushelsPerTonne()).toFixed(2);
} else if (selectedDestination) {
grainVal = (+grainVal * selectedDestination.value.bushelsPerTonne()).toFixed(2);
}
}
}
setGrainEntry(e.target.value);
setGrainChangeVal(grainVal);
}}
/>
)}
<Typography>Leave selection blank to perform a correction</Typography>
{isObject === "source" && destinationSelect()}
{isObject === "destination" && sourceSelect()}
<TextField
label="Message(optional)"
margin="dense"
fullWidth
variant="outlined"
value={grainMessage}
onChange={e => setGrainMessage(e.target.value)}
/>
<Box marginTop={1}>
{allowAttachmentUploads && (
<FileUploader
uploadEnd={fileID => {
if (fileID) {
let ids = imageIDs;
ids.push(fileID);
setImageIDs([...ids]);
}
setUploadingFile(false);
}}
uploadStart={() => {
setUploadingFile(true);
}}
/>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => closeDialogs(false)}>Cancel</Button>
<Button
onClick={confirm}
variant="contained"
color="primary"
disabled={uploadingFile || isNaN(parseFloat(grainEntry))}>
Confirm
</Button>
</DialogActions>
</ResponsiveDialog>
);
}