Browse Source

initial commit

master
Bernd-René Predota 4 years ago
commit
76c4fdb6f7
  1. 1
      .env
  2. 26
      .eslintrc.json
  3. 23
      .gitignore
  4. 21
      LICENSE
  5. 64
      README.md
  6. 30
      deploy.sh
  7. BIN
      doc/add-expense.gif
  8. BIN
      doc/delete-expense.gif
  9. BIN
      doc/edit-expense.gif
  10. BIN
      doc/expenses.png
  11. BIN
      doc/zapier.png
  12. 39293
      package-lock.json
  13. 32
      package.json
  14. BIN
      public/android-chrome-192x192.png
  15. BIN
      public/android-chrome-512x512.png
  16. BIN
      public/apple-touch-icon.png
  17. 9
      public/browserconfig.xml
  18. BIN
      public/favicon-16x16.png
  19. BIN
      public/favicon-32x32.png
  20. BIN
      public/favicon.ico
  21. 29
      public/index.html
  22. 20
      public/manifest.json
  23. BIN
      public/mstile-144x144.png
  24. BIN
      public/mstile-150x150.png
  25. BIN
      public/mstile-310x150.png
  26. BIN
      public/mstile-310x310.png
  27. BIN
      public/mstile-70x70.png
  28. 24
      public/safari-pinned-tab.svg
  29. 9
      public/web.config
  30. 50
      src/App.css
  31. 375
      src/App.js
  32. 8
      src/App.test.js
  33. 130
      src/components/LoadingBar.js
  34. 18
      src/components/expense-form/ExpenseForm.css
  35. 191
      src/components/expense-form/ExpenseForm.js
  36. BIN
      src/components/expense-list/.ExpenseList.css.swp
  37. 32
      src/components/expense-list/ExpenseDetail.js
  38. 55
      src/components/expense-list/ExpenseIcon.js
  39. 44
      src/components/expense-list/ExpenseList.css
  40. 22
      src/components/expense-list/ExpenseList.js
  41. 9
      src/components/index.js
  42. 6
      src/index.css
  43. 10
      src/index.js
  44. 51
      src/registerServiceWorker.js
  45. 11627
      yarn.lock

1
.env

@ -0,0 +1 @@
PORT=8080

26
.eslintrc.json

@ -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"
]
}

23
.gitignore

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

21
LICENSE

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

64
README.md

@ -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.
![Delete expense](doc/delete-expense.gif)
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.
![Zapier setup for recurring expenses](doc/zapier.png)
### 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 ..

30
deploy.sh

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

BIN
doc/add-expense.gif

After

Width: 409  |  Height: 726  |  Size: 161 KiB

BIN
doc/delete-expense.gif

After

Width: 409  |  Height: 726  |  Size: 113 KiB

BIN
doc/edit-expense.gif

After

Width: 409  |  Height: 726  |  Size: 106 KiB

BIN
doc/expenses.png

After

Width: 826  |  Height: 1468  |  Size: 153 KiB

BIN
doc/zapier.png

After

Width: 2600  |  Height: 1766  |  Size: 379 KiB

39293
package-lock.json
File diff suppressed because it is too large
View File

32
package.json

@ -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"
]
}
}

BIN
public/android-chrome-192x192.png

After

Width: 192  |  Height: 192  |  Size: 1.0 KiB

BIN
public/android-chrome-512x512.png

After

Width: 512  |  Height: 512  |  Size: 3.6 KiB

BIN
public/apple-touch-icon.png

After

Width: 180  |  Height: 180  |  Size: 1.1 KiB

9
public/browserconfig.xml

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

BIN
public/favicon-16x16.png

After

Width: 16  |  Height: 16  |  Size: 530 B

BIN
public/favicon-32x32.png

After

Width: 32  |  Height: 32  |  Size: 617 B

BIN
public/favicon.ico

29
public/index.html

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

20
public/manifest.json

@ -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": "."
}

BIN
public/mstile-144x144.png

After

Width: 144  |  Height: 144  |  Size: 1.2 KiB

BIN
public/mstile-150x150.png

After

Width: 270  |  Height: 270  |  Size: 1.5 KiB

BIN
public/mstile-310x150.png

After

Width: 558  |  Height: 270  |  Size: 1.6 KiB

BIN
public/mstile-310x310.png

After

Width: 558  |  Height: 558  |  Size: 2.9 KiB

BIN
public/mstile-70x70.png

After

Width: 128  |  Height: 128  |  Size: 1.0 KiB

24
public/safari-pinned-tab.svg

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

9
public/web.config

@ -0,0 +1,9 @@
<?xml version="1.0"?>
<configuration>
<system.webServer>
<staticContent>
<mimeMap fileExtension=".json" mimeType="application/json" />
</staticContent>
</system.webServer>
</configuration>

50
src/App.css

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

375
src/App.js

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

8
src/App.test.js

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

130
src/components/LoadingBar.js

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

18
src/components/expense-form/ExpenseForm.css

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

191
src/components/expense-form/ExpenseForm.js

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

BIN
src/components/expense-list/.ExpenseList.css.swp

32
src/components/expense-list/ExpenseDetail.js

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

55
src/components/expense-list/ExpenseIcon.js

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

44
src/components/expense-list/ExpenseList.css

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

22
src/components/expense-list/ExpenseList.js

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

9
src/components/index.js

@ -0,0 +1,9 @@
import ExpenseList from "./expense-list/ExpenseList";
import ExpenseForm from "./expense-form/ExpenseForm";
import LoadingBar from "./LoadingBar";
export {
ExpenseList,
ExpenseForm,
LoadingBar
};

6
src/index.css

@ -0,0 +1,6 @@
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background: #fafafa;
}

10
src/index.js

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

51
src/registerServiceWorker.js

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

11627
yarn.lock
File diff suppressed because it is too large
View File

Loading…
Cancel
Save