Interface improvements
This commit is contained in:
parent
90924073ed
commit
7d1ab6cb0a
8 changed files with 114 additions and 64 deletions
|
@ -2,8 +2,15 @@ import { Atom, useAtom } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
export interface Props {
|
export interface CanBeConvertedToString {
|
||||||
valueAtom: Atom<number>;
|
toString(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Props<T extends CanBeConvertedToString> {
|
||||||
|
valueAtom: Atom<T>;
|
||||||
|
formatter?: (arg: T) => string;
|
||||||
|
inputType?: string;
|
||||||
|
validator: (arg: string) => T | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClickableContainer = styled.span`
|
const ClickableContainer = styled.span`
|
||||||
|
@ -12,7 +19,6 @@ const ClickableContainer = styled.span`
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #eee;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
width: 120px;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
|
@ -25,19 +31,18 @@ const EditingBox = styled.input`
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #eee;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
width: 120px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function EditBox({ valueAtom }: Props) {
|
export default function EditBox<T extends CanBeConvertedToString>({
|
||||||
|
valueAtom,
|
||||||
|
formatter,
|
||||||
|
inputType,
|
||||||
|
validator,
|
||||||
|
}: Props<T>) {
|
||||||
const [value, setValue] = useAtom(valueAtom);
|
const [value, setValue] = useAtom(valueAtom);
|
||||||
const [valueInput, setValueInput] = useState("");
|
const [valueInput, setValueInput] = useState("");
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
const formatter = new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
});
|
|
||||||
|
|
||||||
const startEditing = (_: any) => {
|
const startEditing = (_: any) => {
|
||||||
setValueInput(value.toString());
|
setValueInput(value.toString());
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
@ -45,13 +50,10 @@ export default function EditBox({ valueAtom }: Props) {
|
||||||
|
|
||||||
const finalize = (e: Event) => {
|
const finalize = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
const validateResult = validator(valueInput);
|
||||||
const n = parseFloat(valueInput);
|
if (validateResult !== null) {
|
||||||
if (isNaN(n) || !isFinite(n)) return;
|
setValue(validateResult);
|
||||||
setValue(n);
|
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
} catch (e) {
|
|
||||||
// TODO: Handle
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -60,7 +62,7 @@ export default function EditBox({ valueAtom }: Props) {
|
||||||
<form onSubmit={finalize} style={{ display: "inline" }}>
|
<form onSubmit={finalize} style={{ display: "inline" }}>
|
||||||
<EditingBox
|
<EditingBox
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
type="number"
|
type={inputType ?? "text"}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={valueInput}
|
value={valueInput}
|
||||||
onBlur={finalize}
|
onBlur={finalize}
|
||||||
|
@ -71,7 +73,7 @@ export default function EditBox({ valueAtom }: Props) {
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<ClickableContainer onClick={startEditing}>
|
<ClickableContainer onClick={startEditing}>
|
||||||
{formatter.format(value)}
|
{formatter ? formatter(value) : value.toString()}
|
||||||
</ClickableContainer>
|
</ClickableContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
28
components/NumberEditBox.tsx
Normal file
28
components/NumberEditBox.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { Atom } from "jotai";
|
||||||
|
import EditBox from "./EditBox";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
valueAtom: Atom<number>;
|
||||||
|
formatter?: (arg: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NumberEditBox({ valueAtom, formatter }: Props) {
|
||||||
|
const validator = (arg: string) => {
|
||||||
|
try {
|
||||||
|
const n = parseFloat(arg);
|
||||||
|
if (isNaN(n) || !isFinite(n)) return;
|
||||||
|
return n;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditBox
|
||||||
|
valueAtom={valueAtom}
|
||||||
|
inputType="number"
|
||||||
|
formatter={formatter ?? ((n) => n.toString())}
|
||||||
|
validator={validator}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,13 +1,14 @@
|
||||||
import { Atom, useAtom, WritableAtom } from "jotai";
|
import { Atom, useAtom, WritableAtom } from "jotai";
|
||||||
import { Badge, ListGroup } from "react-bootstrap";
|
import { Badge, ListGroup } from "react-bootstrap";
|
||||||
|
import EditBox from "./EditBox";
|
||||||
|
|
||||||
export interface IPerson {
|
export interface IPerson {
|
||||||
name: string;
|
name: Atom<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
personAtom: Atom<IPerson>;
|
personAtom: Atom<IPerson>;
|
||||||
splitBetweenAtom: WritableAtom<Atom<IPerson>[]>;
|
splitBetweenAtom: Atom<Atom<IPerson>[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Person({ personAtom, splitBetweenAtom }: Props) {
|
export default function Person({ personAtom, splitBetweenAtom }: Props) {
|
||||||
|
@ -19,8 +20,9 @@ export default function Person({ personAtom, splitBetweenAtom }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup.Item>
|
<>
|
||||||
{person.name}
|
<EditBox valueAtom={person.name} validator={(s) => s} />
|
||||||
|
|
||||||
<Badge
|
<Badge
|
||||||
bg="danger"
|
bg="danger"
|
||||||
pill
|
pill
|
||||||
|
@ -29,6 +31,6 @@ export default function Person({ personAtom, splitBetweenAtom }: Props) {
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</Badge>
|
</Badge>
|
||||||
</ListGroup.Item>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
import { Atom, useAtom } from "jotai";
|
import { Atom, useAtom } from "jotai";
|
||||||
import { Badge, Card } from "react-bootstrap";
|
import { Badge, Card } from "react-bootstrap";
|
||||||
|
import { moneyFormatter } from "../lib/formatter";
|
||||||
import { receiptAtom } from "../lib/state";
|
import { receiptAtom } from "../lib/state";
|
||||||
import EditBox from "./EditBox";
|
import EditBox from "./EditBox";
|
||||||
|
import NumberEditBox from "./NumberEditBox";
|
||||||
import { IPerson } from "./Person";
|
import { IPerson } from "./Person";
|
||||||
import SplitBetween from "./SplitBetween";
|
import SplitBetween from "./SplitBetween";
|
||||||
|
|
||||||
export interface IReceiptItem {
|
export interface IReceiptItem {
|
||||||
name: string;
|
name: Atom<string>;
|
||||||
price: Atom<number>;
|
price: Atom<number>;
|
||||||
splitBetween: Atom<Atom<IPerson>[]>;
|
splitBetween: Atom<Atom<IPerson>[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Price({ priceAtom }) {
|
function Price({ priceAtom }) {
|
||||||
return <EditBox valueAtom={priceAtom} />;
|
return (
|
||||||
|
<NumberEditBox valueAtom={priceAtom} formatter={moneyFormatter.format} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
@ -31,7 +35,9 @@ export default function ReceiptItem({ itemAtom }: Props) {
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title className="d-flex justify-content-between align-items-center">
|
<Card.Title className="d-flex justify-content-between align-items-center">
|
||||||
<h3>{item.name}</h3>
|
<h3>
|
||||||
|
<EditBox valueAtom={item.name} validator={(s) => s} />
|
||||||
|
</h3>
|
||||||
<span>
|
<span>
|
||||||
<Price priceAtom={item.price} />
|
<Price priceAtom={item.price} />
|
||||||
<Badge
|
<Badge
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { atom, Atom, useAtom } from "jotai";
|
import { atom, Atom, useAtom } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ListGroup } from "react-bootstrap";
|
import { Button, Form, ListGroup } from "react-bootstrap";
|
||||||
import Person, { IPerson } from "./Person";
|
import Person, { IPerson } from "./Person";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
@ -19,37 +19,37 @@ export default function SplitBetween({ splitBetweenAtom }: Props) {
|
||||||
|
|
||||||
const addPerson = (e) => {
|
const addPerson = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSplitBetween([...splitBetween, atom({ name: input })]);
|
const person = { name: atom(input) };
|
||||||
|
setSplitBetween([...splitBetween, atom(person)]);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
Split between ({splitBetween.length}):
|
Split between ({splitBetween.length}):
|
||||||
<ListGroup horizontal>
|
{splitBetween.map((a, i) => (
|
||||||
{splitBetween.map((a, i) => (
|
<Person
|
||||||
<Person
|
personAtom={a}
|
||||||
personAtom={a}
|
key={`split-${i}`}
|
||||||
key={`split-${i}`}
|
splitBetweenAtom={splitBetweenAtom}
|
||||||
splitBetweenAtom={splitBetweenAtom}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
<Button onClick={startEditing} variant="default">
|
||||||
<ListGroup.Item onClick={startEditing}>
|
{editing ? (
|
||||||
{editing ? (
|
<Form onSubmit={addPerson}>
|
||||||
<form onSubmit={addPerson}>
|
<Form.Control
|
||||||
<input
|
autoFocus={true}
|
||||||
autoFocus={true}
|
type="text"
|
||||||
type="text"
|
value={input}
|
||||||
value={input}
|
placeholder="Add person to split with..."
|
||||||
onBlur={(_) => setEditing(false)}
|
onBlur={(_) => setEditing(false)}
|
||||||
onInput={(e) => setInput(e.target.value)}
|
onInput={(e) => setInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
"[+]"
|
"[+]"
|
||||||
)}
|
)}
|
||||||
</ListGroup.Item>
|
</Button>
|
||||||
</ListGroup>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
4
lib/formatter.ts
Normal file
4
lib/formatter.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export const moneyFormatter = new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
});
|
|
@ -22,32 +22,33 @@ export const receiptTotalAtom = atom((get) => {
|
||||||
subtotalSum += price;
|
subtotalSum += price;
|
||||||
for (const personAtom of splitBetween) {
|
for (const personAtom of splitBetween) {
|
||||||
const person = get(personAtom);
|
const person = get(personAtom);
|
||||||
const personName = person.name;
|
const personName = get(person.name);
|
||||||
let accum = totals.get(personName) || 0;
|
let accum = totals.get(personName) || 0;
|
||||||
accum += eachPrice;
|
accum += eachPrice;
|
||||||
totals.set(personName, accum);
|
totals.set(personName, accum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subtotalSum == 0) return totals;
|
if (subtotalSum == 0) return { subtotal: subtotalSum, totalMap: totals };
|
||||||
|
|
||||||
const newTotals = new Map();
|
const newTotals = new Map();
|
||||||
const proportion = totalValue / subtotalSum;
|
const proportion = totalValue / subtotalSum;
|
||||||
for (const [person, value] of totals.entries()) {
|
for (const [person, value] of totals.entries()) {
|
||||||
newTotals.set(person, value * proportion);
|
newTotals.set(person, value * proportion);
|
||||||
}
|
}
|
||||||
return newTotals;
|
return { subtotal: subtotalSum, totalMap: newTotals };
|
||||||
});
|
});
|
||||||
|
|
||||||
export function addLine(line: string, setReceipt) {
|
export function addLine(line: string, setReceipt) {
|
||||||
let parsed = parseInput(line);
|
let parsed = parseInput(line);
|
||||||
console.log(parsed);
|
console.log(parsed);
|
||||||
|
const name = atom(parsed.itemName);
|
||||||
const price = atom(parsed.price || 0);
|
const price = atom(parsed.price || 0);
|
||||||
const splitBetween = atom(
|
const splitBetween = atom(
|
||||||
[...parsed.splitBetween].map((a) => atom({ name: a }))
|
[...parsed.splitBetween].map((a) => atom({ name: a }))
|
||||||
);
|
);
|
||||||
setReceipt((prev) => [
|
setReceipt((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
atom<IReceiptItem>({ name: parsed.itemName, price, splitBetween }),
|
atom<IReceiptItem>({ name, price, splitBetween }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { atom, useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Form } from "react-bootstrap";
|
import { Form } from "react-bootstrap";
|
||||||
import EditBox from "../components/EditBox";
|
import NumberEditBox from "../components/NumberEditBox";
|
||||||
import ReceiptItem, { IReceiptItem } from "../components/ReceiptItem";
|
import ReceiptItem from "../components/ReceiptItem";
|
||||||
import parseInput, { ParsedInputDisplay } from "../lib/parseInput";
|
import { moneyFormatter } from "../lib/formatter";
|
||||||
|
import { ParsedInputDisplay } from "../lib/parseInput";
|
||||||
import {
|
import {
|
||||||
addLine,
|
addLine,
|
||||||
receiptAtom,
|
receiptAtom,
|
||||||
|
@ -14,8 +15,9 @@ import {
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
const Home: NextPage = () => {
|
||||||
const [receipt, setReceipt] = useAtom(receiptAtom);
|
const [receipt, setReceipt] = useAtom(receiptAtom);
|
||||||
const [total] = useAtom(receiptTotalAtom);
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
const [total] = useAtom(totalAtom);
|
||||||
|
const [calculated] = useAtom(receiptTotalAtom);
|
||||||
|
|
||||||
const formatter = new Intl.NumberFormat("en-US", {
|
const formatter = new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
|
@ -48,20 +50,25 @@ const Home: NextPage = () => {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
Receipt Total:
|
Receipt Total:
|
||||||
<EditBox valueAtom={totalAtom} />
|
<span style={total < calculated.subtotal ? { color: "red" } : {}}>
|
||||||
|
<NumberEditBox
|
||||||
|
valueAtom={totalAtom}
|
||||||
|
formatter={moneyFormatter.format}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{receipt.map((itemAtom, i) => {
|
{receipt.map((itemAtom, i) => {
|
||||||
return <ReceiptItem itemAtom={itemAtom} key={`receiptItem-${i}`} />;
|
return <ReceiptItem itemAtom={itemAtom} key={`receiptItem-${i}`} />;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{total.size > 0 && (
|
{calculated.totalMap.size > 0 && (
|
||||||
<>
|
<>
|
||||||
<h3>Weighted Breakdown</h3>
|
<h3>Weighted Breakdown</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<ul>
|
<ul>
|
||||||
{[...total.entries()].map(([person, value], i) => (
|
{[...calculated.totalMap.entries()].map(([person, value], i) => (
|
||||||
<li key={`breakdown-${i}`}>
|
<li key={`breakdown-${i}`}>
|
||||||
<b>{person}</b>: {formatter.format(value)}
|
<b>{person}</b>: {formatter.format(value)}
|
||||||
</li>
|
</li>
|
||||||
|
|
Loading…
Reference in a new issue