Interface improvements

This commit is contained in:
Michael Zhang 2022-10-24 14:00:16 -05:00
parent 90924073ed
commit 7d1ab6cb0a
8 changed files with 114 additions and 64 deletions

View file

@ -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>
); );
} }

View 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}
/>
);
}

View file

@ -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) {
> >
&times; &times;
</Badge> </Badge>
</ListGroup.Item> </>
); );
} }

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,4 @@
export const moneyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});

View file

@ -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 }),
]); ]);
} }

View file

@ -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>