From 3c6d7f81394dad62dd9baaec9e3b8bb76318fc48 Mon Sep 17 00:00:00 2001 From: Jonathan Nicholson Date: Sat, 24 Apr 2021 21:24:34 -0600 Subject: [PATCH 1/4] wip --- web/Taskfile.yml | 1 + web/src/App.tsx | 22 ++++++++------ web/src/Envelopes.tsx | 69 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 web/src/Envelopes.tsx diff --git a/web/Taskfile.yml b/web/Taskfile.yml index d3fc599..024ba65 100644 --- a/web/Taskfile.yml +++ b/web/Taskfile.yml @@ -12,6 +12,7 @@ tasks: dev: desc: Run the ui development server cmds: + - npm install - npm start build: diff --git a/web/src/App.tsx b/web/src/App.tsx index c0325e2..d125d37 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,6 +8,7 @@ import Activity from './Activity' import { ProvideAuth, useAuth } from './User' import Nav from './Nav' import { ProvideAccount } from './Accounts' +import Envelopes from './Envelopes' function PrivateRoute({children, ...rest}: RouteProps) { const { user } = useAuth() @@ -40,19 +41,22 @@ function App () { -
-
-
+ diff --git a/web/src/Envelopes.tsx b/web/src/Envelopes.tsx new file mode 100644 index 0000000..f18a135 --- /dev/null +++ b/web/src/Envelopes.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react' + +export default function Envelopes () { + const [editing, setEditing] = useState(false) + return ( +
+
+ +

Envelopes

+
+ + + +
+
+
+
    +
  1. +
    + + Coffee + + + $10.00 + +
    +
  2. +
+
+
+ { + editing + ? + : null + } +
+
+
+ ) +} + +interface Envelope { + name: string +} + +function EditEnvelope () { + const [name, setName] = useState('') + const onSubmit = useCallback( + () => { + console.log('submit new envelope call', name) + }, + [name] + ) + return ( +
+
+ + setName(e.target.value)} /> +
+
+ ) +} From 169d9fb68b2d8dec3b3767514cb7e70c6f26279f Mon Sep 17 00:00:00 2001 From: Jonathan Nicholson Date: Sun, 2 May 2021 22:26:53 -0600 Subject: [PATCH 2/4] wip --- web/src/Envelopes.ts | 0 web/src/Envelopes.tsx | 1 + 2 files changed, 1 insertion(+) delete mode 100644 web/src/Envelopes.ts diff --git a/web/src/Envelopes.ts b/web/src/Envelopes.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/Envelopes.tsx b/web/src/Envelopes.tsx index f18a135..17401db 100644 --- a/web/src/Envelopes.tsx +++ b/web/src/Envelopes.tsx @@ -14,6 +14,7 @@ export default function Envelopes () { className="bg-yellow-300 rounded-sm px-4 py-2 ml border border-transparent shadow hover:shadow-md" onClick={() => setEditing(!editing)} > + Create Envelope
From 481902c591e32e4dcd57610aafd62a0de6951428 Mon Sep 17 00:00:00 2001 From: Jonathan Nicholson Date: Tue, 4 May 2021 17:44:28 -0600 Subject: [PATCH 3/4] wip --- web/src/Accounts.ts | 22 +++++- web/src/Activity.tsx | 160 ++++++++++++++---------------------------- web/src/Api.ts | 20 ------ web/src/App.tsx | 9 ++- web/src/Balances.tsx | 55 +++++++++++++++ web/src/Envelopes.tsx | 115 ++++++++++++++++++++---------- web/src/Loading.tsx | 12 ++++ web/src/PlaidApi.ts | 31 +++++++- web/src/Setup.tsx | 19 ++--- 9 files changed, 256 insertions(+), 187 deletions(-) delete mode 100644 web/src/Api.ts create mode 100644 web/src/Balances.tsx create mode 100644 web/src/Loading.tsx diff --git a/web/src/Accounts.ts b/web/src/Accounts.ts index 087fc7b..362ba34 100644 --- a/web/src/Accounts.ts +++ b/web/src/Accounts.ts @@ -1,11 +1,13 @@ import { createElement, createContext, useContext, useState } from 'react' -import { Transaction, Account, Balances, TransactionsResponse } from './PlaidApi' +import { Transaction, Account, Balances, Envelope, TransactionsResponse } from './PlaidApi' interface AccountContext { account: Account|null transactions: Array + envelopes: Array loadAccount: () => Promise loadBalance: () => Promise + loadEnvelopes: () => Promise error: Error|null } @@ -29,10 +31,20 @@ export async function getBalances(): Promise { throw new Error(response.statusText) } +export async function getEnvelopes(): Promise> { + const response = await fetch('/api/get_envelopes', { method: 'POST' }) + if (response.ok) { + const data = await response.json() + return data as Array + } + throw new Error(response.statusText) +} + function useAccountState(): AccountContext { const [account, setAccount] = useState(null) const [balance, setBalance] = useState(null) const [transactions, setTransactions] = useState>([]) + const [envelopes, setEnvelopes] = useState>([]) const [error, setError] = useState(null) async function loadAccount () { const {transactions, accounts} = await getTransactions() @@ -52,11 +64,17 @@ function useAccountState(): AccountContext { async function loadBalance () { getBalances().then() } + async function loadEnvelopes () { + const envelopes = await getEnvelopes() + setEnvelopes(envelopes) + } return { account, transactions, + envelopes, loadAccount, loadBalance, + loadEnvelopes, error } } @@ -68,8 +86,10 @@ const noop = async () => { const accountContext = createContext({ account: null, transactions: [], + envelopes: [], loadAccount: noop, loadBalance: noop, + loadEnvelopes: noop, error: new Error('Invalid Context! This is used out of context') }) diff --git a/web/src/Activity.tsx b/web/src/Activity.tsx index f8d049b..57f21e6 100644 --- a/web/src/Activity.tsx +++ b/web/src/Activity.tsx @@ -40,115 +40,61 @@ export default function Account() { }, [selected]) return ( -
-
-
-

- - Safe-to-Spend - -

-
    -
  • -

    - available balance - -

    -
  • -
  • -

    - scheduled activity - -

    -
  • -
  • -

    - in envelopes -

    -
  • -
-
-
+
+
+
    { - user - && !user.accessToken - && Link your account - } -
- {/* Search -
- x - - -
- Current Filters: -
    -
  1. Last months
  2. -
-
-
*/} - {/* Alerts/notificatons */} -
-
-
    - { - transactions.map((transaction, index, arr) => { - const isSelected = transaction.transaction_id === selected?.transaction_id - const prev = arr[index - 1] - const dateDiff = transaction.date !== prev?.date - return ( - <> - { - dateDiff - ?
  1. - {transaction.date} -
  2. - : null - } -
  3. - + transactions.map((transaction, index, arr) => { + const isSelected = transaction.transaction_id === selected?.transaction_id + const prev = arr[index - 1] + const dateDiff = transaction.date !== prev?.date + return ( + <> + { + dateDiff + ?
  4. + {transaction.date}
  5. - - ) - }) - } -
-
-
- { - selected - ? ( - - ) : null - } -
-
-
+ : null + } +
  • + +
  • + + ) + }) + } + +
    +
    + { + selected + ? ( + + ) : null + } +
    ) } diff --git a/web/src/Api.ts b/web/src/Api.ts deleted file mode 100644 index a94cf74..0000000 --- a/web/src/Api.ts +++ /dev/null @@ -1,20 +0,0 @@ -export async function createLinkToken(): Promise { - const response = await fetch('/api/create_link_token', { - method: 'POST', - credentials: "same-origin", - }) - if (response.ok) { - return response.text() - } - throw new Error(response.statusText) -} - -export async function createAccessToken(publicToken: string): Promise { - const body = JSON.stringify({ public_token: publicToken }) - const headers = { 'Content-Type': 'application/json' } - const response = await fetch('/api/get_access_token', { method: 'POST', headers, body }) - if (response.ok) { - return response.text() - } - throw new Error(response.statusText) -} \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index d125d37..35b630d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Switch, Route, Link, NavLink, Redirect, RouteProps } from 'react-router-dom' +import { Switch, Route, Redirect, RouteProps } from 'react-router-dom' import Landing from './Landing' import Login from './Login' import Signup from './Signup' @@ -9,6 +9,7 @@ import { ProvideAuth, useAuth } from './User' import Nav from './Nav' import { ProvideAccount } from './Accounts' import Envelopes from './Envelopes' +import Balances from './Balances' function PrivateRoute({children, ...rest}: RouteProps) { const { user } = useAuth() @@ -45,14 +46,12 @@ function App () {
    diff --git a/web/src/Balances.tsx b/web/src/Balances.tsx new file mode 100644 index 0000000..1827998 --- /dev/null +++ b/web/src/Balances.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { Money } from './Money' +import { useAccount } from './Accounts' +import { useAuth } from './User' + +export default function Balances () { + const { account } = useAccount() + const { user } = useAuth() + return ( +
    +

    + + Safe-to-Spend + +

    +
      +
    • +

      + available balance + +

      +
    • +
    • +

      + scheduled activity + +

      +
    • +
    • +

      + + in envelopes +

      +
    • +
    +
    + { + user + && !user.accessToken + && ( +

    You have not linked your account! + + Link Account + +

    + ) + } +
    +
    + ) +} \ No newline at end of file diff --git a/web/src/Envelopes.tsx b/web/src/Envelopes.tsx index 17401db..ce99bdf 100644 --- a/web/src/Envelopes.tsx +++ b/web/src/Envelopes.tsx @@ -1,52 +1,88 @@ import React, { useCallback, useEffect, useState } from 'react' +import { useAccount } from './Accounts' +import { Envelope } from './PlaidApi' +import { Money } from './Money' +import Loading from './Loading' export default function Envelopes () { - const [editing, setEditing] = useState(false) + const { envelopes, loadEnvelopes } = useAccount() + const [editing, setEditing] = useState() + const [loading, setLoading] = useState(false) + const templates: Array = [ + { name: '🏠 Rent', notes: 'Have funds ready for your rent', balance: 0 }, + { name: '🏖️ Vacation', notes: 'Start saving for that trip', balance: 0 } + ] + useEffect(() => { + setLoading(true) + loadEnvelopes() + .then(() => { + setTimeout(() => setLoading(false), 2000) + }) + .catch(err => { + setTimeout(() => setLoading(false), 2000) + }) + }, []) return ( -
    -
    - -

    Envelopes

    -
    - - - -
    -
    -
    -
      -
    1. -
      - - Coffee - - - $10.00 - -
      -
    2. -
    +
    +
    +
    +
    + +

    Envelopes

    +
    - { - editing - ? - : null + { + loading + ? + : <> +

    No envelopes! Create an envelope and start saving money.

    +

    Get started with some of these envelope ideas:

    +
      + { + (envelopes.length ? envelopes : templates).map(e => { + return ( +
    1. +
      + + {e.name} + {e.notes} + + + + + +
      +
    2. + ) + }) + } +
    + }
    +
    + + + + { + editing + ? + : null + } +
    +
    ) } -interface Envelope { - name: string } function EditEnvelope () { @@ -59,12 +95,15 @@ function EditEnvelope () { ) return (
    -
    + +
    - setName(e.target.value)} /> + setName(e.target.value)} /> +
    ) } + diff --git a/web/src/Loading.tsx b/web/src/Loading.tsx new file mode 100644 index 0000000..6733648 --- /dev/null +++ b/web/src/Loading.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +export default function Loading (props = { spinnerClass: "text-white" }) { + return ( + + + + + Loading... + + ) + } diff --git a/web/src/PlaidApi.ts b/web/src/PlaidApi.ts index 7a05d4a..f11c389 100644 --- a/web/src/PlaidApi.ts +++ b/web/src/PlaidApi.ts @@ -38,6 +38,8 @@ export interface Balances { available: number, current: number, limit: number, + envelopes: number, + safe_to_spend: number iso_currency_code: IsoCurrencyCode, unofficial_currency_code: string } @@ -116,4 +118,31 @@ export type TransactionsResponse = { request_id: string, total_transactions: number, transactions: Array -} \ No newline at end of file +} + +export interface Envelope { + name: string + balance: number + notes: string +} + +export async function createLinkToken(): Promise { + const response = await fetch('/api/create_link_token', { + method: 'POST', + credentials: "same-origin", + }) + if (response.ok) { + return response.text() + } + throw new Error(response.statusText) + } + + export async function createAccessToken(publicToken: string): Promise { + const body = JSON.stringify({ public_token: publicToken }) + const headers = { 'Content-Type': 'application/json' } + const response = await fetch('/api/get_access_token', { method: 'POST', headers, body }) + if (response.ok) { + return response.text() + } + throw new Error(response.statusText) + } \ No newline at end of file diff --git a/web/src/Setup.tsx b/web/src/Setup.tsx index 5fb9c81..bba1c3d 100644 --- a/web/src/Setup.tsx +++ b/web/src/Setup.tsx @@ -1,24 +1,13 @@ import React, { ReactElement, useCallback, useEffect, useState } from 'react' -import { createLinkToken, createAccessToken } from './Api' import { usePlaidLink } from 'react-plaid-link' import { Redirect } from 'react-router' -import { Account } from './PlaidApi' -import { getTransactions } from './Accounts' +import { Account, createLinkToken, createAccessToken } from './PlaidApi' +import { getTransactions, } from './Accounts' import { useAuth } from './User' - -function Loading () { - return ( - - - - - Loading... - - ) -} +import Loading from './Loading' interface LinkDetails { - public_token: string, + public_token: string accounts: Array } From 3501934cb73bd7c7f943f753562abedf6942477f Mon Sep 17 00:00:00 2001 From: Jonathan Nicholson Date: Wed, 5 May 2021 00:47:34 -0600 Subject: [PATCH 4/4] MVP Envelope creation and spending --- web/src/Accounts.ts | 49 +++++++- web/src/Activity.tsx | 79 ++++++++++-- web/src/Balances.tsx | 18 ++- web/src/Envelopes.tsx | 277 +++++++++++++++++++++++++++++++----------- web/src/Loading.tsx | 4 +- web/src/PlaidApi.ts | 4 +- 6 files changed, 341 insertions(+), 90 deletions(-) diff --git a/web/src/Accounts.ts b/web/src/Accounts.ts index 362ba34..a0cb30e 100644 --- a/web/src/Accounts.ts +++ b/web/src/Accounts.ts @@ -1,4 +1,4 @@ -import { createElement, createContext, useContext, useState } from 'react' +import { createElement, createContext, useContext, useCallback, useState } from 'react' import { Transaction, Account, Balances, Envelope, TransactionsResponse } from './PlaidApi' interface AccountContext { @@ -8,6 +8,10 @@ interface AccountContext { loadAccount: () => Promise loadBalance: () => Promise loadEnvelopes: () => Promise + appendEnvelope: (e: Envelope) => void + removeEnvelope: (e: Envelope) => void + updateEnvelope: (e: Envelope) => void + spendFromEnvelope: (e: Envelope, trx: Transaction) => void error: Error|null } @@ -46,6 +50,7 @@ function useAccountState(): AccountContext { const [transactions, setTransactions] = useState>([]) const [envelopes, setEnvelopes] = useState>([]) const [error, setError] = useState(null) + async function loadAccount () { const {transactions, accounts} = await getTransactions() // TODO: Protected envelopes are actually savings accounts. @@ -68,6 +73,40 @@ function useAccountState(): AccountContext { const envelopes = await getEnvelopes() setEnvelopes(envelopes) } + const appendEnvelope = useCallback((e: Envelope) => { + setEnvelopes(current => [...current, e]) + }, []) + const removeEnvelope = useCallback((e: Envelope) => { + setEnvelopes(current => { + return current.filter(c => c.id != e.id) + }) + }, []) + const updateEnvelope = useCallback((e: Envelope) => { + setEnvelopes(current => { + return current.map((c) => { + if (c.id == e.id) return {...c, ...e} + return c + }) + }) + }, []) + const spendFromEnvelope = useCallback((e: Envelope, trx: Transaction) => { + setTransactions(current => { + return current.map(c => { + if (c.transaction_id == trx.transaction_id) { + return {...c, envelope_id: e.id } + } + return c + }) + }) + setEnvelopes(current => { + return current.map((c) => { + if (c.id == e.id) { + return {...c, balance: (c.balance - Math.min(c.balance, trx.amount)) } + } + return c + }) + }) + }, [envelopes, transactions]) return { account, transactions, @@ -75,6 +114,10 @@ function useAccountState(): AccountContext { loadAccount, loadBalance, loadEnvelopes, + appendEnvelope, + removeEnvelope, + updateEnvelope, + spendFromEnvelope, error } } @@ -90,6 +133,10 @@ const accountContext = createContext({ loadAccount: noop, loadBalance: noop, loadEnvelopes: noop, + appendEnvelope: noop, + removeEnvelope: noop, + updateEnvelope: noop, + spendFromEnvelope: noop, error: new Error('Invalid Context! This is used out of context') }) diff --git a/web/src/Activity.tsx b/web/src/Activity.tsx index 57f21e6..6d947c0 100644 --- a/web/src/Activity.tsx +++ b/web/src/Activity.tsx @@ -1,20 +1,20 @@ import React, { useCallback, useEffect, useState, ReactElement, useMemo } from 'react' import { useAccount } from './Accounts' import { Money } from './Money' -import { Transaction } from './PlaidApi' +import { Transaction, Envelope } from './PlaidApi' import { DateTime } from 'luxon' import { useAuth } from './User' -import { Link } from 'react-router-dom' - +import Loading from './Loading' export default function Account() { const { user } = useAuth() const [loading, setLoading] = useState(false) const [selected, setSelected] = useState() const { - account, transactions, loadAccount, + envelopes, + spendFromEnvelope, error: accountError } = useAccount() @@ -44,10 +44,14 @@ export default function Account() {
      { - transactions.map((transaction, index, arr) => { + loading + ? + : transactions.map((transaction, index, arr) => { const isSelected = transaction.transaction_id === selected?.transaction_id const prev = arr[index - 1] const dateDiff = transaction.date !== prev?.date + const spentFrom = transaction.envelope_id != null + && envelopes.find(e => e.id == transaction.envelope_id) return ( <> { @@ -66,7 +70,9 @@ export default function Account() { + category={transaction.category[0]} + spendFrom={spentFrom ? spentFrom.name : undefined} + /> ) @@ -90,7 +96,7 @@ export default function Account() { Posted Transaction - + ) : null } @@ -103,7 +109,7 @@ export default function Account() { type TransactionItemProps = { name: string, amount: number, - from_balance?: string, + spendFrom?: string, pending?: string, category: string, } @@ -118,7 +124,7 @@ function TransactionItem (props: TransactionItemProps): ReactElement { - Spent from: {props.from_balance || "Safe-to-Spend"} + Spent from: {props.spendFrom || "Safe-to-Spend"} { props.category }
    @@ -130,10 +136,54 @@ type TransactionDetails = { } function TransactionDetails (props: TransactionDetails) { + const { spendFromEnvelope, envelopes } = useAccount() const { transaction } = props - const isPending = transaction.pending const [categoryBroad, categorySpecific] = transaction.category + const spentFrom = useMemo(() => { + return transaction.envelope_id != null + ? envelopes.find(e => e.id == transaction.envelope_id) + : undefined + }, []) + const [editing, setEditing] = useState(false) + const [newSpendFrom, setNewSpendFrom] = useState() const date = DateTime.fromISO(transaction.date) + if (editing) { + return ( +
    +
      +
    1. setNewSpendFrom(undefined)} + className={`py-2 cursor-pointer hover:bg-purple-200 ${newSpendFrom == null ? 'bg-purple-100' : '' }`}> + Safe-To-Spend +
    2. + { + envelopes.map(e => { + return ( +
    3. setNewSpendFrom(e)} + className={`py-2 cursor-pointer hover:bg-purple-200 ${newSpendFrom?.id == e.id ? 'bg-purple-100' : ''}`} + > + {e.name} +
    4. + ) + }) + } +
    +
    + + +
    +
    + ) + } return (

    {date.toLocaleString()}

    @@ -155,7 +205,14 @@ function TransactionDetails (props: TransactionDetails) {
    Spent From
    -
    Safe-to-Spend
    +
    + { spentFrom ? spentFrom.name : "Safe-to-Spend" } + + + +
    Memo
    diff --git a/web/src/Balances.tsx b/web/src/Balances.tsx index 1827998..5ec03f5 100644 --- a/web/src/Balances.tsx +++ b/web/src/Balances.tsx @@ -1,16 +1,22 @@ -import React from 'react' +import React, { useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { Money } from './Money' import { useAccount } from './Accounts' import { useAuth } from './User' export default function Balances () { - const { account } = useAccount() + const { account, envelopes } = useAccount() const { user } = useAuth() + const envelopeSum = useMemo(() => { + return envelopes.reduce((sum, e) => sum + e.balance, 0.00) + }, [envelopes]) + const safeToSpend = useMemo(() => { + return (account?.balances?.available || 0.00) - envelopeSum + }, [account, envelopeSum]) return ( -
    +

    - + Safe-to-Spend

    @@ -29,7 +35,7 @@ export default function Balances () {
  • - + in envelopes

  • @@ -41,7 +47,7 @@ export default function Balances () { && (

    You have not linked your account! Link Account diff --git a/web/src/Envelopes.tsx b/web/src/Envelopes.tsx index ce99bdf..33ac28d 100644 --- a/web/src/Envelopes.tsx +++ b/web/src/Envelopes.tsx @@ -1,109 +1,250 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useAccount } from './Accounts' import { Envelope } from './PlaidApi' import { Money } from './Money' import Loading from './Loading' export default function Envelopes () { - const { envelopes, loadEnvelopes } = useAccount() + const {envelopes, loadEnvelopes, appendEnvelope, removeEnvelope, updateEnvelope} = useAccount() const [editing, setEditing] = useState() const [loading, setLoading] = useState(false) + const [error, setError] = useState() const templates: Array = [ { name: '🏠 Rent', notes: 'Have funds ready for your rent', balance: 0 }, - { name: '🏖️ Vacation', notes: 'Start saving for that trip', balance: 0 } + { name: '🏖️ Vacation', notes: 'Start saving for that trip', balance: 0 }, + { name: '💻 New Computer', notes: 'Get that new Macbook', balance: 0 } ] useEffect(() => { setLoading(true) loadEnvelopes() .then(() => { - setTimeout(() => setLoading(false), 2000) + setTimeout(() => setLoading(false), 100) }) .catch(err => { - setTimeout(() => setLoading(false), 2000) + setTimeout(() => { + setLoading(false) + setError(err) + }, 500) }) }, []) + const handleEnvelope = useCallback( + (envelope: Envelope) => { + appendEnvelope(envelope) + setEditing(null) + }, + [] + ) return (

    -
    -
    - -

    Envelopes

    -
    -
    -
    - { - loading - ? - : <> -

    No envelopes! Create an envelope and start saving money.

    -

    Get started with some of these envelope ideas:

    -
      - { - (envelopes.length ? envelopes : templates).map(e => { - return ( -
    1. -
      - - {e.name} - {e.notes} - - - - +
      + { + loading + ? + : <> + { + !envelopes.length + ? ( +
      +

      - No envelopes -

      +

      Create an envelope and start budgeting! Here are some envelope ideas to get you started:

      +
      + ) + : null + } +
        + { + (envelopes.length ? envelopes : templates).map((e, i) => { + return ( +
      1. setEditing(e)}> +
        + + {e.name} + {e.notes} -
        -
      2. - ) - }) - } -
      - - } -
      +
      + + + +
      +
    2. + ) + }) + } +
    + + }
    -
    - - - +
    { editing - ? - : null + ? setEditing(null)} + onCreate={handleEnvelope} + onDelete={() => { + removeEnvelope(editing) + setEditing(null) + }} + onUpdate={(e: Envelope) => { + updateEnvelope(e) + setEditing(null) + }} + /> + : ( + + + + ) }
    ) } -} - -function EditEnvelope () { - const [name, setName] = useState('') - const onSubmit = useCallback( - () => { - console.log('submit new envelope call', name) +function EditEnvelope (props: { envelope: Envelope, onCancel: () => void, onCreate: (e: Envelope) => void, onDelete: () => void, onUpdate: (e: Envelope) => void }) { + const [name, setName] = useState(props.envelope.name) + const [balance, setBalance] = useState(props.envelope.balance.toFixed(2)) + const [writing, setWriting] = useState(false) + const isNew = useMemo(() => props.envelope.id == null, []) + const handleReset = useCallback( + (event: React.FormEvent) => { + event.preventDefault() + props.onCancel() + }, + [] + ) + const handleDelete = useCallback( + () => props.onDelete(), + [] + ) + const handleSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault() + isNew ? handleCreate() : handleUpdate() }, - [name] + [name, balance] + ) + // FIXME: we are simulating balance transfer + const handleUpdate = useCallback( + async () => { + setWriting(true) + console.log("Attempting to update envelope", name, balance) + setTimeout(() => { + props.onUpdate({ + id: props.envelope.id, + name, + balance: parseFloat(balance), + notes: '' + }) + }, 300) + }, + [name, balance] + ) + // FIXME: we are currently abusing the envelope create + // API to simulate moving money into an envelope + const handleCreate = useCallback( + async () => { + setWriting(true) + console.log("Attempting to create envelope", name, balance) + const form = new FormData() + form.append("name", name) + form.append("target_balance", balance) + const response = await fetch('/api/envelopes', { + method: 'POST', + body: form + }) + if (!response.ok) { + const detail = await response.json() + const message = detail?.message || response.statusText + throw new Error(message) + } + const envelopeResponse = await response.json() + const { Name, TargetAmount } = envelopeResponse + // https://www.w3resource.com/javascript-exercises/fundamental/javascript-fundamental-exercise-148.php + const Id = Name.split('').reduce((hashCode: number, ch: string) => + (hashCode = ch.charCodeAt(0) + (hashCode << 6) + (hashCode << 16) - hashCode), + 0 + ) + console.debug('created envelope', Id) + const newEnvelope = { + id: Id, + name: Name, + notes: '', + balance: TargetAmount + } + setWriting(false) + props.onCreate(newEnvelope) + }, + [name, balance] ) return ( -
    -
    -
    - - setName(e.target.value)} /> +
    + ) } diff --git a/web/src/Loading.tsx b/web/src/Loading.tsx index 6733648..5b16c45 100644 --- a/web/src/Loading.tsx +++ b/web/src/Loading.tsx @@ -1,9 +1,9 @@ import React from 'react' -export default function Loading (props = { spinnerClass: "text-white" }) { +export default function Loading (props: { spinnerClass?: string }) { return ( - + Loading... diff --git a/web/src/PlaidApi.ts b/web/src/PlaidApi.ts index f11c389..436d0bd 100644 --- a/web/src/PlaidApi.ts +++ b/web/src/PlaidApi.ts @@ -38,8 +38,6 @@ export interface Balances { available: number, current: number, limit: number, - envelopes: number, - safe_to_spend: number iso_currency_code: IsoCurrencyCode, unofficial_currency_code: string } @@ -110,6 +108,7 @@ export interface Transaction { transaction_id: string, transaction_type: string transaction_code: string + envelope_id?: string } export type TransactionsResponse = { @@ -121,6 +120,7 @@ export type TransactionsResponse = { } export interface Envelope { + id?: string name: string balance: number notes: string