import React, { useState, useContext, useReducer, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import 'bootstrap/dist/css/bootstrap.min.css';
import Card from 'react-bootstrap/Card';
import Container from 'react-bootstrap/Container';
import Col from 'react-bootstrap/Col';
import Navbar from 'react-bootstrap/Navbar';
import Nav from 'react-bootstrap/Nav';
import Button from 'react-bootstrap/Button';
import ButtonToolbar from 'react-bootstrap/ButtonToolbar';
import ButtonGroup from 'react-bootstrap/ButtonGroup';
import Modal from 'react-bootstrap/Modal';
import Form from 'react-bootstrap/Form';
import { BsPlus, BsDownload, BsUpload, BsTrash, BsShuffle } from 'react-icons/bs';
import produce from 'immer';
import currency from 'currency.js';
import axios from 'axios';
import SortableList, { SortableItem } from 'react-easy-sort';
const serv = window.location.origin + "/envs"
const eur = value => currency(value, { symbol: '€' });
const cents = value => currency(value, {symbol: '€', fromCents:true});
//TODO: split file
const HOME = Symbol("HOME")
const NEW_ENV = Symbol("NEW_ENV")
const DELETE_ENV = Symbol("DELETE_ENV")
const MOD_ENV = Symbol("MOD_ENV")
const TRANS_SEL = Symbol("TRANS_SEL")
const DO_TRANS = Symbol("DO_TRANS")
const DispatchContext = React.createContext(() => {});
const useDispatch = () => {
return useContext(DispatchContext);
}
const useResetableState = (init) => {
const [curr, setInner] = useState(init());
const isResetRef = useRef(false);
const state = (isResetRef.current) ? init() : curr;
const set = (val) => {
isResetRef.current = false;
setInner(val);
}
const reset = () => { isResetRef.current = true; }
return [state, set, reset];
}
const action = (action) => (payload) => ({action, payload});
const plus = action("plus")
const del = action("delete")
const select = action("select")
const deselect = action("deselect")
const income = action("income")
const payment = action("payment")
const transSel = action("trans_sel")
const transAmt = action("trans_amt")
const closeNewEnv = action("closeNewEnv")
const swap = (oldIx, newIx) => ({action: "swap", payload: {oldIx, newIx}});
const IconButton = ({variant, children, block, action}) => {
const dispatch = useDispatch();
const onClick = (action === undefined) ? () => {} : () => dispatch(action)
return <Button variant={variant} block={block} onClick={onClick}>{children}</Button>;
};
const PlusButton = () => <IconButton variant="outline-success" action={plus()}><BsPlus size="2em"/></IconButton>;
const InButton = ({action}) => <IconButton variant="outline-secondary" action={action}><BsDownload size="2em"/></IconButton>;
const OutButton = ({action}) => <IconButton variant="outline-secondary" action={action}><BsUpload size="2em"/></IconButton>;
const DeleteButton = ({action}) => <IconButton variant="outline-danger" action={action}><BsTrash size="2em"/></IconButton>;
const TransferButton = ({action, block=false}) => <IconButton variant="outline-info" block={block} action={action}><BsShuffle size="2em"/></IconButton>;
const Env = ({env, showDelete, idx, selected = false}) => {
const dispatch = useDispatch();
const onClick = () => dispatch(select(idx))
const variant = (selected) ? "info" : "light";
const text = (selected) ? "white" : "dark";
const maybeTotal = (env.total === null || env.total === undefined) ? "" : <span className="text-muted">/{env.total.format()}</span>;
const maybeDeleteFooter = (!showDelete)?"":<DeleteButton action={del(idx)}/>
return (
<Card className="text-center" text={text} bg={variant} style={{ minWidth: "10rem", cursor: "pointer" }} >
<Card.Header>
<div className="handle">{env.name}</div>
</Card.Header>
<Card.Body onClick={onClick}>
{env.val.format()}{maybeTotal}
</Card.Body>
<Card.Footer>
{maybeDeleteFooter}
</Card.Footer>
</Card>
);
};
//TODO: confirm deletion
const ModifyEnvModal = ({show, idx, env}) => {
const dispatch = useDispatch();
const [val, setVal] = useState(env && env.val);
const cancel = () => dispatch(deselect());
return <Modal show={show} animation={false} centered onHide={cancel}>
<Modal.Header>
<Modal.Title>Make a transaction from/to `{env && env.name}`</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Control type="number" placeholder={env && env.val} onChange = {(e) => setVal(eur(e.target.value))} />
</Form>
</Modal.Body>
<Modal.Footer>
<ButtonToolbar className="mr-auto">
<DeleteButton action={del(idx)}/>
</ButtonToolbar>
<ButtonGroup>
<InButton action={income({idx, val})}/>
<OutButton action={payment({idx, val})}/>
</ButtonGroup>
</Modal.Footer>
</Modal>
}
const DoTransModal = ({show, fromIdx, toIdx}) => {
const dispatch = useDispatch();
const [val, setVal] = useState(0);
const cancel = () => dispatch(deselect());
return <Modal show={show} animation={false} centered onHide={cancel}>
<Modal.Header>
<Modal.Title>Transfer amount</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Control type="number" placeholder={0} onChange = {(e) => setVal(eur(e.target.value))} />
</Form>
</Modal.Body>
<Modal.Footer>
<ButtonGroup>
<TransferButton action={transAmt({fromIdx, toIdx, val})}/>
</ButtonGroup>
</Modal.Footer>
</Modal>
}
const NewEnvModal = ({show}) => {
const dispatch = useDispatch();
const [state, setState, resetState] = useResetableState(() => ({rowid:null, name:null, val:eur(0), total:null}));
const close = () => { resetState(); dispatch(closeNewEnv()) };
const save = () => { resetState(); dispatch(closeNewEnv(state)) };
const canSave = state.name !== null && state.name !== "";
const onChangeTotal = (e) => {
const newVal = e.target.value;
setState({...state, total:(newVal === "") ? null : eur(e.target.value)});
}
return <Modal show={show} onHide={close} animation={false} centered>
<Modal.Header>
<Modal.Title>New envelope</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group controlId="newEnv-name">
<Form.Control placeholder="Envelope Name" autoComplete="off" onChange={(e) => setState({...state, name:e.target.value})} />
</Form.Group>
<Form.Row>
<Form.Group as={Col} controlId="newEnv-curr">
<Form.Control type="number" placeholder="Current val (0)" autoComplete="off" onChange={(e) => setState({...state, val:eur(e.target.value)})} />
</Form.Group>
<Form.Group as={Col} controlId="newEnv-total">
<Form.Control type="number" placeholder="Total (optional)" autoComplete="off" onChange={onChangeTotal} />
</Form.Group>
</Form.Row>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" disabled={!canSave} onClick={save}>Save</Button>
</Modal.Footer>
</Modal>;
}
const AppModel = ({data}) => {
const dispatch = useDispatch()
const total = data.envs.reduce((acc,env) => acc.add(env.val), eur(0)).format();
const title = (data.state === TRANS_SEL) ? "(Select 2 envelopes to transfer between)" : ("Env budget (" + total + ")");
const onSortEnd = (oldIx, newIx) => dispatch(swap(oldIx, newIx))
return (
<Container>
<Navbar sticky="top" bg="light" expand="lg">
<Navbar.Brand href="#home">{title}</Navbar.Brand>
<Nav className="ml-auto">
<ButtonToolbar>
<ButtonGroup>
<PlusButton/>
<TransferButton action={transSel()} />
<DeleteButton action={del()}/>
</ButtonGroup>
</ButtonToolbar>
</Nav>
</Navbar>
<NewEnvModal show={data.state === NEW_ENV}/>
<ModifyEnvModal show={data.state === MOD_ENV } idx={data.selectedIdx} env={data.envs[data.selectedIdx]} />
<DoTransModal show={data.state === DO_TRANS } fromIdx={data.selectedIdx} toIdx={data.toIdx} />
<SortableList className="envs" onSortEnd={onSortEnd}>
{ data.envs.map((env,i) =>
<SortableItem key={env.rowid}>
<div>
<Env env={env} selected={data.selectedIdx === i || data.toIdx === i} showDelete={data.state === DELETE_ENV} idx={i} />
</div>
</SortableItem>
)}
</SortableList>
</Container>
);
}
const maybeAddIdx = (list, maybeItem) => {
if (maybeItem === undefined) {
return [[...list], undefined]
} else {
return [[...list, maybeItem], list.length]
}
}
const remove = (list, idx) => {
const res = [...list];
res.splice(idx,1);
return res;
}
const toggleState = (state, stToToggle) => {
if (state.state === stToToggle){
state.state = HOME
} else {
state.state = stToToggle
}
}
//TODO: refactor
const dispatch = produce((state, {action, payload}) => {
switch(action) {
case "setInitEnvs":
const st = (state && state.state) || HOME;
return {envs:payload, state:st, selectedIdx:null, toIdx:null, toUpdate:[], toAdd:[], toDelete:[]};
case "plus":
state.state = NEW_ENV
break
case "delete":
if (payload !== null && payload !== undefined) {
if (state.state === MOD_ENV) {
state.state = HOME;
}
state.toDelete.push(state.envs[payload].rowid)
state.envs = remove(state.envs, payload)
} else {
toggleState(state, DELETE_ENV)
}
break
case "income":
if (payload !== null && payload !== undefined && payload.idx !== undefined && payload.val) {
const {idx, val} = payload;
const env = state.envs[idx];
env.val = env.val.add(val);
state.toUpdate.push(env.rowid)
state.state = HOME;
}
break
case "payment":
if (payload !== null && payload !== undefined && payload.idx !== undefined && payload.val) {
const {idx, val} = payload;
const env = state.envs[idx];
env.val = env.val.subtract(val);
state.toUpdate.push(env.rowid)
state.state = HOME;
}
break
case "deselect":
state.state = HOME;
break
case "select":
if (state.state === HOME) {
state.selectedIdx = payload;
state.state = MOD_ENV;
} else if (state.state === TRANS_SEL && state.selectedIdx === payload) {
state.selectedIdx = null;
} else if (state.state === TRANS_SEL && state.selectedIdx !== null) {
// do transfer
state.toIdx = payload;
state.state = DO_TRANS;
} else if (state.state === TRANS_SEL) {
state.selectedIdx = payload;
}
break
case "closeNewEnv":
const [envs, idx] = maybeAddIdx(state.envs, payload);
state.envs = envs
state.toAdd = maybeAddIdx(state.toAdd, idx)[0];
state.state = HOME;
break
case "trans_sel":
toggleState(state, TRANS_SEL)
break
case "trans_amt":
const {fromIdx, toIdx, val} = payload;
const from = state.envs[fromIdx];
const to = state.envs[toIdx];
from.val = from.val.subtract(val);
to.val = to.val.add(val);
state.toUpdate.push(from.rowid)
state.toUpdate.push(to.rowid)
state.state = HOME;
break
case "swap":
const {oldIx, newIx} = payload;
const ords = [0, ...state.envs.map(env => env.ord), state.envs.length+1];
const slotIx = newIx+1
const pairIx = (newIx > oldIx) ? slotIx+1 : slotIx-1;
const env = state.envs[oldIx];
env.ord = (ords[slotIx]+ords[pairIx]) / 2;
state.toUpdate.push(env.rowid);
break;
default: break
}
if (state.state === HOME) {
state.selectedIdx = null;
state.toIdx = null;
}
});
const normalise = (env) => {
const normed = {...env, val:env.val.intValue};
if (env.total !== null && env.total !== undefined) {
normed.total = env.total.intValue;
}
return normed;
}
const doUpdates = (data, doReload) => {
let allChanges = [];
let updates = [];
data.toUpdate.forEach(rowid => {
const env = data.envs.find(e => e.rowid === rowid);
if (env) {
updates.push(normalise(env));
}
});
if (updates.length) {
allChanges.push(axios.patch(serv, updates));
}
data.toAdd.forEach(envIdx => {
const env = normalise(data.envs[envIdx]);
allChanges.push(axios.post(serv, env));
})
data.toDelete.forEach(rowid => {
allChanges.push(axios.delete(serv+"/"+rowid));
})
if (allChanges.length) {
Promise.all(allChanges)
.then(doReload);
}
}
const App = () => {
const [data, disp] = useReducer(dispatch);
const reloadData = () => {
axios.get(serv)
.then(resp => resp.data)
.then(data => data.map(env => {
const newEnv = {...env};
newEnv.val = cents(newEnv.val);
if (newEnv.total !== null && newEnv.total !== undefined) {
newEnv.total = cents(newEnv.total);
}
return newEnv;
}))
.then(data => disp({action:"setInitEnvs", payload: data}))
}
useEffect(reloadData, []);
useEffect(() => {
if (data) {
doUpdates(data, reloadData);
window.data = data
}
}, [data]);
return (
<DispatchContext.Provider value={disp}>
{data ? <AppModel data={data}/> : "loading..." }
</DispatchContext.Provider>
);
};
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);