-
1.env
-
26.eslintrc.json
-
23.gitignore
-
21LICENSE
-
64README.md
-
30deploy.sh
-
BINdoc/add-expense.gif
-
BINdoc/delete-expense.gif
-
BINdoc/edit-expense.gif
-
BINdoc/expenses.png
-
BINdoc/zapier.png
-
39293package-lock.json
-
32package.json
-
BINpublic/android-chrome-192x192.png
-
BINpublic/android-chrome-512x512.png
-
BINpublic/apple-touch-icon.png
-
9public/browserconfig.xml
-
BINpublic/favicon-16x16.png
-
BINpublic/favicon-32x32.png
-
BINpublic/favicon.ico
-
29public/index.html
-
20public/manifest.json
-
BINpublic/mstile-144x144.png
-
BINpublic/mstile-150x150.png
-
BINpublic/mstile-310x150.png
-
BINpublic/mstile-310x310.png
-
BINpublic/mstile-70x70.png
-
24public/safari-pinned-tab.svg
-
9public/web.config
-
50src/App.css
-
375src/App.js
-
8src/App.test.js
-
130src/components/LoadingBar.js
-
18src/components/expense-form/ExpenseForm.css
-
191src/components/expense-form/ExpenseForm.js
-
BINsrc/components/expense-list/.ExpenseList.css.swp
-
32src/components/expense-list/ExpenseDetail.js
-
55src/components/expense-list/ExpenseIcon.js
-
44src/components/expense-list/ExpenseList.css
-
22src/components/expense-list/ExpenseList.js
-
9src/components/index.js
-
6src/index.css
-
10src/index.js
-
51src/registerServiceWorker.js
-
11627yarn.lock
@ -0,0 +1 @@ |
|||||
|
PORT=8080 |
||||
@ -0,0 +1,26 @@ |
|||||
|
{ |
||||
|
"env": { |
||||
|
"browser": true, |
||||
|
"commonjs": true, |
||||
|
"es6": true, |
||||
|
"node": true |
||||
|
}, |
||||
|
"parserOptions": { |
||||
|
"ecmaFeatures": { |
||||
|
"jsx": true |
||||
|
}, |
||||
|
"sourceType": "module" |
||||
|
}, |
||||
|
"rules": { |
||||
|
"no-const-assign": "warn", |
||||
|
"no-this-before-super": "warn", |
||||
|
"no-undef": "warn", |
||||
|
"no-unreachable": "warn", |
||||
|
"no-unused-vars": "warn", |
||||
|
"constructor-super": "warn", |
||||
|
"valid-typeof": "warn" |
||||
|
}, |
||||
|
"plugins": [ |
||||
|
"react" |
||||
|
] |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
# See https://help.github.com/ignore-files/ for more about ignoring files. |
||||
|
|
||||
|
# dependencies |
||||
|
/node_modules |
||||
|
|
||||
|
# testing |
||||
|
/coverage |
||||
|
|
||||
|
# production |
||||
|
/build |
||||
|
/production |
||||
|
/demo |
||||
|
|
||||
|
# misc |
||||
|
.DS_Store |
||||
|
.env.local |
||||
|
.env.development.local |
||||
|
.env.test.local |
||||
|
.env.production.local |
||||
|
|
||||
|
npm-debug.log* |
||||
|
yarn-debug.log* |
||||
|
yarn-error.log* |
||||
@ -0,0 +1,21 @@ |
|||||
|
MIT License |
||||
|
|
||||
|
Copyright (c) 2017 Jakub Chodounsky |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
|
of this software and associated documentation files (the "Software"), to deal |
||||
|
in the Software without restriction, including without limitation the rights |
||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
|
copies of the Software, and to permit persons to whom the Software is |
||||
|
furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all |
||||
|
copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
|
SOFTWARE. |
||||
@ -0,0 +1,64 @@ |
|||||
|
💰Expenses is a [progressive web application](https://developers.google.com/web/progressive-web-apps/) on top of [Google Sheets](https://developers.google.com/sheets/) 📉 written in [React](https://facebook.github.io/react/) ⚛️. It is only a static HTML that works great on mobile 📱 and can be deployed anywhere. |
||||
|
|
||||
|
Check out the [demo](https://demo-expenses.chodounsky.net) but please be considerate and don't break it for others. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
It was inspired by the [expense-manager](https://github.com/mitul45/expense-manager) by mitul45 and it uses the [material web components](https://material.io/components/) and [material icons](https://material.io/icons/). |
||||
|
|
||||
|
## Features |
||||
|
|
||||
|
* Multiple accounts |
||||
|
* Checking, savings, joint, etc. |
||||
|
* Categories |
||||
|
* [Google Sheet](https://docs.google.com/spreadsheets/d/1Lz1_gHIgCKPKhJpFerq9PoNy-TIst7eLZ5plQi5Prv0/edit?usp=sharing) as a backend |
||||
|
* Great privacy and access control. |
||||
|
* Don't share sensitive data with 3rd party. |
||||
|
* Unlimited analysis up to your sheet skill. |
||||
|
* Works great on mobile |
||||
|
* Progressive Web App. Loads quickly and works as a standalone app. |
||||
|
* Beautiful material design |
||||
|
* Better than native ;) |
||||
|
* Recurring expenses |
||||
|
* Totally doable with [Zapier](http://zapier.com/). |
||||
|
* Monthly summary |
||||
|
* This month. Last month. You immediately know how you doing. |
||||
|
|
||||
|
## Get started |
||||
|
|
||||
|
You will need a somewhat recent version of [Node](https://nodejs.org/en/) and a place to deploy static HTML under a custom domain (doesn't have to be top level). To get the full offline support with service workers you'll need HTTPS – [CloudFlare](cloudflare.com) works fine or you can use your own certificate. |
||||
|
|
||||
|
1) make a copy of [Expense Sheet](https://docs.google.com/spreadsheets/d/1Lz1_gHIgCKPKhJpFerq9PoNy-TIst7eLZ5plQi5Prv0/edit?usp=sharing) to your drive `File -> Make a copy...` |
||||
|
2) note the id of your new sheet (it's part of the URL) |
||||
|
3) clone, install dependencies and build the app: |
||||
|
|
||||
|
|
||||
|
``` |
||||
|
npm i && REACT_APP_SHEET_ID=<replace with your sheet id> npm run build |
||||
|
``` |
||||
|
|
||||
|
4) copy the content of `build` folder to your server |
||||
|
|
||||
|
### Recurring Expenses |
||||
|
|
||||
|
Zapier is a service for connecting apps and automating your workflows. And it can be used to add recurring expenses with the [Google Sheets Integrations](https://zapier.com/zapbook/google-sheets/). |
||||
|
|
||||
|
Select a trigger – it could be every month, week, or based on anything else. |
||||
|
|
||||
|
Use the `Create Spreadsheet Row` integration and select your expense sheet and fill it with the desired values. Easy. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### Sharing |
||||
|
|
||||
|
Adding another person (for example your partner) to the app is easy – you just give them access to the expense sheet in Google Sheets. |
||||
|
|
||||
|
After that, they have the same access as you are and can add expenses through the same URL. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
If you like this project – you might also enjoy [React Digest](https://reactdigest.net/) newsletter 🗞. Subscribe to get the top 5 news from React community every week. |
||||
|
|
||||
|
## Install |
||||
|
|
||||
|
rm -rf node_modules && npm i && REACT_APP_SHEET_ID=1TT4oJ7B_Lq4quyizxK2DxGR_qhYtaG4Mn40j2MNpB6E npm run build && cd build && rm ../expenses.tgz && tar cvzf ../expenses.tgz * && scp ../expenses.tgz jumper@144spokes.com:/tmp/ && cd .. |
||||
@ -0,0 +1,30 @@ |
|||||
|
#!/bin/sh |
||||
|
|
||||
|
if [ $# -eq 0 ] |
||||
|
then |
||||
|
echo "Add a commit message" |
||||
|
return 1 |
||||
|
fi |
||||
|
|
||||
|
git add . |
||||
|
git commit -m "$1" |
||||
|
git push origin master |
||||
|
|
||||
|
# expenses.chodounsky.net |
||||
|
REACT_APP_SHEET_ID=18uwYwUAVw0H5bhszMgAORmvAN2APxAtJI3FB-XH7Dzk npm run build |
||||
|
cp -a build/. production/ |
||||
|
cd production |
||||
|
git add . |
||||
|
git commit -m "$1" |
||||
|
git push origin production |
||||
|
cd .. |
||||
|
|
||||
|
# github pages |
||||
|
|
||||
|
npm run build |
||||
|
cp -a build/. demo/ |
||||
|
cd demo |
||||
|
git add . |
||||
|
git commit -m "$1" |
||||
|
git push origin gh-pages |
||||
|
cd .. |
||||
|
After Width: 409 | Height: 726 | Size: 161 KiB |
|
After Width: 409 | Height: 726 | Size: 113 KiB |
|
After Width: 409 | Height: 726 | Size: 106 KiB |
|
After Width: 826 | Height: 1468 | Size: 153 KiB |
|
After Width: 2600 | Height: 1766 | Size: 379 KiB |
39293
package-lock.json
File diff suppressed because it is too large
View File
@ -0,0 +1,32 @@ |
|||||
|
{ |
||||
|
"name": "expenses", |
||||
|
"version": "0.1.0", |
||||
|
"private": true, |
||||
|
"dependencies": { |
||||
|
"material-components-web": "^0.12.1", |
||||
|
"material-icons": "^0.1.0", |
||||
|
"react": "^15.5.4", |
||||
|
"react-dom": "^15.5.4" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"react-scripts": "^3.4.1" |
||||
|
}, |
||||
|
"scripts": { |
||||
|
"start": "react-scripts start", |
||||
|
"build": "react-scripts build", |
||||
|
"test": "react-scripts test --env=jsdom", |
||||
|
"eject": "react-scripts eject" |
||||
|
}, |
||||
|
"browserslist": { |
||||
|
"production": [ |
||||
|
">0.2%", |
||||
|
"not dead", |
||||
|
"not op_mini all" |
||||
|
], |
||||
|
"development": [ |
||||
|
"last 1 chrome version", |
||||
|
"last 1 firefox version", |
||||
|
"last 1 safari version" |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
After Width: 192 | Height: 192 | Size: 1.0 KiB |
|
After Width: 512 | Height: 512 | Size: 3.6 KiB |
|
After Width: 180 | Height: 180 | Size: 1.1 KiB |
@ -0,0 +1,9 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<browserconfig> |
||||
|
<msapplication> |
||||
|
<tile> |
||||
|
<square150x150logo src="/mstile-150x150.png"/> |
||||
|
<TileColor>#50d29d</TileColor> |
||||
|
</tile> |
||||
|
</msapplication> |
||||
|
</browserconfig> |
||||
|
After Width: 16 | Height: 16 | Size: 530 B |
|
After Width: 32 | Height: 32 | Size: 617 B |
@ -0,0 +1,29 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="en"> |
||||
|
|
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
||||
|
<meta name="theme-color" content="#27AE60"> |
||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"> |
||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> |
||||
|
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> |
||||
|
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png"> |
||||
|
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png"> |
||||
|
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png"> |
||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"> |
||||
|
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5bbad5"> |
||||
|
<meta name="theme-color" content="#50d29d"> |
||||
|
<script src="https://apis.google.com/js/api.js"></script> |
||||
|
|
||||
|
<title>Expenses</title> |
||||
|
</head> |
||||
|
|
||||
|
<body> |
||||
|
<noscript> |
||||
|
You need to enable JavaScript to run this app. |
||||
|
</noscript> |
||||
|
<div id="root"></div> |
||||
|
</body> |
||||
|
|
||||
|
</html> |
||||
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"short_name": "Expenses", |
||||
|
"name": "Expense Manager", |
||||
|
"icons": [ |
||||
|
{ |
||||
|
"src": "/android-chrome-192x192.png", |
||||
|
"sizes": "192x192", |
||||
|
"type": "image/png" |
||||
|
}, |
||||
|
{ |
||||
|
"src": "/android-chrome-512x512.png", |
||||
|
"sizes": "512x512", |
||||
|
"type": "image/png" |
||||
|
} |
||||
|
], |
||||
|
"theme_color": "#50d29d", |
||||
|
"background_color": "#50d29d", |
||||
|
"display": "standalone", |
||||
|
"start_url": "." |
||||
|
} |
||||
|
After Width: 144 | Height: 144 | Size: 1.2 KiB |
|
After Width: 270 | Height: 270 | Size: 1.5 KiB |
|
After Width: 558 | Height: 270 | Size: 1.6 KiB |
|
After Width: 558 | Height: 558 | Size: 2.9 KiB |
|
After Width: 128 | Height: 128 | Size: 1.0 KiB |
@ -0,0 +1,24 @@ |
|||||
|
<?xml version="1.0" standalone="no"?> |
||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" |
||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> |
||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" |
||||
|
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" |
||||
|
preserveAspectRatio="xMidYMid meet"> |
||||
|
<metadata> |
||||
|
Created by potrace 1.11, written by Peter Selinger 2001-2013 |
||||
|
</metadata> |
||||
|
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" |
||||
|
fill="#000000" stroke="none"> |
||||
|
<path d="M1405 6122 c-150 -20 -254 -70 -356 -171 -82 -82 -134 -174 -160 |
||||
|
-281 -20 -83 -21 -4211 -1 -4320 40 -221 220 -408 442 -461 83 -20 4211 -21 |
||||
|
4320 -1 211 38 389 200 452 412 16 53 20 101 22 258 l2 192 -1345 0 c-1315 0 |
||||
|
-1348 1 -1426 20 -226 57 -406 260 -432 488 -4 31 -6 604 -6 1272 0 1142 1 |
||||
|
1219 18 1280 46 165 149 295 296 373 25 13 57 27 70 32 13 4 26 8 29 9 5 3 79 |
||||
|
19 95 21 6 1 616 3 1356 3 l1345 2 -2 193 c-2 165 -5 202 -24 262 -65 210 |
||||
|
-239 367 -452 411 -30 7 -4199 13 -4243 6z"/> |
||||
|
<path d="M3500 3500 l0 -1167 1453 2 c798 1 1455 3 1458 3 4 2 5 2305 1 2323 |
||||
|
-1 3 -657 5 -1457 5 l-1455 0 0 -1166z m1290 418 c187 -59 311 -225 311 -418 |
||||
|
-1 -387 -463 -579 -740 -308 -90 88 -132 199 -126 328 5 121 41 203 124 286 |
||||
|
111 111 288 157 431 112z"/> |
||||
|
</g> |
||||
|
</svg> |
||||
@ -0,0 +1,9 @@ |
|||||
|
<?xml version="1.0"?> |
||||
|
|
||||
|
<configuration> |
||||
|
<system.webServer> |
||||
|
<staticContent> |
||||
|
<mimeMap fileExtension=".json" mimeType="application/json" /> |
||||
|
</staticContent> |
||||
|
</system.webServer> |
||||
|
</configuration> |
||||
@ -0,0 +1,50 @@ |
|||||
|
.app-fab--absolute { |
||||
|
position: fixed; |
||||
|
bottom: 1rem; |
||||
|
right: 1rem; |
||||
|
} |
||||
|
|
||||
|
header.mdc-toolbar { |
||||
|
background-color: #50d29d; |
||||
|
} |
||||
|
button.mdc-toolbar__icon { |
||||
|
display: inline-block; |
||||
|
color: white; |
||||
|
} |
||||
|
.toolbar-adjusted-content { |
||||
|
margin-top: 70px; |
||||
|
} |
||||
|
.mdc-card { |
||||
|
margin: 0.5em; |
||||
|
background: white; |
||||
|
} |
||||
|
.mdc-card__title { |
||||
|
margin: 0.5em 0; |
||||
|
font-size: 200%; |
||||
|
color: #20b1d6; |
||||
|
} |
||||
|
.mdc-card__supporting-text { |
||||
|
color: #7a7a7a; |
||||
|
} |
||||
|
.mdc-card__supporting-text:last-child { |
||||
|
padding-bottom: 8px; |
||||
|
} |
||||
|
.center { |
||||
|
text-align: center; |
||||
|
} |
||||
|
button.sign-in { |
||||
|
margin: 2em auto; |
||||
|
} |
||||
|
a.material-icons { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.mdc-fab { |
||||
|
display: inline-block; |
||||
|
} |
||||
|
|
||||
|
@media (min-width: 1024px) { |
||||
|
.app-fab--absolute { |
||||
|
bottom: 3rem; |
||||
|
right: 5rem; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,375 @@ |
|||||
|
import React, { Component } from "react"; |
||||
|
import { ExpenseList, ExpenseForm, LoadingBar } from "./components/index"; |
||||
|
import { MDCSnackbar } from "@material/snackbar/dist/mdc.snackbar.js"; |
||||
|
|
||||
|
import "@material/fab/dist/mdc.fab.css"; |
||||
|
import "@material/button/dist/mdc.button.css"; |
||||
|
import "@material/toolbar/dist/mdc.toolbar.css"; |
||||
|
import "@material/snackbar/dist/mdc.snackbar.css"; |
||||
|
import "@material/card/dist/mdc.card.css"; |
||||
|
|
||||
|
import "./App.css"; |
||||
|
|
||||
|
class App extends Component { |
||||
|
constructor() { |
||||
|
super(); |
||||
|
|
||||
|
this.clientId = |
||||
|
"199291003615-nmv1fbsh6cqt1dt7m92a1r3tb6ljf5rf.apps.googleusercontent.com"; |
||||
|
this.spreadsheetId = |
||||
|
process.env.REACT_APP_SHEET_ID || |
||||
|
"1eYrQf0xhs2mTSWEzQRfSM-MD-tCcx1r0NVEacLg3Jrc"; |
||||
|
|
||||
|
this.state = { |
||||
|
signedIn: undefined, |
||||
|
accounts: [], |
||||
|
categories: [], |
||||
|
expenses: [], |
||||
|
processing: true, |
||||
|
expense: {}, |
||||
|
currentMonth: undefined, |
||||
|
previousMonth: undefined, |
||||
|
showExpenseForm: false |
||||
|
}; |
||||
|
|
||||
|
} |
||||
|
|
||||
|
componentDidMount() { |
||||
|
window.gapi.load("client:auth2", () => { |
||||
|
window.gapi.client |
||||
|
.init({ |
||||
|
discoveryDocs: [ |
||||
|
"https://sheets.googleapis.com/$discovery/rest?version=v4" |
||||
|
], |
||||
|
clientId: this.clientId, |
||||
|
scope: |
||||
|
"https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.metadata.readonly" |
||||
|
}) |
||||
|
.then(() => { |
||||
|
window.gapi.auth2 |
||||
|
.getAuthInstance() |
||||
|
.isSignedIn.listen(this.signedInChanged); |
||||
|
this.signedInChanged( |
||||
|
window.gapi.auth2.getAuthInstance().isSignedIn.get() |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
document.addEventListener("keyup", this.onKeyPressed.bind(this)); |
||||
|
} |
||||
|
|
||||
|
onKeyPressed = (e) => { |
||||
|
if (this.state.signedIn === true) { |
||||
|
if (this.state.showExpenseForm === false) { |
||||
|
if (e.keyCode === 65) { // a
|
||||
|
this.onExpenseNew() |
||||
|
} |
||||
|
} else { |
||||
|
if (e.keyCode === 27) { // escape
|
||||
|
this.handleExpenseCancel() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
signedInChanged = (signedIn) => { |
||||
|
this.setState({ signedIn: signedIn }); |
||||
|
if (this.state.signedIn) { |
||||
|
this.load(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
handleExpenseSubmit = () => { |
||||
|
this.setState({ processing: true, showExpenseForm: false }); |
||||
|
const submitAction = (this.state.expense.id |
||||
|
? this.update |
||||
|
: this.append).bind(this); |
||||
|
submitAction(this.state.expense).then( |
||||
|
response => { |
||||
|
this.snackbar.show({ |
||||
|
message: `Expense ${this.state.expense.id ? "updated" : "added"}!` |
||||
|
}); |
||||
|
this.load(); |
||||
|
}, |
||||
|
response => { |
||||
|
console.error("Something went wrong"); |
||||
|
console.error(response); |
||||
|
this.setState({ loading: false }); |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
handleExpenseChange = (attribute, value) => { |
||||
|
this.setState({ |
||||
|
expense: Object.assign({}, this.state.expense, { [attribute]: value }) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
handleExpenseDelete = (expense) => { |
||||
|
this.setState({ processing: true, showExpenseForm: false }); |
||||
|
const expenseRow = expense.id.substring(10); |
||||
|
window.gapi.client.sheets.spreadsheets |
||||
|
.batchUpdate({ |
||||
|
spreadsheetId: this.spreadsheetId, |
||||
|
resource: { |
||||
|
requests: [ |
||||
|
{ |
||||
|
deleteDimension: { |
||||
|
range: { |
||||
|
sheetId: 0, |
||||
|
dimension: "ROWS", |
||||
|
startIndex: expenseRow - 1, |
||||
|
endIndex: expenseRow |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
}) |
||||
|
.then( |
||||
|
response => { |
||||
|
this.snackbar.show({ message: "Expense deleted!" }); |
||||
|
this.load(); |
||||
|
}, |
||||
|
response => { |
||||
|
console.error("Something went wrong"); |
||||
|
console.error(response); |
||||
|
this.setState({ loading: false }); |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
handleExpenseSelect = (expense) => { |
||||
|
this.setState({ expense: expense, showExpenseForm: true }); |
||||
|
} |
||||
|
|
||||
|
handleExpenseCancel = () => { |
||||
|
this.setState({ showExpenseForm: false }); |
||||
|
} |
||||
|
|
||||
|
onExpenseNew() { |
||||
|
const now = new Date(); |
||||
|
this.setState({ |
||||
|
showExpenseForm: true, |
||||
|
expense: { |
||||
|
amount: "", |
||||
|
description: "", |
||||
|
date: `${now.getFullYear()}-${now.getMonth() < 9 |
||||
|
? "0" + (now.getMonth() + 1) |
||||
|
: now.getMonth() + 1}-${now.getDate() < 10 |
||||
|
? "0" + now.getDate() |
||||
|
: now.getDate()}`,
|
||||
|
category: this.state.categories[0], |
||||
|
account: this.state.accounts[0] |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
parseExpense(value, index) { |
||||
|
return { |
||||
|
id: `Expenses!A${index + 2}`, |
||||
|
date: value[0], |
||||
|
description: value[1], |
||||
|
category: value[3], |
||||
|
amount: value[4].replace(",", ""), |
||||
|
account: value[2] |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
formatExpense(expense) { |
||||
|
return [ |
||||
|
`=DATE(${expense.date.substr(0, 4)}, ${expense.date.substr( |
||||
|
5, |
||||
|
2 |
||||
|
)}, ${expense.date.substr(-2)})`,
|
||||
|
expense.description, |
||||
|
expense.account, |
||||
|
expense.category, |
||||
|
expense.amount |
||||
|
]; |
||||
|
} |
||||
|
|
||||
|
append(expense) { |
||||
|
return window.gapi.client.sheets.spreadsheets.values.append({ |
||||
|
spreadsheetId: this.spreadsheetId, |
||||
|
range: "Expenses!A1", |
||||
|
valueInputOption: "USER_ENTERED", |
||||
|
insertDataOption: "INSERT_ROWS", |
||||
|
values: [this.formatExpense(expense)] |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
update(expense) { |
||||
|
return window.gapi.client.sheets.spreadsheets.values.update({ |
||||
|
spreadsheetId: this.spreadsheetId, |
||||
|
range: expense.id, |
||||
|
valueInputOption: "USER_ENTERED", |
||||
|
values: [this.formatExpense(expense)] |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
load() { |
||||
|
window.gapi.client.sheets.spreadsheets.values |
||||
|
.batchGet({ |
||||
|
spreadsheetId: this.spreadsheetId, |
||||
|
ranges: [ |
||||
|
"Data!A2:A50", |
||||
|
"Data!E2:E50", |
||||
|
"Expenses!A2:F", |
||||
|
"Current!H1", |
||||
|
"Previous!H1" |
||||
|
] |
||||
|
}) |
||||
|
.then(response => { |
||||
|
const accounts = response.result.valueRanges[0].values.map( |
||||
|
items => items[0] |
||||
|
); |
||||
|
const categories = response.result.valueRanges[1].values.map( |
||||
|
items => items[0] |
||||
|
); |
||||
|
this.setState({ |
||||
|
accounts: accounts, |
||||
|
categories: categories, |
||||
|
expenses: (response.result.valueRanges[2].values || []) |
||||
|
.map(this.parseExpense) |
||||
|
.reverse() |
||||
|
.slice(0, 30), |
||||
|
processing: false, |
||||
|
currentMonth: response.result.valueRanges[3].values[0][0], |
||||
|
previousMonth: response.result.valueRanges[4].values[0][0] |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
render() { |
||||
|
return ( |
||||
|
<div> |
||||
|
<header className="mdc-toolbar mdc-toolbar--fixed"> |
||||
|
<div className="mdc-toolbar__row"> |
||||
|
<section className="mdc-toolbar__section mdc-toolbar__section--align-start"> |
||||
|
<span className="mdc-toolbar__title">Expenses</span> |
||||
|
</section> |
||||
|
<section |
||||
|
className="mdc-toolbar__section mdc-toolbar__section--align-end" |
||||
|
role="toolbar" |
||||
|
> |
||||
|
{this.state.signedIn === false && |
||||
|
<a |
||||
|
className="material-icons mdc-toolbar__icon" |
||||
|
aria-label="Sign in" |
||||
|
alt="Sign in" |
||||
|
onClick={e => { |
||||
|
e.preventDefault(); |
||||
|
window.gapi.auth2.getAuthInstance().signIn(); |
||||
|
}} |
||||
|
> |
||||
|
perm_identity |
||||
|
</a>} |
||||
|
{this.state.signedIn && |
||||
|
<a |
||||
|
className="material-icons mdc-toolbar__icon" |
||||
|
aria-label="Sign out" |
||||
|
alt="Sign out" |
||||
|
onClick={e => { |
||||
|
e.preventDefault(); |
||||
|
window.gapi.auth2.getAuthInstance().signOut(); |
||||
|
}} |
||||
|
> |
||||
|
exit_to_app |
||||
|
</a>} |
||||
|
</section> |
||||
|
</div> |
||||
|
</header> |
||||
|
<div className="toolbar-adjusted-content"> |
||||
|
{this.state.signedIn === undefined && <LoadingBar />} |
||||
|
{this.state.signedIn === false && |
||||
|
<div className="center"> |
||||
|
<button |
||||
|
className="mdc-button sign-in" |
||||
|
aria-label="Sign in" |
||||
|
onClick={() => { |
||||
|
window.gapi.auth2.getAuthInstance().signIn(); |
||||
|
}} |
||||
|
> |
||||
|
Sign In |
||||
|
</button> |
||||
|
</div>} |
||||
|
{this.state.signedIn && this.renderBody()} |
||||
|
</div> |
||||
|
<div |
||||
|
ref={el => { |
||||
|
if (el) { |
||||
|
this.snackbar = new MDCSnackbar(el); |
||||
|
} |
||||
|
}} |
||||
|
className="mdc-snackbar" |
||||
|
aria-live="assertive" |
||||
|
aria-atomic="true" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<div className="mdc-snackbar__text" /> |
||||
|
<div className="mdc-snackbar__action-wrapper"> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="mdc-button mdc-snackbar__action-button" |
||||
|
aria-hidden="true" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
renderBody() { |
||||
|
if (this.state.processing) return <LoadingBar />; |
||||
|
else |
||||
|
return ( |
||||
|
<div className="content"> |
||||
|
{this.renderExpenses()} |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
renderExpenses() { |
||||
|
if (this.state.showExpenseForm) |
||||
|
return ( |
||||
|
<ExpenseForm |
||||
|
categories={this.state.categories} |
||||
|
accounts={this.state.accounts} |
||||
|
expense={this.state.expense} |
||||
|
onSubmit={this.handleExpenseSubmit} |
||||
|
onCancel={this.handleExpenseCancel} |
||||
|
onDelete={this.handleExpenseDelete} |
||||
|
onChange={this.handleExpenseChange} |
||||
|
/> |
||||
|
); |
||||
|
else |
||||
|
return ( |
||||
|
<div> |
||||
|
<div className="mdc-card"> |
||||
|
<section className="mdc-card__primary"> |
||||
|
<h2 className="mdc-card__subtitle">This month you've spent:</h2> |
||||
|
<h1 className="mdc-card__title mdc-card__title--large center"> |
||||
|
{this.state.currentMonth} |
||||
|
</h1> |
||||
|
</section> |
||||
|
<section className="mdc-card__supporting-text"> |
||||
|
Previous month: {this.state.previousMonth} |
||||
|
</section> |
||||
|
</div> |
||||
|
<ExpenseList |
||||
|
expenses={this.state.expenses} |
||||
|
onSelect={this.handleExpenseSelect} |
||||
|
/> |
||||
|
<button |
||||
|
onClick={() => this.onExpenseNew()} |
||||
|
className="mdc-fab app-fab--absolute material-icons" |
||||
|
aria-label="Add expense" |
||||
|
> |
||||
|
<span className="mdc-fab__icon">add</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default App; |
||||
@ -0,0 +1,8 @@ |
|||||
|
import React from "react"; |
||||
|
import ReactDOM from "react-dom"; |
||||
|
import App from "./App"; |
||||
|
|
||||
|
it("renders without crashing", () => { |
||||
|
const div = document.createElement("div"); |
||||
|
ReactDOM.render(<App />, div); |
||||
|
}); |
||||
@ -0,0 +1,130 @@ |
|||||
|
import React, { Component } from "react"; |
||||
|
|
||||
|
class LoadingBar extends Component { |
||||
|
render() { |
||||
|
return ( |
||||
|
<div className="center"> |
||||
|
<svg |
||||
|
width="80px" |
||||
|
height="80px" |
||||
|
xmlns="http://www.w3.org/2000/svg" |
||||
|
viewBox="0 0 100 100" |
||||
|
preserveAspectRatio="xMidYMid" |
||||
|
> |
||||
|
<circle cx="84" cy="50" r="0" fill="#c0f6d2"> |
||||
|
<animate |
||||
|
attributeName="r" |
||||
|
values="11;0;0;0;0" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="0s" |
||||
|
/> |
||||
|
<animate |
||||
|
attributeName="cx" |
||||
|
values="84;84;84;84;84" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="0s" |
||||
|
/> |
||||
|
</circle> |
||||
|
<circle cx="40.0957" cy="50" r="11" fill="#ff7c81"> |
||||
|
<animate |
||||
|
attributeName="r" |
||||
|
values="0;11;11;11;0" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="-0.5s" |
||||
|
/> |
||||
|
<animate |
||||
|
attributeName="cx" |
||||
|
values="16;16;50;84;84" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="-0.5s" |
||||
|
/> |
||||
|
</circle> |
||||
|
<circle cx="16" cy="50" r="7.79567" fill="#fac090"> |
||||
|
<animate |
||||
|
attributeName="r" |
||||
|
values="0;11;11;11;0" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="-0.25s" |
||||
|
/> |
||||
|
<animate |
||||
|
attributeName="cx" |
||||
|
values="16;16;50;84;84" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="-0.25s" |
||||
|
/> |
||||
|
</circle> |
||||
|
<circle cx="84" cy="50" r="3.20433" fill="#ffffcb"> |
||||
|
<animate |
||||
|
attributeName="r" |
||||
|
values="0;11;11;11;0" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="0s" |
||||
|
/> |
||||
|
<animate |
||||
|
attributeName="cx" |
||||
|
values="16;16;50;84;84" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="0s" |
||||
|
/> |
||||
|
</circle> |
||||
|
<circle cx="74.0957" cy="50" r="11" fill="#c0f6d2"> |
||||
|
<animate |
||||
|
attributeName="r" |
||||
|
values="0;0;11;11;11" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="0s" |
||||
|
/> |
||||
|
<animate |
||||
|
attributeName="cx" |
||||
|
values="16;16;16;50;84" |
||||
|
keyTimes="0;0.25;0.5;0.75;1" |
||||
|
keySplines="0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1;0 0.5 0.5 1" |
||||
|
calcMode="spline" |
||||
|
dur="1s" |
||||
|
repeatCount="indefinite" |
||||
|
begin="0s" |
||||
|
/> |
||||
|
</circle> |
||||
|
</svg> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default LoadingBar; |
||||
@ -0,0 +1,18 @@ |
|||||
|
form { |
||||
|
padding: 0 2em; |
||||
|
max-width: 400px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
input, select { |
||||
|
min-width: 200px; |
||||
|
} |
||||
|
.mdc-form-field { |
||||
|
display: block; |
||||
|
} |
||||
|
.mdc-select, .mdc-form-submit { |
||||
|
margin-top: 24px; |
||||
|
} |
||||
|
.mdc-form-submit { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
@ -0,0 +1,191 @@ |
|||||
|
import React, { Component } from "react"; |
||||
|
import { MDCTextfield } from "@material/textfield/dist/mdc.textfield.js"; |
||||
|
import { MDCDialog } from "@material/dialog/dist/mdc.dialog.js"; |
||||
|
|
||||
|
import "@material/form-field/dist/mdc.form-field.css"; |
||||
|
import "@material/select/dist/mdc.select.css"; |
||||
|
import "@material/textfield/dist/mdc.textfield.css"; |
||||
|
import "@material/button/dist/mdc.button.css"; |
||||
|
import "@material/dialog/dist/mdc.dialog.css"; |
||||
|
|
||||
|
import "./ExpenseForm.css"; |
||||
|
|
||||
|
class ExpenseForm extends Component { |
||||
|
constructor(props) { |
||||
|
super(props); |
||||
|
|
||||
|
this.state = { isValid: false }; |
||||
|
} |
||||
|
|
||||
|
handleInputChange = (event) => { |
||||
|
const target = event.target; |
||||
|
|
||||
|
target.reportValidity(); |
||||
|
this.setState({ isValid: this.form.checkValidity() }); |
||||
|
this.props.onChange(target.name, target.value); |
||||
|
} |
||||
|
|
||||
|
componentDidMount() { |
||||
|
document.querySelectorAll(".mdc-textfield").forEach(selector => { |
||||
|
new MDCTextfield(selector); |
||||
|
}); |
||||
|
if (this.props.expense.id === undefined) { |
||||
|
this.amountInput.focus(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
handleSubmit = (event) => { |
||||
|
event.preventDefault(); |
||||
|
this.props.onSubmit(); |
||||
|
} |
||||
|
|
||||
|
initializeDeleteModal = (element) => { |
||||
|
if (element) { |
||||
|
this.dialog = new MDCDialog(element); |
||||
|
this.dialog.listen("MDCDialog:accept", () => { |
||||
|
// a fix for not closing the modal dialog properly
|
||||
|
document.body.className = document.body.className.replace( |
||||
|
"mdc-dialog-scroll-lock", |
||||
|
"" |
||||
|
); |
||||
|
this.props.onDelete(this.props.expense); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
render() { |
||||
|
return ( |
||||
|
<form |
||||
|
onSubmit={this.handleSubmit} |
||||
|
ref={form => { |
||||
|
this.form = form; |
||||
|
}} |
||||
|
noValidate |
||||
|
> |
||||
|
<aside className="mdc-dialog" ref={this.initializeDeleteModal}> |
||||
|
<div className="mdc-dialog__surface"> |
||||
|
<header className="mdc-dialog__header"> |
||||
|
<h2 className="mdc-dialog__header__title"> |
||||
|
Are you sure? |
||||
|
</h2> |
||||
|
</header> |
||||
|
<section className="mdc-dialog__body"> |
||||
|
Do you really want to delete the expense? |
||||
|
</section> |
||||
|
<footer className="mdc-dialog__footer"> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="mdc-button mdc-dialog__footer__button mdc-dialog__footer__button--cancel" |
||||
|
> |
||||
|
Cancel |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="mdc-button mdc-dialog__footer__button mdc-dialog__footer__button--accept" |
||||
|
> |
||||
|
Delete |
||||
|
</button> |
||||
|
</footer> |
||||
|
</div> |
||||
|
</aside> |
||||
|
<div className="mdc-form-field"> |
||||
|
<div className="mdc-textfield"> |
||||
|
<input |
||||
|
name="amount" |
||||
|
className="mdc-textfield__input" |
||||
|
ref={el => { |
||||
|
this.amountInput = el; |
||||
|
}} |
||||
|
value={this.props.expense.amount} |
||||
|
onChange={this.handleInputChange} |
||||
|
type="number" |
||||
|
step="0.01" |
||||
|
min="0" |
||||
|
required |
||||
|
/> |
||||
|
<label className="mdc-textfield__label">Amount</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="mdc-form-field"> |
||||
|
<select |
||||
|
name="category" |
||||
|
className="mdc-select" |
||||
|
value={this.props.expense.category} |
||||
|
onChange={this.handleInputChange} |
||||
|
required |
||||
|
> |
||||
|
{this.props.categories.map(category => |
||||
|
<option value={category} key={category}>{category}</option> |
||||
|
)} |
||||
|
</select> |
||||
|
</div> |
||||
|
|
||||
|
<div className="mdc-form-field"> |
||||
|
<div className="mdc-textfield"> |
||||
|
<input |
||||
|
name="description" |
||||
|
className="mdc-textfield__input" |
||||
|
value={this.props.expense.description} |
||||
|
onChange={this.handleInputChange} |
||||
|
type="text" |
||||
|
/> |
||||
|
<label className="mdc-textfield__label">Description</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="mdc-form-field"> |
||||
|
<div className="mdc-textfield"> |
||||
|
<input |
||||
|
name="date" |
||||
|
className="mdc-textfield__input" |
||||
|
value={this.props.expense.date} |
||||
|
onChange={this.handleInputChange} |
||||
|
type="date" |
||||
|
required |
||||
|
/> |
||||
|
<label className="mdc-textfield__label">Date</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="mdc-form-field"> |
||||
|
<select |
||||
|
name="account" |
||||
|
className="mdc-select" |
||||
|
value={this.props.expense.account} |
||||
|
onChange={this.handleInputChange} |
||||
|
required |
||||
|
> |
||||
|
{this.props.accounts.map(account => |
||||
|
<option value={account} key={account}>{account}</option> |
||||
|
)} |
||||
|
</select> |
||||
|
</div> |
||||
|
|
||||
|
<div className="mdc-form-field mdc-form-submit"> |
||||
|
<input |
||||
|
type="submit" |
||||
|
className="mdc-button" |
||||
|
value={this.props.expense.id ? "Update" : "Add"} |
||||
|
disabled={!this.state.isValid} |
||||
|
/> |
||||
|
{this.props.expense.id && |
||||
|
<input |
||||
|
type="button" |
||||
|
className="mdc-button" |
||||
|
onClick={() => this.dialog.show()} |
||||
|
value="Delete" |
||||
|
/>} |
||||
|
<input |
||||
|
type="button" |
||||
|
className="mdc-button" |
||||
|
onClick={() => this.props.onCancel()} |
||||
|
value="Close" |
||||
|
/> |
||||
|
</div> |
||||
|
</form> |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default ExpenseForm; |
||||
@ -0,0 +1,32 @@ |
|||||
|
import React, { Component } from "react"; |
||||
|
import ExpenseIcon from "./ExpenseIcon"; |
||||
|
|
||||
|
export default class ExpenseDetail extends Component { |
||||
|
formatDate(date) { |
||||
|
const dateParts = date.split("-"); |
||||
|
return `${dateParts[2]}/${dateParts[1]}/${dateParts[0]}`; |
||||
|
} |
||||
|
|
||||
|
render() { |
||||
|
return ( |
||||
|
<li |
||||
|
className="mdc-list-item" |
||||
|
onClick={() => this.props.onSelect(this.props.expense)} |
||||
|
> |
||||
|
<ExpenseIcon category={this.props.expense.category} /> |
||||
|
<span className="mdc-list-item__text"> |
||||
|
{this.props.expense.category} |
||||
|
<span className="mdc-list-item__text__secondary"> |
||||
|
{this.formatDate(this.props.expense.date)} |
||||
|
{this.props.expense.description |
||||
|
? ` ${this.props.expense.description.replace(/^(.{14}).+/, "$1…")}` |
||||
|
: ""} |
||||
|
</span> |
||||
|
</span> |
||||
|
<span className="mdc-list-item__end-detail"> |
||||
|
${this.props.expense.amount} |
||||
|
</span> |
||||
|
</li> |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
import React, { Component } from 'react'; |
||||
|
|
||||
|
export default class ExpenseIcon extends Component { |
||||
|
iconFrom(category) { |
||||
|
switch (category) { |
||||
|
case "Lebensmittel": |
||||
|
return "local_grocery_store"; |
||||
|
case "Restaurant": |
||||
|
return "local_restaurant"; |
||||
|
case "Auto": |
||||
|
return "directions_car"; |
||||
|
case "Tanken": |
||||
|
return "local_gas_station"; |
||||
|
case "Hobbies": |
||||
|
return "pedal_bike"; |
||||
|
case "Kleidung": |
||||
|
return "checkroom"; |
||||
|
case "Shopping": |
||||
|
return "shopping_cart"; |
||||
|
case "Gesundheit": |
||||
|
return "local_hospital"; |
||||
|
case "Drogerie": |
||||
|
return "clean_hands"; |
||||
|
case "Unterhaltung": |
||||
|
return "subscriptions"; |
||||
|
case "Transport": |
||||
|
return "directions_subway"; |
||||
|
case "Übernachtung": |
||||
|
return "local_hotel"; |
||||
|
case "Yvonne": |
||||
|
return "face"; |
||||
|
case "Haustiere": |
||||
|
return "pets"; |
||||
|
case "Fixkosten": |
||||
|
return "payments"; |
||||
|
case "Sonstiges": |
||||
|
return "compost"; |
||||
|
default: |
||||
|
return "attach_money"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
render() { |
||||
|
return ( |
||||
|
<span |
||||
|
className={`mdc-list-item__start-detail ${this.props.category}`} |
||||
|
role="presentation" |
||||
|
> |
||||
|
<i className="material-icons" aria-hidden="true"> |
||||
|
{this.iconFrom(this.props.category)} |
||||
|
</i> |
||||
|
</span> |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
.mdc-list-item { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.mdc-list-item__start-detail { |
||||
|
background: rgba(0, 0, 0, .26); |
||||
|
color: white; |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
.mdc-list-item__end-detail { |
||||
|
width: auto; |
||||
|
} |
||||
|
|
||||
|
.Lebensmittel { |
||||
|
background: rgb(85, 178, 35); |
||||
|
} |
||||
|
.Restaurant { |
||||
|
background: rgb(242, 60, 0); |
||||
|
} |
||||
|
.Auto, .Transport, .Tanken { |
||||
|
background: rgb(166, 2, 221); |
||||
|
} |
||||
|
.Hobbies { |
||||
|
background: rgb(38, 94, 4); |
||||
|
} |
||||
|
.Kleidung, .Shopping, Drogerie { |
||||
|
background: rgb(0, 183, 224); |
||||
|
} |
||||
|
.Übernachtung { |
||||
|
background: rgb(214, 214, 0); |
||||
|
} |
||||
|
.Gesundheit { |
||||
|
background: rgb(252, 169, 5); |
||||
|
} |
||||
|
.Unterhaltung { |
||||
|
background: rgb(245, 151, 29); |
||||
|
} |
||||
|
.Yvonne, .Haustiere { |
||||
|
background: rgb(252, 194, 246) |
||||
|
} |
||||
|
.Fixkosten, .Sonstiges { |
||||
|
background: rgb(146, 148, 142) |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
import React, { Component } from "react"; |
||||
|
import ExpenseDetail from "./ExpenseDetail.js" |
||||
|
import "@material/list/dist/mdc.list.css"; |
||||
|
import "./ExpenseList.css"; |
||||
|
|
||||
|
class ExpenseList extends Component { |
||||
|
render() { |
||||
|
return ( |
||||
|
<ul className="mdc-list mdc-list--two-line mdc-list--avatar-list"> |
||||
|
{this.props.expenses.map(expense => |
||||
|
<ExpenseDetail |
||||
|
key={expense.id} |
||||
|
expense={expense} |
||||
|
onSelect={this.props.onSelect} |
||||
|
/> |
||||
|
)} |
||||
|
</ul> |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default ExpenseList; |
||||
@ -0,0 +1,9 @@ |
|||||
|
import ExpenseList from "./expense-list/ExpenseList"; |
||||
|
import ExpenseForm from "./expense-form/ExpenseForm"; |
||||
|
import LoadingBar from "./LoadingBar"; |
||||
|
|
||||
|
export { |
||||
|
ExpenseList, |
||||
|
ExpenseForm, |
||||
|
LoadingBar |
||||
|
}; |
||||
@ -0,0 +1,6 @@ |
|||||
|
body { |
||||
|
margin: 0; |
||||
|
padding: 0; |
||||
|
font-family: sans-serif; |
||||
|
background: #fafafa; |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
import React from "react"; |
||||
|
import ReactDOM from "react-dom"; |
||||
|
|
||||
|
import App from "./App"; |
||||
|
import registerServiceWorker from "./registerServiceWorker"; |
||||
|
import "material-icons/css/material-icons.css"; |
||||
|
import "./index.css"; |
||||
|
|
||||
|
ReactDOM.render(<App />, document.getElementById("root")); |
||||
|
registerServiceWorker(); |
||||
@ -0,0 +1,51 @@ |
|||||
|
// In production, we register a service worker to serve assets from local cache.
|
||||
|
|
||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||
|
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||
|
// cached resources are updated in the background.
|
||||
|
|
||||
|
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
|
// This link also includes instructions on opting out of this behavior.
|
||||
|
|
||||
|
export default function register() { |
||||
|
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { |
||||
|
window.addEventListener("load", () => { |
||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; |
||||
|
navigator.serviceWorker |
||||
|
.register(swUrl) |
||||
|
.then(registration => { |
||||
|
registration.onupdatefound = () => { |
||||
|
const installingWorker = registration.installing; |
||||
|
installingWorker.onstatechange = () => { |
||||
|
if (installingWorker.state === "installed") { |
||||
|
if (navigator.serviceWorker.controller) { |
||||
|
// At this point, the old content will have been purged and
|
||||
|
// the fresh content will have been added to the cache.
|
||||
|
// It's the perfect time to display a "New content is
|
||||
|
// available; please refresh." message in your web app.
|
||||
|
console.log("New content is available; please refresh."); |
||||
|
} else { |
||||
|
// At this point, everything has been precached.
|
||||
|
// It's the perfect time to display a
|
||||
|
// "Content is cached for offline use." message.
|
||||
|
console.log("Content is cached for offline use."); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
}; |
||||
|
}) |
||||
|
.catch(error => { |
||||
|
console.error("Error during service worker registration:", error); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function unregister() { |
||||
|
if ("serviceWorker" in navigator) { |
||||
|
navigator.serviceWorker.ready.then(registration => { |
||||
|
registration.unregister(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||