├── .nvmrc ├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── redux │ ├── reducers.js │ ├── sagas.js │ └── invoice │ │ ├── actions.js │ │ ├── reducer.js │ │ └── sagas.js ├── decorators │ ├── actionTypes.js │ ├── connectWithRedux.js │ └── connectWithStyles.js ├── index.css ├── .babelrc.json ├── index.js ├── store.js ├── shared-styles │ └── index.js ├── components │ ├── Invoice │ │ ├── InvoiceMeta.js │ │ ├── InvoiceEntries.js │ │ ├── InvoiceSubject.js │ │ ├── index.js │ │ └── Invoice.css │ ├── App │ │ ├── index.js │ │ └── App.css │ ├── PrefillCustomer │ │ └── index.js │ ├── InvoiceParty │ │ └── index.js │ ├── StoredInvoicesList │ │ └── index.js │ ├── InvoiceEntries │ │ └── index.js │ ├── InvoiceMeta │ │ └── index.js │ └── InvoiceForm │ │ └── index.js ├── translations │ ├── index.js │ ├── en-UK.js │ └── lt-LT.js ├── invoiceConfig.js ├── utils │ └── invoice.js └── registerServiceWorker.js ├── docs ├── react-invoice-window.png └── react-invoice-print-preview.png ├── config-overrides.js ├── .gitignore ├── package.json ├── .github └── workflows │ └── delpoy-master.yml ├── .env ├── .env.production └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-geddy/react-invoice/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/redux/reducers.js: -------------------------------------------------------------------------------- 1 | import invoice from './invoice/reducer' 2 | 3 | export default { 4 | invoice 5 | } 6 | -------------------------------------------------------------------------------- /docs/react-invoice-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-geddy/react-invoice/HEAD/docs/react-invoice-window.png -------------------------------------------------------------------------------- /docs/react-invoice-print-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-geddy/react-invoice/HEAD/docs/react-invoice-print-preview.png -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const {override, addDecoratorsLegacy } = require('customize-cra'); 2 | module.exports = override(addDecoratorsLegacy()); -------------------------------------------------------------------------------- /src/redux/sagas.js: -------------------------------------------------------------------------------- 1 | import {all} from 'redux-saga/effects' 2 | 3 | import invoice from './invoice/sagas' 4 | 5 | export default function* () { 6 | yield all([ 7 | ...invoice, 8 | ]) 9 | } 10 | -------------------------------------------------------------------------------- /src/decorators/actionTypes.js: -------------------------------------------------------------------------------- 1 | export default (scope) => (types) => Object.keys(types) 2 | .reduce((result, type) => { 3 | result[type] = `${scope}/${types[type]}`; 4 | 5 | return result; 6 | }, {}); 7 | -------------------------------------------------------------------------------- /src/decorators/connectWithRedux.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux' 2 | 3 | export const connectWithRedux = (...connectArgs) => 4 | (componentArgs) => 5 | connect(...connectArgs)(componentArgs) 6 | 7 | 8 | export default connectWithRedux -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | body { 8 | font-family: 'Open Sans', sans-serif; 9 | font-weight: 400; 10 | font-size: 10px; 11 | color: #000; 12 | -webkit-font-smoothing: antialiased; 13 | } -------------------------------------------------------------------------------- /src/decorators/connectWithStyles.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux' 2 | import {styled} from '@mui/styles' 3 | 4 | export default (...connectArgs) => 5 | (...withStylesArgs) => 6 | (...componentArgs) => 7 | connect(...connectArgs)(styled(...withStylesArgs)(...componentArgs)) 8 | -------------------------------------------------------------------------------- /src/.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-transform-runtime", 8 | ["@babel/plugin-proposal-decorators", {"legacy": true}], 9 | ["@babel/plugin-proposal-class-properties"] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | .idea 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {Provider} from 'react-redux' 4 | 5 | import './index.css' 6 | import App from './components/App' 7 | import store from './store' 8 | 9 | // import registerServiceWorker from './registerServiceWorker'; 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , document.getElementById('root')); 15 | //registerServiceWorker(); 16 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import {createStore, applyMiddleware, combineReducers} from 'redux' 2 | import createSagaMiddleware from 'redux-saga' 3 | import reducers from './redux/reducers' 4 | import sagas from './redux/sagas' 5 | 6 | const sagaMiddleware = createSagaMiddleware() 7 | const store = createStore( 8 | combineReducers({ 9 | ...reducers 10 | }), 11 | applyMiddleware(sagaMiddleware) 12 | ) 13 | 14 | sagaMiddleware.run(sagas) 15 | 16 | export default store 17 | -------------------------------------------------------------------------------- /src/shared-styles/index.js: -------------------------------------------------------------------------------- 1 | export const inputStyle = { 2 | style: { 3 | marginBottom: '8px', 4 | }, 5 | inputProps: { 6 | style: { 7 | fontSize: '14px', 8 | fontWeight: 400, 9 | padding: '6px 8px 5px 8px', 10 | backgroundColor: 'rgba(255, 255, 255, 0.5)', 11 | } 12 | }, 13 | InputLabelProps: { 14 | style: { 15 | fontSize: '14px', 16 | } 17 | }, 18 | } 19 | 20 | export const subtitleStyle = { 21 | style: { 22 | fontSize: '11px', 23 | fontWeight: 'bold', 24 | marginTop: '12px', 25 | marginBottom: '8px', 26 | } 27 | } 28 | 29 | export default { 30 | inputStyle, 31 | subtitleStyle, 32 | } -------------------------------------------------------------------------------- /src/components/Invoice/InvoiceMeta.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react' 2 | import labels from './../../translations' 3 | 4 | export class InvoiceMeta extends PureComponent { 5 | render() { 6 | const {meta} = this.props 7 | 8 | return ( 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
{labels.invoiceDate}{labels.invoiceNumber}
{meta.invoiceDate}{meta.invoiceSeries}{meta.invoiceNo}
24 |
25 | ) 26 | } 27 | } 28 | 29 | export default InvoiceMeta -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | React Invoice - In-browser Invoice Management WebApp 17 | 18 | 19 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /src/translations/index.js: -------------------------------------------------------------------------------- 1 | import {labels as en} from './en-UK' 2 | import {labels as lt} from './lt-LT' 3 | 4 | const translations = { 5 | en, 6 | lt, 7 | } 8 | 9 | export const getActiveLang = () => { 10 | const urlSearchParams = new URLSearchParams(window.location.search); 11 | const {lang} = Object.fromEntries(urlSearchParams.entries()); 12 | 13 | return ['en', 'lt'].includes(String(lang).toLowerCase()) ? String(lang).toLowerCase() : 'en' 14 | } 15 | 16 | export const loadTranslations = (setToLang = activeLang) => { 17 | if (translations[setToLang]) { 18 | return translations[setToLang] 19 | } 20 | console.error(`Translations for "${setToLang}" not found. ${activeLang} will remain the active language.`) 21 | } 22 | 23 | 24 | // export const setActiveLang = (lang) => { 25 | // if (translations[lang]) { 26 | // activeLang = lang 27 | // } else { 28 | // console.error(`Translations for "${lang}" not found. "${activeLang}" will remain the active language.`) 29 | // } 30 | // 31 | // } 32 | 33 | // export const t = (key) => { 34 | // return labels[key] ? labels[key] : '•translation missing•' 35 | // } 36 | 37 | export default loadTranslations(getActiveLang()) -------------------------------------------------------------------------------- /src/components/Invoice/InvoiceEntries.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react' 2 | import labels from './../../translations' 3 | 4 | export class InvoiceEntries extends PureComponent { 5 | 6 | render() { 7 | 8 | const {entries, invoiceMeta} = this.props 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {entries?.map((entry, index) => { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | })} 33 | 34 |
{labels.date}{labels.description}{labels.qty}{labels.rate}{labels.total}
{entry.dateProvided}{entry.description}{entry.qty}{entry.qtyType}{invoiceMeta.currency}{entry.rate}{entry.qtyType && `/${entry.qtyType}`}{invoiceMeta.currency}{entry.total}
35 | ) 36 | } 37 | } 38 | 39 | export default InvoiceEntries -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-invoice", 3 | "version": "2.0.1", 4 | "author": "Gediminas Ginkevicius", 5 | "description": "Use YARN for running the commands!", 6 | "private": true, 7 | "dependencies": { 8 | "@babel/plugin-proposal-decorators": "^7.16.4", 9 | "@emotion/react": "^11.7.0", 10 | "@emotion/styled": "^11.6.0", 11 | "@mui/icons-material": "^5.2.1", 12 | "@mui/lab": "^5.0.0-alpha.59", 13 | "@mui/material": "^5.2.3", 14 | "@mui/styles": "^5.2.3", 15 | "customize-cra": "^1.0.0", 16 | "date-fns": "^2.27.0", 17 | "dayjs": "^1.10.7", 18 | "prop-types": "^15.7.2", 19 | "react": "^17.0.2", 20 | "react-app-rewired": "^2.1.8", 21 | "react-dom": "^17.0.2", 22 | "react-redux": "^7.2.6", 23 | "react-scripts": "^4.0.3", 24 | "redux": "^4.1.2", 25 | "redux-saga": "^1.1.3", 26 | "uuid": "^8.3.2" 27 | }, 28 | "scripts": { 29 | "start": "react-app-rewired start", 30 | "build": "react-app-rewired build", 31 | "test": "react-scripts test --env=jsdom", 32 | "eject": "react-scripts eject" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/delpoy-master.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Deploy to production 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [16.x] 18 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | - run: yarn install --frozen-lockfile 28 | - run: yarn build --if-present 29 | # - run: yarn test 30 | - name: Clear hosted static files before deployment 31 | uses: appleboy/ssh-action@master 32 | with: 33 | host: ${{secrets.DROPLET_HOST}} 34 | username: ${{secrets.DROPLET_USER}} 35 | key: ${{ secrets.DROPLET_PRIVATE_KEY }} 36 | script: | 37 | rm -rf /var/www/domains/invoice.reactivelabs.cloud/public_html/* 38 | 39 | - name: Deploy files to production 40 | uses: appleboy/scp-action@master 41 | with: 42 | host: ${{ secrets.DROPLET_HOST }} 43 | username: ${{ secrets.DROPLET_USER }} 44 | key: ${{ secrets.DROPLET_PRIVATE_KEY }} 45 | source: "./build/*" 46 | strip_components: 1 47 | target: "/var/www/domains/invoice.reactivelabs.cloud/public_html" 48 | overwrite: true 49 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP__LANG=en 2 | 3 | # Service provider details below 4 | 5 | REACT_APP__COMPANY_NAME=Your Company Limited 6 | REACT_APP__REPRESENTATIVE_NAME=First Last 7 | REACT_APP__REPRESENTATIVE_ROLE=Sales Director 8 | 9 | REACT_APP__ADDR_LINE_1=1 Big Ben 10 | REACT_APP__ADDR_LINE_2=London 11 | REACT_APP__ADDR_LINE_3=EC1 1AA 12 | REACT_APP__ADDR_LINE_4=United Kingdom 13 | 14 | REACT_APP__COMPANY_REG_NO=87654321 15 | REACT_APP__VAT_NO=GB987654321 16 | 17 | REACT_APP__BANK_ACCOUNT_NO=12345678 18 | REACT_APP__BANK_SORT_CODE=20 20 20 19 | 20 | REACT_APP__BANK_IBAN=GB99LOYD111122222333344 21 | REACT_APP__BANK_BIC=LOYDGB12345 22 | 23 | 24 | # Client details below 25 | 26 | REACT_APP__CLIENT__COMPANY_NAME=Client Company Limited 27 | REACT_APP__CLIENT__REPRESENTATIVE_NAME=First Last 28 | REACT_APP__CLIENT__REPRESENTATIVE_ROLE=CEO 29 | 30 | REACT_APP__CLIENT__ADDR_LINE_1= 31 | REACT_APP__CLIENT__ADDR_LINE_2= 32 | REACT_APP__CLIENT__ADDR_LINE_3= 33 | REACT_APP__CLIENT__ADDR_LINE_4=United Kingdom 34 | 35 | REACT_APP__CLIENT__COMPANY_REG_NO=01234567 36 | REACT_APP__CLIENT__VAT_NO=GB012345678 37 | 38 | REACT_APP__CLIENT__BANK_ACCOUNT_NO= 39 | REACT_APP__CLIENT__BANK_SORT_CODE= 40 | 41 | REACT_APP__CLIENT__BANK_IBAN= 42 | REACT_APP__CLIENT__BANK_BIC= 43 | 44 | 45 | # Invoice default meta data 46 | 47 | REACT_APP__INVOICE_DATE=01/01/2021 48 | REACT_APP__INVOICE_SERIES=SALE- 49 | REACT_APP__INVOICE_NO=001 50 | REACT_APP__INVOICE_CURRENCY=£ 51 | REACT_APP__INVOICE_BRAND_NAME=YOUR COMPANY 52 | REACT_APP__INVOICE_BRAND_SUBNAME= LIMITED 53 | REACT_APP__INVOICE_VAT_RATE_PCT=20 54 | 55 | REACT_APP__INVOICE_ENTRY__DATE_PROVIDED=01/01/2021 56 | REACT_APP__INVOICE_ENTRY__DESCRIPTION=Consulting 57 | REACT_APP__INVOICE_ENTRY__QTY=1 58 | REACT_APP__INVOICE_ENTRY__QTY_TYPE=days 59 | REACT_APP__INVOICE_ENTRY__RATE=1000 60 | REACT_APP__INVOICE_ENTRY__TOTAL=1000 61 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP__LANG=en 2 | 3 | 4 | # Service provider details below 5 | 6 | REACT_APP__COMPANY_NAME=GoodCompany Ltd 7 | REACT_APP__REPRESENTATIVE_NAME=Firstname Lastname 8 | REACT_APP__REPRESENTATIVE_ROLE=Director 9 | 10 | REACT_APP__ADDR_LINE_1=9000 Tall Building 11 | REACT_APP__ADDR_LINE_2=100 Wide Street 12 | REACT_APP__ADDR_LINE_3=W1W London 13 | REACT_APP__ADDR_LINE_4=United Kingdom 14 | 15 | REACT_APP__COMPANY_REG_NO=098765432 16 | REACT_APP__VAT_NO=GB98765432 17 | 18 | REACT_APP__BANK_ACCOUNT_NO=01234567 19 | REACT_APP__BANK_SORT_CODE=01-00-01 20 | 21 | REACT_APP__BANK_IBAN=GB39 ABCD 0011 0011 0011 00 22 | REACT_APP__BANK_BIC=ABCDGB21 23 | 24 | 25 | # Client details below 26 | 27 | REACT_APP__CLIENT__COMPANY_NAME=ThePerfectClient Ltd 28 | REACT_APP__CLIENT__REPRESENTATIVE_NAME=Firstname Lastname 29 | REACT_APP__CLIENT__REPRESENTATIVE_ROLE=Director 30 | 31 | REACT_APP__CLIENT__ADDR_LINE_1=9001 Tall Building 32 | REACT_APP__CLIENT__ADDR_LINE_2=100 Wide Street 33 | REACT_APP__CLIENT__ADDR_LINE_3=W1W London 34 | REACT_APP__CLIENT__ADDR_LINE_4=United Kingdom 35 | 36 | REACT_APP__CLIENT__COMPANY_REG_NO=098765431 37 | REACT_APP__CLIENT__VAT_NO=GB98765431 38 | 39 | REACT_APP__CLIENT__BANK_ACCOUNT_NO=098765430 40 | REACT_APP__CLIENT__BANK_SORT_CODE=01-00-01 41 | 42 | REACT_APP__CLIENT__BANK_IBAN=GB39 ABCD 0011 0011 0011 01 43 | REACT_APP__CLIENT__BANK_BIC=ABCDGB21 44 | 45 | 46 | # Invoice default meta data 47 | 48 | REACT_APP__INVOICE_DATE=16/12/2021 49 | REACT_APP__INVOICE_SERIES=INV- 50 | REACT_APP__INVOICE_NO=00101 51 | REACT_APP__INVOICE_CURRENCY=£ 52 | REACT_APP__INVOICE_BRAND_NAME=Good 53 | REACT_APP__INVOICE_BRAND_SUBNAME=Company 54 | REACT_APP__INVOICE_VAT_RATE_PCT=20 55 | 56 | REACT_APP__INVOICE_ENTRY__DATE_PROVIDED=16/12/2021 57 | REACT_APP__INVOICE_ENTRY__DESCRIPTION=Invoice system sample 58 | REACT_APP__INVOICE_ENTRY__QTY=1 59 | REACT_APP__INVOICE_ENTRY__QTY_TYPE=h 60 | REACT_APP__INVOICE_ENTRY__RATE=7.83 61 | REACT_APP__INVOICE_ENTRY__TOTAL=7.83 62 | -------------------------------------------------------------------------------- /src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import CssBaseline from "@mui/material/CssBaseline" 3 | import {createTheme, ThemeProvider} from "@mui/material/styles" 4 | import {Grid, Link} from "@mui/material" 5 | import './App.css' 6 | import Invoice from '../Invoice' 7 | import StoredInvoicesList from "../StoredInvoicesList" 8 | import InvoiceForm from "../InvoiceForm" 9 | import PrefillCustomer from "../PrefillCustomer" 10 | 11 | const theme = createTheme({ 12 | typography: { 13 | fontFamily: [ 14 | '"Open Sans"', 15 | 'BlinkMacSystemFont', 16 | ].join(','), 17 | }, 18 | }) 19 | 20 | class App extends PureComponent { 21 | render() { 22 | return ( 23 | 24 | 25 |
26 |
27 | 28 | 29 |

REACTINVOICE

30 |
31 | 32 | LT 33 | EN 34 | 35 |
36 |
37 |
38 |
39 | 40 |
41 | 42 |
43 | 44 | 45 |
46 | 47 |
48 | 49 |
50 |
51 | 54 |
55 |
56 | ) 57 | } 58 | } 59 | 60 | export default App; 61 | -------------------------------------------------------------------------------- /src/components/Invoice/InvoiceSubject.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import PropTypes from 'prop-types' 3 | import labels from './../../translations' 4 | 5 | export class InvoiceSubject extends Component { 6 | 7 | subjectTypes = { 8 | provider: labels.provider, 9 | customer: labels.customer 10 | } 11 | 12 | render() { 13 | const { 14 | subject, 15 | subjectType, 16 | } = this.props 17 | 18 | const subjectTypeName = this.subjectTypes[subjectType] || '' 19 | 20 | return ( 21 |
22 | {subjectTypeName} 23 |

24 | {subject.companyName}
25 | {subject.companyRegNo && `${labels.companyRegNo} ${subject.companyRegNo}`} 26 | {subject.companyRegNo &&
} 27 | {subject.companyVatNo && `${labels.companyVatNo} ${subject.companyVatNo}`} 28 | {subject.companyVatNo &&
} 29 | {subject.name}{subject.role && ` - ${subject.role}`}
30 | {subject.addressLine1}
31 | {subject.addressLine2}
32 | {subject.addressLine3}
33 | {subject.addressLine4} 34 |

35 | 36 | {(subject.billingBankAccountBic || subject.billingBankAccountNo) &&
{labels.billingInformation}
} 37 |

38 | {subject.billingBankAccountBic && `${labels.billingBankAccountBic} ${subject.billingBankAccountBic}`} 39 | {subject.billingBankAccountBic &&
} 40 | {subject.billingBankAccountIban && `${labels.billingBankAccountIban} ${subject.billingBankAccountIban}`} 41 | {subject.billingBankAccountIban &&
} 42 |

43 |

44 | {subject.billingBankAccountNo && `${labels.billingBankAccountNo} ${subject.billingBankAccountNo}`} 45 | {subject.billingBankAccountNo &&
} 46 | {subject.billingBankAccountSortCode && `${labels.billingBankAccountSortCode} ${subject.billingBankAccountSortCode}`} 47 | {subject.billingBankAccountSortCode &&
} 48 |

49 |
50 | ) 51 | } 52 | } 53 | 54 | InvoiceSubject.propTypes = { 55 | subject: PropTypes.object, 56 | subjectType: PropTypes.string, 57 | } 58 | 59 | export default InvoiceSubject -------------------------------------------------------------------------------- /src/translations/en-UK.js: -------------------------------------------------------------------------------- 1 | export const labels = { 2 | companyName: 'Company name', 3 | companyRegNo: 'Company reg. no.:', 4 | companyVatNo: 'VAT reg. no.:', 5 | provider: 'Provider', 6 | customer: 'Customer', 7 | invoice: 'Invoice', 8 | invoiceDate: 'Invoice Date', 9 | invoiceNumber: 'Invoice Number', 10 | invoiceSeries: 'Invoice series', 11 | worksCompleted: 'Works completed / Services provided', 12 | date: 'Date', 13 | description: 'Description', 14 | qty: 'Qty', 15 | unit: 'Unit', 16 | rate: 'Rate', 17 | total: 'Total', 18 | vatBasis: 'VAT Basis', 19 | vatAmount: 'VAT Amount', 20 | vatRate: 'VAT rate', 21 | vatRatePct: 'VAT rate %', 22 | currency: 'Currency', 23 | vat: 'VAT', 24 | totalPayable: 'Total Payable', 25 | addNew: 'Add new row', 26 | invoiceMeta: 'Invoice meta', 27 | invoiceBranding: 'Invoice branding', 28 | companyId: 'Comapny ID', 29 | enterEntryDescription: 'Entry description', 30 | billingInformation: 'Billing information', 31 | billingBankAccountIban: 'IBAN:', 32 | billingBankAccountBic: 'BIC:', 33 | billingBankAccountNo: 'Account number:', 34 | billingBankAccountSortCode: 'Sort code:', 35 | name: 'Name', 36 | brandName: 'Brand name part 1', 37 | brandSubName: 'Brand name part 2', 38 | role: 'Role', 39 | addressLine1: 'Address Line 1', 40 | addressLine2: 'Address Line 2', 41 | addressLine3: 'Address Line 3', 42 | addressLine4: 'Address Line 4', 43 | 44 | // other interface translations 45 | dateFormat: 'DD/MM/YYYY', 46 | // show: 'Show', 47 | // hide: 'Hide', 48 | showForm: 'Show form', 49 | hideForm: 'Hide form', 50 | note: 'Note', 51 | createNewInvoice: 'Create new invoice', 52 | creatingNewInvoice: 'Creating new invoice', 53 | editingExistingInvoice: 'Editing existing invoice', 54 | saveInvoice: 'Save invoice', 55 | lock: 'Lock', 56 | unlock: 'Unlock', 57 | deleteInvoice: 'Delete invoice', 58 | companyDetails: 'Company details', 59 | representativeDetails: 'Representative details', 60 | companyAddress: 'Company address', 61 | billingInfo: 'Billing info', 62 | isClientVatRegistered: 'Is client VAT registered?', 63 | prefillCustomer: 'Prefill', 64 | selectExistingCustomer: 'Select from existing customers', 65 | showingCustomersWithDetails: 'Showing customers that have "Address Line 1" present.', 66 | } -------------------------------------------------------------------------------- /src/translations/lt-LT.js: -------------------------------------------------------------------------------- 1 | export const labels = { 2 | companyName: 'Įmonės pavadinimas', 3 | companyRegNo: 'Registracijos nr.:', 4 | companyVatNo: 'PVM kodas:', 5 | provider: 'Tiekėjas', 6 | customer: 'Klientas', 7 | invoice: 'Sąskaita', 8 | invoiceDate: 'Sąskaitos išrašymo data', 9 | invoiceNumber: 'Sąskaitos numeris', 10 | invoiceSeries: 'Sąskaitos serija', 11 | worksCompleted: 'Atlikti darbai / suteiktos paslaugos', 12 | date: 'Data', 13 | description: 'Aprašymas', 14 | qty: 'Kiekis', 15 | unit: 'Matas', 16 | rate: 'Įkainis', 17 | total: 'Viso', 18 | vatBasis: 'PVM taikomas sumai', 19 | vatAmount: 'PVM suma', 20 | vatRate: 'PVM tarifas', 21 | vatRatePct: 'PVM tarifas %', 22 | currency: 'Valiuta', 23 | vat: 'PVM', 24 | totalPayable: 'Viso mokėti', 25 | addNew: 'Pridėti naują eilutę', 26 | invoiceMeta: 'Sąskaitos informacija', 27 | invoiceBranding: 'Sąskaitos firminis stilius', 28 | companyId: 'Įmonės identifikacija', 29 | enterEntryDescription: 'Eilutės aprašymas', 30 | billingInformation: 'Atsiskaitymo informacija', 31 | billingBankAccountIban: 'IBAN:', 32 | billingBankAccountBic: 'BIC:', 33 | billingBankAccountNo: 'Sąskaitos nr:', 34 | billingBankAccountSortCode: 'Bankas:', 35 | name: 'Vardas', 36 | brandName: 'Pavadinimo pirma dalis', 37 | brandSubName: 'Pavadinimo antra dalis', 38 | role: 'Pareigos', 39 | addressLine1: 'Adreso eilutė 1', 40 | addressLine2: 'Adreso eilutė 2', 41 | addressLine3: 'Adreso eilutė 3', 42 | addressLine4: 'Adreso eilutė 4', 43 | 44 | // other interface translations 45 | dateFormat: 'YYYY-MM-DD', 46 | // show: 'Show', 47 | // hide: 'Hide', 48 | showForm: 'Rodyti formą', 49 | hideForm: 'Nerodyti formos', 50 | note: 'Pastaba', 51 | createNewInvoice: 'Kurti naują sąskaitą', 52 | creatingNewInvoice: 'Kuriama nauja sąskaita faktūra', 53 | editingExistingInvoice: 'Redaguojama faktūra', 54 | saveInvoice: 'Įšsaugoti sąskaitą', 55 | lock: 'Užrakinti', 56 | unlock: 'Atrakinti', 57 | deleteInvoice: 'Ištrinti sąskaitą', 58 | companyDetails: 'Įmonės duomenys', 59 | representativeDetails: 'Įmonę atstovaujantis asmuo', 60 | companyAddress: 'Įmonės adresas', 61 | billingInfo: 'Atsiskaitymo info', 62 | isClientVatRegistered: 'Ar klientas yra PVM mokėtojas?', 63 | prefillCustomer: 'Pildyti', 64 | selectExistingCustomer: 'Parinkti iš esamų klientų', 65 | showingCustomersWithDetails: 'Rodomi klientai, kurių "Adreso eilutė 1" įvesta.', 66 | } -------------------------------------------------------------------------------- /src/components/App/App.css: -------------------------------------------------------------------------------- 1 | html, body, #root, .App { 2 | height: 100%; 3 | } 4 | 5 | .App { 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .App-header { 11 | flex-grow: 0; 12 | border-bottom: 1px solid #cacaca; 13 | background-color: rgba(0, 0, 0, 0.11); 14 | } 15 | 16 | .App-header h1 { 17 | font-size: 17px; 18 | margin: 7px 15px; 19 | font-weight: normal; 20 | } 21 | 22 | .App-header h1 > strong { 23 | font-weight: bold; 24 | } 25 | 26 | .App-content { 27 | flex-grow: 1; 28 | } 29 | 30 | .App-footer { 31 | flex-grow: 0; 32 | height: 20px; 33 | border-top: 1px solid #cacaca; 34 | background-color: rgba(0, 0, 0, 0.11); 35 | font-size: 11px; 36 | text-align: center; 37 | color: #666; 38 | } 39 | 40 | .App-content-columns { 41 | display: flex; 42 | } 43 | 44 | .App-invoice-list { 45 | flex-grow: 0; 46 | width: 20%; 47 | max-width: 300px; 48 | min-width: 240px; 49 | background-color: rgba(0, 0, 0, 0.08); 50 | border-right: 1px solid #cacaca; 51 | } 52 | 53 | .App-invoice-form { 54 | flex-grow: 0; 55 | width: 50%; 56 | max-width: 700px; 57 | min-width: 600px; 58 | background-color: rgba(0, 0, 0, 0.05); 59 | border-right: 1px solid #cacaca; 60 | } 61 | 62 | .App-invoice-preview { 63 | flex-grow: 1; 64 | } 65 | 66 | .App-invoice-list-items { 67 | list-style: none; 68 | margin: 0; 69 | padding: 0; 70 | } 71 | 72 | .App-invoice-list-items > li { 73 | border-bottom: 1px solid #cacaca; 74 | margin: 0; 75 | } 76 | 77 | .App-invoice-list-items > li > a > small { 78 | font-size: 11px; 79 | } 80 | .App-invoice-list-items > li > a { 81 | display: block; 82 | font-size: 15px; 83 | text-decoration: none; 84 | color: #333; 85 | padding: 7px 15px; 86 | } 87 | 88 | .App-invoice-list-items > li > a:hover { 89 | background-color: #fafafa; 90 | } 91 | 92 | .App-invoice-list .Invoice-active > a { 93 | position: relative; 94 | background-color: rgba(255, 255, 255, 0.4); 95 | } 96 | .App-invoice-list .Invoice-active > a::after { 97 | position: absolute; 98 | content: " "; 99 | height: 60px; 100 | background-color: rgba(255, 255, 255, 0.7); 101 | width: 1px; 102 | top: 0; 103 | right: -1px; 104 | } 105 | .App-invoice-list .Invoice-active > a:hover { 106 | background-color: #f5f5f5; 107 | } 108 | 109 | @media print { 110 | .App-header { display: none; } 111 | .App-footer { display: none; } 112 | .App-invoice-list { display: none; } 113 | .App-invoice-form { display: none; } 114 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [react-invoice](https://react-invoice-nu.vercel.app/) 2 | I've built this app for in-browser quick invoice creation. You can start using it here: [https://react-invoice-nu.vercel.app/](https://react-invoice-nu.vercel.app/). 3 | 4 | ### Features 5 | 6 | - Create invoice 7 | - Manage multiple invoices (no limit on count is set in the code) 8 | - Edit invoice branding 9 | - Edit supplier/provider details 10 | - Edit customer details 11 | - Select customer details from previous customers to auto-fill 12 | - Add, edit, remove invoice entries 13 | - Edit invoice number and series 14 | - Define currency 15 | - Define VAT rate 16 | - Lock invoice so you don't accidentally change it 17 | - Print layout. You may print invoice to a PDF or send to a printer. 18 | - Auto-generate invoice number based on existing invoices 19 | - Auto pre-fill provider details from latest invoice 20 | - Show/hide provider and customer forms 21 | - All data is stored in your browser's memory (localStorage). All data is yours. 22 | 23 | ### What it looks like 24 | 25 | Demo is available here: [https://react-invoice-nu.vercel.app/](https://react-invoice-nu.vercel.app/). 26 | 27 | ![React Invoice Screenshot](docs/react-invoice-window.png?raw=true "React Invoice Screenshot") 28 | 29 | ### Printing 30 | 31 | Use in-browser print functionality to print to PDF or send to your printer. Print view includes only active invoice. 32 | 33 | ![React Invoice Print Preview](docs/react-invoice-print-preview.png?raw=true "React Invoice Screenshot") 34 | 35 | ### Running on your computer 36 | To run it on your machine you'll need [NVM](https://github.com/nvm-sh/nvm) to run this JS app (or simply [Node](https://nodejs.org/) v16). 37 | 38 | Install relevant Node version by running `nvm install 16`. Once Node is installed run below commands within the project directory. 39 | 40 | ``` 41 | nvm use 42 | yarn install 43 | yarn start 44 | ``` 45 | 46 | Note: all information is stored in your Browser's LocalStorage. You may access that via Developer Tools / Application / LocalStorage. 47 | This means no information is sent anywhere, just exists and is stored on your web browser. If you update your browser or clear all history / cache, you're likely to lose all stored invoices. 48 | 49 | ### Default config 50 | 51 | Default configuration is stored in `.env` file. In order to define your default invoice values clone `.env` into `.env.local` and modify default config values accordingly. 52 | 53 | ### Deployment 54 | 55 | If you'd like to deploy this app for yourself, you need only a simple http server (Apache, Nginx or similar). Run `yarn build` and copy `/build` directory contents to your hosting. 56 | -------------------------------------------------------------------------------- /src/invoiceConfig.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Default invoice info is picked up here from .env file 3 | * */ 4 | 5 | export default { 6 | lang: process.env.REACT_APP__LANG, 7 | history: [], 8 | provider: { 9 | companyName: process.env.REACT_APP__COMPANY_NAME || '', 10 | name: process.env.REACT_APP__REPRESENTATIVE_NAME || '', 11 | role: process.env.REACT_APP__REPRESENTATIVE_ROLE || '', 12 | addressLine1: process.env.REACT_APP__ADDR_LINE_1 || '', 13 | addressLine2: process.env.REACT_APP__ADDR_LINE_2 || '', 14 | addressLine3: process.env.REACT_APP__ADDR_LINE_3 || '', 15 | addressLine4: process.env.REACT_APP__ADDR_LINE_4 || '', 16 | companyRegNo: process.env.REACT_APP__COMPANY_REG_NO || '', 17 | companyVatNo: process.env.REACT_APP__VAT_NO || '', 18 | billingBankAccountIban: process.env.REACT_APP__BANK_IBAN || '', 19 | billingBankAccountBic: process.env.REACT_APP__BANK_BIC || '', 20 | billingBankAccountNo: process.env.REACT_APP__BANK_ACCOUNT_NO || '', 21 | billingBankAccountSortCode: process.env.REACT_APP__BANK_SORT_CODE || '', 22 | }, 23 | customer: { 24 | companyName: process.env.REACT_APP__CLIENT__COMPANY_NAME || '', 25 | name: process.env.REACT_APP__CLIENT__REPRESENTATIVE_NAME || '', 26 | role: process.env.REACT_APP__CLIENT__REPRESENTATIVE_ROLE || '', 27 | addressLine1: process.env.REACT_APP__CLIENT__ADDR_LINE_1 || '', 28 | addressLine2: process.env.REACT_APP__CLIENT__ADDR_LINE_2 || '', 29 | addressLine3: process.env.REACT_APP__CLIENT__ADDR_LINE_3 || '', 30 | addressLine4: process.env.REACT_APP__CLIENT__ADDR_LINE_4 || '', 31 | companyRegNo: process.env.REACT_APP__CLIENT__COMPANY_REG_NO || '', 32 | companyVatNo: process.env.REACT_APP__CLIENT__VAT_NO || '', 33 | billingBankAccountIban: process.env.REACT_APP__CLIENT__BANK_IBAN || '', 34 | billingBankAccountBic: process.env.REACT_APP__CLIENT__BANK_BIC || '', 35 | billingBankAccountNo: process.env.REACT_APP__CLIENT__BANK_ACCOUNT_NO || '', 36 | billingBankAccountSortCode: process.env.REACT_APP__CLIENT__BANK_SORT_CODE || '', 37 | }, 38 | invoiceMeta: { 39 | invoiceDate: process.env.REACT_APP__INVOICE_DATE || '', 40 | invoiceSeries: process.env.REACT_APP__INVOICE_SERIES || '', 41 | invoiceNo: process.env.REACT_APP__INVOICE_NO || '', 42 | currency: process.env.REACT_APP__INVOICE_CURRENCY || '', 43 | brandName: process.env.REACT_APP__INVOICE_BRAND_NAME || '', 44 | brandSubName: process.env.REACT_APP__INVOICE_BRAND_SUBNAME || '', 45 | vatRate: process.env.REACT_APP__INVOICE_VAT_RATE_PCT || '', 46 | }, 47 | invoiceEntries: [ 48 | { 49 | dateProvided: process.env.REACT_APP__INVOICE_ENTRY__DATE_PROVIDED || '', 50 | description: process.env.REACT_APP__INVOICE_ENTRY__DESCRIPTION || '', 51 | qty: process.env.REACT_APP__INVOICE_ENTRY__QTY || '', 52 | qtyType: process.env.REACT_APP__INVOICE_ENTRY__QTY_TYPE || '', 53 | rate: process.env.REACT_APP__INVOICE_ENTRY__RATE || '', 54 | total: process.env.REACT_APP__INVOICE_ENTRY__TOTAL || '', 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /src/redux/invoice/actions.js: -------------------------------------------------------------------------------- 1 | import actionTypes from '../../decorators/actionTypes' 2 | 3 | export const types = actionTypes('invoice')({ 4 | UPDATE_INVOICE_SECTION: 'UPDATE_INVOICE_SECTION', 5 | UPDATE_INVOICE_ENTRY: 'UPDATE_INVOICE_ENTRY', 6 | ADD_INVOICE_ENTRY: 'ADD_INVOICE_ENTRY', 7 | REMOVE_INVOICE_ENTRY: 'REMOVE_INVOICE_ENTRY', 8 | GET_STORED_INVOICES: 'GET_STORED_INVOICES', 9 | GET_STORED_INVOICE: 'GET_STORED_INVOICE', 10 | GET_ACTIVE_INVOICE: 'GET_ACTIVE_INVOICE', 11 | SET_INVOICE: 'SET_INVOICE', 12 | SET_INVOICES: 'SET_INVOICES', 13 | STORE_NEW_INVOICE: 'STORE_NEW_INVOICE', 14 | START_NEW_INVOICE: 'START_NEW_INVOICE', 15 | SET_LOADING: 'SET_LOADING', 16 | SET_ERROR: 'SET_ERROR', 17 | LOCK_INVOICE: 'LOCK_INVOICE', 18 | DELETE_INVOICE: 'DELETE_INVOICE', 19 | TOGGLE_SECTION_VISIBILITY: 'TOGGLE_SECTION_VISIBILITY', 20 | GENERATE_INVOICE_NUMBER: 'GENERATE_INVOICE_NUMBER', 21 | COPY_LATEST_SUPPLIER_DETAILS: 'COPY_LATEST_SUPPLIER_DETAILS', 22 | SET_INVOICE_NUMBER: 'SET_INVOICE_NUMBER', 23 | NEW_INVOICE_ENTRY: 'NEW_INVOICE_ENTRY', 24 | }) 25 | 26 | export const invoiceActions = { 27 | setInvoiceNo: (invoiceNo) => ({type: types.SET_INVOICE_NUMBER, payload: {invoiceNo}}), 28 | generateInvoiceNumber: () => ({type: types.GENERATE_INVOICE_NUMBER, payload: {}}), 29 | copyLatestSupplierDetails: () => ({type: types.COPY_LATEST_SUPPLIER_DETAILS, payload: {}}), 30 | toggleSectionVisibility: (section, display) => ({type: types.TOGGLE_SECTION_VISIBILITY, payload: {section, display}}), 31 | updateInvoiceSection: (section, sectionData) => ({type: types.UPDATE_INVOICE_SECTION, payload: {section, sectionData}}), 32 | updateInvoiceEntry: (entryIndex, entryData) => ({type: types.UPDATE_INVOICE_ENTRY, payload: {entryIndex, entryData}}), 33 | removeInvoiceEntry: (entryIndex) => ({type: types.REMOVE_INVOICE_ENTRY, payload: {entryIndex}}), 34 | addInvoiceEntry: (entryData) => ({type: types.ADD_INVOICE_ENTRY, payload: {entryData}}), 35 | newInvoiceEntry: () => ({type: types.NEW_INVOICE_ENTRY, payload: {}}), 36 | lockInvoice: (uuid) => ({type: types.LOCK_INVOICE, payload: {uuid}}), 37 | deleteInvoice: (uuid) => ({type: types.DELETE_INVOICE, payload: {uuid}}), 38 | 39 | getInvoices: () => ({ 40 | type: types.GET_STORED_INVOICES, 41 | payload: { 42 | } 43 | }), 44 | getInvoice: (uuid) => ({ 45 | type: types.GET_STORED_INVOICE, 46 | payload: { 47 | uuid 48 | } 49 | }), 50 | getActiveInvoice: (uuid) => ({ 51 | type: types.GET_ACTIVE_INVOICE, 52 | payload: { 53 | uuid 54 | } 55 | }), 56 | setInvoice: (invoice) => ({ 57 | type: types.SET_INVOICE, 58 | payload: { 59 | invoice 60 | } 61 | }), 62 | storeNewInvoice: (invoice) => ({ 63 | type: types.STORE_NEW_INVOICE, 64 | payload: { 65 | invoice 66 | } 67 | }), 68 | setInvoices: (invoices) => ({ 69 | type: types.SET_INVOICES, 70 | payload: { 71 | invoices 72 | } 73 | }), 74 | startNewInvoice: () => ({ 75 | type: types.START_NEW_INVOICE, 76 | payload: { 77 | } 78 | }), 79 | setLoading: (isLoading) => ({ 80 | type: types.SET_LOADING, 81 | payload: { 82 | isLoading 83 | } 84 | }), 85 | setError: (error) => ({ 86 | type: types.SET_ERROR, 87 | payload: { 88 | error 89 | } 90 | }), 91 | } 92 | 93 | export default invoiceActions -------------------------------------------------------------------------------- /src/utils/invoice.js: -------------------------------------------------------------------------------- 1 | export const recalcEntry = (entry, fieldName, fieldValue) => { 2 | switch (fieldName) { 3 | case "qty": 4 | entry.total = parseFloat(fieldValue) * parseFloat(entry.rate) 5 | entry.qty = parseInt(fieldValue) 6 | return entry 7 | case "rate": 8 | entry.total = parseFloat(fieldValue) * parseInt(entry.qty) 9 | entry.rate = parseFloat(fieldValue) 10 | return entry 11 | case "total": 12 | entry.rate = parseFloat(fieldValue) / parseInt(entry.qty) 13 | entry.total = parseFloat(fieldValue) 14 | return entry 15 | default: 16 | break 17 | } 18 | } 19 | 20 | // title and numbers calc 21 | 22 | export const getVatMultiplier = (vat) => { 23 | return parseFloat(100 + parseFloat(vat)) / 100 24 | } 25 | 26 | export const getInvoiceTotalPayable = ({invoiceEntries, invoiceMeta}) => { 27 | const total = invoiceEntries?.reduce((grandTotal, entry) => { 28 | return grandTotal + parseFloat(entry.total) 29 | }, 0) 30 | 31 | const multiplier = getVatMultiplier(invoiceMeta.vatRate) 32 | 33 | return total * multiplier 34 | } 35 | 36 | export const getTotalsAndVat = (entries, invoiceMeta) => { 37 | const total = entries?.reduce((grandTotal, entry) => { 38 | return grandTotal + parseFloat(entry.total) 39 | }, 0) 40 | 41 | const multiplier = getVatMultiplier(invoiceMeta?.vatRate) 42 | 43 | return { 44 | total: total, 45 | vatAmount: (total * multiplier) - total, 46 | vatBasis: total, 47 | totalVat: total * multiplier 48 | } 49 | } 50 | 51 | export const constructTitle = ({provider, customer, invoiceEntries, invoiceMeta, uuid}) => { 52 | try { 53 | const invoiceDateISO = String(invoiceMeta.invoiceDate).split('/').reverse().join('_') 54 | const invoiceCurrency = invoiceMeta.currency 55 | const invoiceCurrencyISO = invoiceCurrency === '£' ? 'GBP' : invoiceCurrency === '€' ? 'EUR' : invoiceCurrency 56 | const invoiceTotal = Number(getInvoiceTotalPayable({invoiceEntries, invoiceMeta})).toFixed(2); 57 | const vatInclusive = Number(invoiceMeta.vatRate) > 0 ? ' VAT incl. ' : ' NON-VAT ' 58 | const nameOnFile = `${customer.companyName}, ${customer.name}` 59 | 60 | return `${invoiceDateISO} - ${invoiceMeta.invoiceSeries}${invoiceMeta.invoiceNo} - ${invoiceCurrencyISO}${invoiceTotal}${vatInclusive} - (PENDING) - ${nameOnFile}` 61 | } catch (error) { 62 | return 'React Invoice - Creating New Invoice'; 63 | } 64 | } 65 | 66 | export const today = () => { 67 | const date = new Date() 68 | const day = date.getDate() 69 | const month = date.getMonth() + 1 70 | const year = date.getFullYear() 71 | 72 | return `${day}/${month}/${year}` 73 | } 74 | 75 | // const qtyTypes = [ 76 | // { 77 | // name: '', 78 | // description: 'none' 79 | // }, 80 | // { 81 | // name: 'h', 82 | // description: 'hours' 83 | // }, 84 | // { 85 | // name: 'd', 86 | // description: 'days' 87 | // }, 88 | // { 89 | // name: 'units', 90 | // description: 'number of goods' 91 | // } 92 | // ] 93 | // 94 | // const currencyTypes = [ 95 | // { 96 | // symbol: '£', 97 | // name: 'British Pound', 98 | // iso: 'GBP', 99 | // }, 100 | // { 101 | // symbol: '€', 102 | // name: 'Euro', 103 | // iso: 'EUR', 104 | // }, 105 | // { 106 | // symbol: '$', 107 | // name: 'US Dollar', 108 | // iso: 'USD', 109 | // }, 110 | // { 111 | // symbol: 'Fr.', 112 | // name: 'Swiss Frank', 113 | // iso: 'CHF', 114 | // } 115 | // ] 116 | 117 | export default { 118 | recalcEntry, 119 | constructTitle, 120 | today, 121 | } 122 | -------------------------------------------------------------------------------- /src/components/PrefillCustomer/index.js: -------------------------------------------------------------------------------- 1 | import React, {Fragment, PureComponent} from 'react' 2 | import {connect} from 'react-redux' 3 | import PropTypes from 'prop-types' 4 | import { 5 | Backdrop, 6 | Box, 7 | Fade, 8 | Modal, 9 | Typography, 10 | List, 11 | ListItem, 12 | ListItemText, 13 | ListItemButton, 14 | Divider, 15 | } from '@mui/material' 16 | import invoiceActions from '../../redux/invoice/actions' 17 | import {selectors as invoiceSelector} from '../../redux/invoice/reducer' 18 | import labels from '../../translations' 19 | 20 | const boxStyle = { 21 | position: 'absolute', 22 | top: '50%', 23 | left: '50%', 24 | transform: 'translate(-50%, -50%)', 25 | width: '80%', 26 | maxWidth: '600px', 27 | bgcolor: 'background.paper', 28 | boxShadow: 10, 29 | p: 3, 30 | } 31 | 32 | const mapStateToProps = (state) => ({ 33 | displaySection: invoiceSelector.displaySection(state), 34 | allCustomers: invoiceSelector.allCustomers(state), 35 | }) 36 | 37 | const mapDispatchToProps = { 38 | getInvoices: invoiceActions.getInvoices, 39 | toggleSectionVisibility: invoiceActions.toggleSectionVisibility, 40 | updateInvoiceSection: invoiceActions.updateInvoiceSection, 41 | } 42 | 43 | export class PrefillCustomer extends PureComponent { 44 | static propTypes = { 45 | isLoading: PropTypes.bool, 46 | getInvoices: PropTypes.func, 47 | updateInvoiceSection: PropTypes.func, 48 | toggleSectionVisibility: PropTypes.func, 49 | displaySection: PropTypes.object, 50 | allCustomers: PropTypes.array, 51 | } 52 | 53 | handlePrefillCustomer = (customer) => () => { 54 | // TODO: copy VAT rate from invoice meta as well 55 | this.props.updateInvoiceSection('customer', customer) 56 | this.props.toggleSectionVisibility('customerPrefill', false) 57 | } 58 | 59 | handleClose = () => { 60 | this.props.toggleSectionVisibility('customerPrefill', false) 61 | } 62 | 63 | render = () => { 64 | const {displaySection, allCustomers} = this.props 65 | 66 | return ( 67 | 68 | 79 | 80 | 81 | 82 | {labels.selectExistingCustomer} 83 | 84 | 85 | {labels.showingCustomersWithDetails} 86 | 87 | 88 | 89 | {allCustomers?.map((customer, index) => ( 90 | 91 | 92 | 93 | 94 | {customer.companyName} 95 | 96 | 97 | 98 | 99 | 100 | ))} 101 | 102 | 103 | 104 | 105 | 106 | ) 107 | } 108 | } 109 | 110 | export default connect(mapStateToProps, mapDispatchToProps)(PrefillCustomer) 111 | -------------------------------------------------------------------------------- /src/components/InvoiceParty/index.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent, Fragment} from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | TextField, 5 | Typography, 6 | } from '@mui/material' 7 | import labels from '../../translations' 8 | import {inputStyle, subtitleStyle} from '../../shared-styles' 9 | 10 | export class InvoiceParty extends PureComponent { 11 | handleChange = (event) => { 12 | const {name, value} = event.target 13 | this.props.onUpdate(name, value) 14 | } 15 | 16 | render() { 17 | const { 18 | subject, 19 | locked, 20 | } = this.props 21 | 22 | return ( 23 | 24 | {labels.companyDetails} 25 | 26 | 27 | 28 | 29 | {labels.representativeDetails} 30 | 31 | 32 | 33 | {labels.companyAddress} 34 | 35 | 36 | 37 | 38 | 39 | {labels.billingInfo} 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | } 48 | 49 | InvoiceParty.propTypes = { 50 | subject: PropTypes.object, 51 | onUpdate: PropTypes.func 52 | } 53 | 54 | export default InvoiceParty -------------------------------------------------------------------------------- /src/components/StoredInvoicesList/index.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent, Fragment} from 'react' 2 | 3 | import PropTypes from 'prop-types' 4 | import { 5 | List, 6 | ListItem, 7 | ListItemButton, 8 | ListItemIcon, 9 | ListItemText, 10 | Divider, 11 | Skeleton, Typography, 12 | } from '@mui/material' 13 | import LockIcon from '@mui/icons-material/Lock'; 14 | import { styled, ThemeProvider, createTheme } from '@mui/material/styles' 15 | 16 | import AddBoxIcon from '@mui/icons-material/AddBox' 17 | import ReceiptIcon from '@mui/icons-material/Receipt' 18 | 19 | import connectWithRedux from '../../decorators/connectWithRedux' 20 | import invoiceActions from '../../redux/invoice/actions' 21 | import {selectors as invoiceSelector} from '../../redux/invoice/reducer' 22 | import labels from '../../translations' 23 | 24 | const StyledList = styled(List)({ 25 | paddingTop: 0, 26 | '& .MuiListItemButton-root': { 27 | paddingLeft: 12, 28 | paddingRight: 12, 29 | }, 30 | '& .MuiListItemIcon-root': { 31 | minWidth: 0, 32 | marginRight: 8, 33 | }, 34 | '& .MuiSvgIcon-root': { 35 | fontSize: 20, 36 | }, 37 | }) 38 | 39 | @connectWithRedux((state) => ({ 40 | isLoading: invoiceSelector.state(state).isLoading, 41 | error: invoiceSelector.error(state), 42 | uuid: invoiceSelector.uuid(state), 43 | invoices: invoiceSelector.invoices(state), 44 | invoice: invoiceSelector.invoice(state), 45 | invoiceMeta: invoiceSelector.invoiceMeta(state), 46 | }), { 47 | getInvoices: invoiceActions.getInvoices, 48 | getInvoice: invoiceActions.getInvoice, 49 | startNewInvoice: invoiceActions.startNewInvoice, 50 | }) 51 | 52 | class StoredInvoicesList extends PureComponent { 53 | static propTypes = { 54 | isLoading: PropTypes.bool, 55 | getInvoices: PropTypes.func, 56 | getInvoice: PropTypes.func, 57 | startNewInvoice: PropTypes.func, 58 | invoices: PropTypes.array 59 | } 60 | 61 | componentDidMount() { 62 | this.props.getInvoices() 63 | } 64 | 65 | handleInvoicePick = (uuid) => () => { 66 | this.props.getInvoice(uuid) 67 | } 68 | 69 | handleNewInvoice = () => { 70 | this.props.startNewInvoice() 71 | } 72 | 73 | render = () => { 74 | const {invoices, invoiceMeta, isLoading, uuid} = this.props 75 | 76 | return ( 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | {labels.createNewInvoice} 86 | 87 | 88 | 89 | 90 | {invoices?.map((invoice, index) => ( 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | {invoice.invoiceMeta.invoiceSeries}{invoice.invoiceMeta.invoiceNo} 99 | {invoice.invoiceMeta.locked && } 100 | 101 | {invoice.invoiceMeta.invoiceDate} - {invoice.customer.companyName} 102 | 103 | 104 | 105 | ))} 106 | 107 | 108 | ) 109 | } 110 | } 111 | 112 | export default StoredInvoicesList 113 | -------------------------------------------------------------------------------- /src/components/InvoiceEntries/index.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent, Fragment} from 'react' 2 | import {Box, Grid, Button, IconButton, TextField, Typography} from "@mui/material"; 3 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; 4 | import AddBoxIcon from '@mui/icons-material/AddBox' 5 | import {inputStyle, subtitleStyle} from '../../shared-styles' 6 | import labels from '../../translations' 7 | import {recalcEntry} from "../../utils/invoice"; 8 | 9 | export class InvoiceEntries extends PureComponent { 10 | handleChange = (index, entry) => (event) => { 11 | const fieldName = event.target.name 12 | const fieldValue = event.target.value 13 | 14 | let updatedRow = { 15 | ...entry 16 | } 17 | 18 | if (fieldName === 'qty' || fieldName === 'rate' || fieldName === 'total') { 19 | try { 20 | updatedRow = { 21 | ...entry, 22 | ...recalcEntry(entry, fieldName, fieldValue) 23 | } 24 | } catch (err) { 25 | console.log('Could not recalculate row: ', err.message) 26 | } 27 | 28 | } else { 29 | updatedRow[event.target.name] = event.target.value 30 | } 31 | 32 | this.props.onUpdate(index, updatedRow) 33 | } 34 | 35 | handleAdd = (event) => { 36 | event.preventDefault() 37 | this.props.onAdd() 38 | } 39 | 40 | handleRemove = (index) => (event) => { 41 | event.preventDefault() 42 | this.props.onRemove(index) 43 | } 44 | 45 | render() { 46 | const {entries, locked} = this.props 47 | 48 | return ( 49 | 50 | {entries?.map((entry, index) => { 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | ) 80 | })} 81 | 82 | 85 | 86 | 87 | ) 88 | } 89 | } 90 | 91 | export default InvoiceEntries -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | } else { 39 | // Is not local host. Just register service worker 40 | registerValidSW(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/components/Invoice/index.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react' 2 | import './Invoice.css' 3 | import InvoiceSubject from './InvoiceSubject' 4 | import InvoiceEntries from './InvoiceEntries' 5 | import InvoiceMeta from './InvoiceMeta' 6 | import labels from './../../translations' 7 | import connectWithRedux from "../../decorators/connectWithRedux" 8 | import {selectors as invoiceSelector} from "../../redux/invoice/reducer" 9 | import {getTotalsAndVat} from '../../utils/invoice' 10 | 11 | @connectWithRedux((state) => ({ 12 | isLoading: invoiceSelector.isLoading(state), 13 | uuid: invoiceSelector.uuid(state), 14 | invoiceMeta: invoiceSelector.invoiceMeta(state), 15 | provider: invoiceSelector.provider(state), 16 | customer: invoiceSelector.customer(state), 17 | invoiceEntries: invoiceSelector.invoiceEntries(state), 18 | }), { 19 | 20 | }) 21 | 22 | class Invoice extends PureComponent { 23 | 24 | render() { 25 | const {provider, customer, invoiceEntries, invoiceMeta} = this.props 26 | const {total, totalVat, vatAmount, vatBasis} = getTotalsAndVat(invoiceEntries, invoiceMeta) 27 | 28 | return ( 29 |
30 |
31 |

32 | {invoiceMeta?.brandName} 33 | {invoiceMeta?.brandSubName} 34 |

35 |
36 | {provider.companyName && {provider?.companyName}} 37 | {provider.companyName &&
} 38 | {provider.companyRegNo && {labels.companyRegNo} {provider?.companyRegNo}} 39 | {provider.companyRegNo &&
} 40 | {provider.companyVatNo && {labels.companyVatNo} {provider?.companyVatNo}} 41 |
42 |
43 |
44 |
45 |
46 | 47 | 48 |
49 |
50 |

{labels.invoice}

51 | 52 |

 

53 |
54 |
55 |

{labels.worksCompleted}

56 | 57 |

 

58 | 59 | 60 | 61 | {provider.companyVatNo && || } 62 | {provider.companyVatNo && || } 63 | {provider.companyVatNo && || } 64 | 82 | 83 | 84 | {provider.companyVatNo && 85 | 86 | 87 | 88 | || } 89 | 90 |
{labels.vatBasis}{labels.vatRate}{labels.vatAmount} 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
{labels.total}{invoiceMeta.currency}{parseFloat(total).toFixed(2)}
{labels.vat}{invoiceMeta.currency}{parseFloat(provider.companyVatNo ? vatAmount : 0).toFixed(2)}
{labels.totalPayable}{invoiceMeta.currency}{parseFloat(totalVat).toFixed(2)}
81 |
{invoiceMeta.currency}{parseFloat(vatBasis).toFixed(2)}{invoiceMeta.vatRate}%{invoiceMeta.currency}{parseFloat(vatAmount).toFixed(2)}
91 |
92 |
93 | 105 |
106 | ) 107 | } 108 | } 109 | 110 | export default Invoice -------------------------------------------------------------------------------- /src/components/InvoiceMeta/index.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent, Fragment} from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | Grid, 5 | IconButton, 6 | TextField, 7 | Typography, 8 | } from '@mui/material' 9 | import InfoIcon from '@mui/icons-material/Info'; 10 | import labels from '../../translations' 11 | import {inputStyle, subtitleStyle} from '../../shared-styles' 12 | import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; 13 | 14 | export class InvoiceParty extends PureComponent { 15 | 16 | handleChange = (event) => { 17 | const {name, value} = event.target 18 | 19 | this.props.onUpdate(name, value) 20 | } 21 | 22 | handleChangeProvider = (event) => { 23 | const {name, value} = event.target 24 | 25 | this.props.onUpdateProvider(name, value) 26 | } 27 | 28 | render() { 29 | const {meta, provider, locked} = this.props 30 | 31 | return ( 32 | 33 | {labels.invoiceMeta} 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {labels.isClientVatRegistered} 60 | 61 | 62 | 63 | {labels.invoiceBranding} 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {labels.companyId} 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ) 87 | } 88 | } 89 | 90 | InvoiceParty.propTypes = { 91 | subject: PropTypes.object, 92 | subjectType: PropTypes.string, 93 | onUpdate: PropTypes.func, 94 | onUpdateProvider: PropTypes.func, 95 | onGenerateInvoiceNumber: PropTypes.func, 96 | } 97 | 98 | export default InvoiceParty -------------------------------------------------------------------------------- /src/components/Invoice/Invoice.css: -------------------------------------------------------------------------------- 1 | .Invoice { 2 | max-width: 900px; 3 | font-size: 14px; 4 | user-select: none; 5 | } 6 | 7 | @media screen { 8 | .Invoice { 9 | padding: 25px; 10 | } 11 | } 12 | 13 | @media print { 14 | .Invoice { 15 | padding: 0 15px; 16 | font-size: 11px; 17 | } 18 | } 19 | 20 | hr { 21 | border: 0 none; 22 | border-top: 1px solid transparent; 23 | border-bottom: 1px solid #ccc; 24 | } 25 | 26 | .Invoice-header { 27 | position: relative; 28 | } 29 | 30 | .Logo { 31 | text-transform: uppercase; 32 | font-weight: 200; 33 | font-size: 32px; 34 | cursor: pointer; 35 | user-select: none; 36 | margin: 0; 37 | } 38 | 39 | .Logo-part-1 { 40 | color: #666; 41 | } 42 | 43 | .Logo-part-2 { 44 | font-weight: 500; 45 | color: #000; 46 | 47 | } 48 | 49 | .Header-meta { 50 | position: absolute; 51 | top: 0; 52 | right: 0; 53 | font-size: 11px; 54 | line-height: 12px; 55 | text-align: right; 56 | } 57 | 58 | .Invoice-body { 59 | } 60 | 61 | .Invoice-subjects { 62 | padding-top: 10px; 63 | } 64 | 65 | .Invoice input { 66 | width: 100%; 67 | box-sizing: border-box; 68 | padding: 2px 5px; 69 | font-size: 14px; 70 | line-height: 14px; 71 | margin-top: 3px; 72 | border: 1px solid #ccc; 73 | border-radius: 2px; 74 | box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.1); 75 | } 76 | 77 | .Invoice input.Half-size { 78 | width: 50%; 79 | } 80 | 81 | .Invoice-subject small { 82 | text-transform: uppercase; 83 | font-weight: 200; 84 | } 85 | 86 | .Invoice-subject { 87 | display: inline-block; 88 | width: 50%; 89 | vertical-align: top; 90 | box-sizing: border-box; 91 | } 92 | 93 | .Subject-provider { 94 | padding-right: 15px; 95 | } 96 | 97 | .Subject-customer { 98 | padding-left: 15px; 99 | } 100 | 101 | .Invoice-the-invoice { 102 | } 103 | 104 | .Invoice-meta { 105 | } 106 | 107 | .Invoice-details { 108 | } 109 | 110 | .Invoice-footer { 111 | text-align: center; 112 | margin: 25px 0 0 0; 113 | font-size: 11px; 114 | white-space: nowrap; 115 | } 116 | 117 | @media print { 118 | .Invoice-footer { 119 | text-align: center; 120 | position: absolute; 121 | bottom: 0px; 122 | left: 25px; 123 | right: 25px; 124 | } 125 | } 126 | 127 | .Invoice-table { 128 | border-collapse: collapse; 129 | border-top: 1px solid #ccc; 130 | border-left: 1px solid #ccc; 131 | } 132 | 133 | .Invoice-table th { 134 | background-color: #f5f5f5; 135 | } 136 | 137 | .Invoice-table td, 138 | .Invoice-table th { 139 | border-bottom: 1px solid #ccc; 140 | border-right: 1px solid #ccc; 141 | line-height: 18px; 142 | padding: 5px 10px; 143 | } 144 | 145 | @media print { 146 | .Invoice-table td, 147 | .Invoice-table th { 148 | border-bottom: 1px solid #ccc; 149 | border-right: 1px solid #ccc; 150 | line-height: 14px; 151 | padding: 3px 7px; 152 | } 153 | 154 | } 155 | 156 | .Table-wide { 157 | width: 100%; 158 | } 159 | 160 | .Invoice-table .Table-totals { 161 | padding: 0; 162 | } 163 | 164 | .Invoice-table .Totals-table { 165 | width: 100%; 166 | border-collapse: collapse; 167 | border: 0 none; 168 | } 169 | 170 | .Invoice-table .Totals-table th { 171 | 172 | } 173 | 174 | .Invoice-table .Totals-table th, 175 | .Invoice-table .Totals-table td { 176 | border: 0 none; 177 | border-top: 1px solid #ccc; 178 | border-left: 1px solid #ccc; 179 | text-align: right; 180 | } 181 | 182 | .Invoice-table .Totals-table tr:first-child th, 183 | .Invoice-table .Totals-table tr:first-child td { 184 | border-top: none; 185 | } 186 | 187 | .Invoice-table .Totals-table th:first-child, 188 | .Invoice-table .Totals-table td:first-child { 189 | border-left: none; 190 | } 191 | 192 | .Col-qty { 193 | text-align: center; 194 | } 195 | 196 | .Col-rate { 197 | text-align: right; 198 | } 199 | 200 | .Col-total { 201 | text-align: right; 202 | } 203 | 204 | /* Invoice-history */ 205 | .Invoice-history { 206 | position: fixed; 207 | top: 0; 208 | right: 0; 209 | width: 356px; 210 | height: 100vh; 211 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); 212 | background-color: #333333; 213 | color: #fff; 214 | display: flex; 215 | flex-direction: column; 216 | } 217 | 218 | .Invoice-history-header { 219 | flex-grow: 0; 220 | padding: 10px; 221 | border-bottom: 1px solid #666; 222 | } 223 | 224 | .Invoice-history-header h2 { 225 | margin: 0; 226 | padding: 0; 227 | padding-bottom: 5px; 228 | font-size: 15px; 229 | } 230 | 231 | .Invoice-history-body { 232 | flex-grow: 1; 233 | overflow-y: auto; 234 | } 235 | 236 | .Invoice-history-button { 237 | border-radius: 2px; 238 | background-color: whitesmoke; 239 | color: #333; 240 | display: inline-block; 241 | padding: 3px 9px; 242 | cursor: pointer; 243 | } 244 | 245 | .Invoice-history-small-button { 246 | border-radius: 2px; 247 | background-color: whitesmoke; 248 | color: #333; 249 | display: inline-block; 250 | padding: 2px 5px; 251 | font-size: 12px; 252 | cursor: pointer; 253 | margin-top: 5px; 254 | } 255 | 256 | .Invoice-history-list { 257 | margin: 0; 258 | padding: 0; 259 | list-style: none; 260 | } 261 | 262 | .Invoice-history-item { 263 | margin: 0; 264 | padding: 0; 265 | padding: 10px; 266 | border-bottom: 1px solid #666; 267 | display: flex; 268 | flex-direction: row; 269 | } 270 | 271 | .Invoice-history-row-head { 272 | flex-grow: 1; 273 | } 274 | .Invoice-history-row-actions { 275 | flex-grow: 0; 276 | } -------------------------------------------------------------------------------- /src/redux/invoice/reducer.js: -------------------------------------------------------------------------------- 1 | import {types} from './actions' 2 | import invoiceConfig from '../../invoiceConfig' 3 | import {getActiveLang} from '../../translations' 4 | import {today} from '../../utils/invoice' 5 | 6 | export const defaultInvoiceEntry = { 7 | dateProvided: today(), 8 | description: '', 9 | qty: '1', 10 | qtyType: 'h', 11 | rate: '0', 12 | total: '0', 13 | } 14 | 15 | const defaultInvoiceState = { 16 | uuid: '', 17 | lang: getActiveLang() || 'en', 18 | provider: { 19 | companyName: invoiceConfig.provider.companyName, 20 | name: invoiceConfig.provider.name, 21 | role: invoiceConfig.provider.role, 22 | addressLine1: invoiceConfig.provider.addressLine1, 23 | addressLine2: invoiceConfig.provider.addressLine2, 24 | addressLine3: invoiceConfig.provider.addressLine3, 25 | addressLine4: invoiceConfig.provider.addressLine4, 26 | companyRegNo: invoiceConfig.provider.companyRegNo, 27 | companyVatNo: invoiceConfig.provider.companyVatNo, 28 | billingBankAccountIban: invoiceConfig.provider.billingBankAccountIban, 29 | billingBankAccountBic: invoiceConfig.provider.billingBankAccountBic, 30 | billingBankAccountNo: invoiceConfig.provider.billingBankAccountNo, 31 | billingBankAccountSortCode: invoiceConfig.provider.billingBankAccountSortCode, 32 | }, 33 | customer: { 34 | companyName: '', 35 | name: '', 36 | role: '', 37 | addressLine1: '', 38 | addressLine2: '', 39 | addressLine3: '', 40 | addressLine4: '', 41 | companyRegNo: '', 42 | companyVatNo: '', 43 | billingBankAccountIban: '', 44 | billingBankAccountBic: '', 45 | billingBankAccountNo: '', 46 | billingBankAccountSortCode: '', 47 | // ...invoiceConfig.customer 48 | }, 49 | invoiceMeta: { 50 | invoiceDate: today(), 51 | invoiceSeries: invoiceConfig.invoiceMeta.invoiceSeries || '', 52 | invoiceNo: 0, 53 | currency: invoiceConfig.invoiceMeta.currency, 54 | brandName: invoiceConfig.invoiceMeta.brandName, 55 | brandSubName: invoiceConfig.invoiceMeta.brandSubName, 56 | vatRate: invoiceConfig.invoiceMeta.vatRate, 57 | locked: false 58 | }, 59 | invoiceEntries: [ 60 | { 61 | dateProvided: today(), 62 | description: '', 63 | qty: '1', 64 | qtyType: 'days', 65 | rate: '0', 66 | total: '0', 67 | } 68 | ], 69 | } 70 | const defaultState = { 71 | invoice: {}, 72 | invoices: [], 73 | isLoading: false, 74 | displaySection: { 75 | provider: true, 76 | customer: true, 77 | customerPrefill: false, 78 | }, 79 | ...defaultInvoiceState 80 | } 81 | 82 | const selectAllCustomers = (invoices) => { 83 | const customers = invoices?.map((invoice) => { 84 | return invoice.customer 85 | })?.filter((customer) => customer.companyName !== '' && customer.addressLine1 !== '') 86 | 87 | customers?.reverse() 88 | 89 | let seen = {} 90 | const uniqueLatest = customers?.filter((customer) => { 91 | return seen[customer.companyName] ? false : (seen[customer.companyName] = true) 92 | }) 93 | 94 | return uniqueLatest 95 | } 96 | 97 | export const selectors = { 98 | state: (state) => state.invoice, 99 | isLoading: (state) => selectors.state(state).isLoading, 100 | error: (state) => selectors.state(state).error, 101 | invoiceEntries: (state) => selectors.state(state).invoiceEntries, 102 | displaySection: (state) => selectors.state(state).displaySection, 103 | invoiceMeta: (state) => selectors.state(state).invoiceMeta, 104 | provider: (state) => selectors.state(state).provider, 105 | customer: (state) => selectors.state(state).customer, 106 | uuid: (state) => selectors.state(state).uuid, 107 | invoice: (state) => ({ 108 | invoiceEntries: selectors.state(state).invoiceEntries, 109 | invoiceMeta: selectors.state(state).invoiceMeta, 110 | provider: selectors.state(state).provider, 111 | customer: selectors.state(state).customer, 112 | }), 113 | invoices: (state) => selectors.state(state).invoices, 114 | allCustomers: (state) => selectAllCustomers(selectors.state(state).invoices) 115 | } 116 | 117 | const setInvoice = (state, {invoice}) => ({ 118 | ...state, 119 | ...invoice, // rewrite the state values with sections of invoice 120 | }) 121 | 122 | const setLoading = (state, {isLoading}) => ({ 123 | ...state, 124 | isLoading 125 | }) 126 | 127 | const toggleSectionVisibility = (state, {section, display}) => ({ 128 | ...state, 129 | displaySection: { 130 | ...state.displaySection, 131 | [section]: display !== undefined ? display : !state.displaySection[section] 132 | } 133 | }) 134 | 135 | const setInvoices = (state, {invoices}) => ({ 136 | ...state, 137 | invoices 138 | }) 139 | 140 | const lockInvoice = (state, {}) => ({ 141 | ...state, 142 | invoiceMeta: { 143 | ...state.invoiceMeta, 144 | locked: !state.invoiceMeta.locked 145 | } 146 | }) 147 | 148 | const setInvoiceNo = (state, {invoiceNo}) => ({ 149 | ...state, 150 | invoiceMeta: { 151 | ...state.invoiceMeta, 152 | invoiceNo 153 | } 154 | }) 155 | 156 | const updateInvoiceSection = (state, {section, sectionData}) => ({ 157 | ...state, 158 | [section]: { 159 | ...state[section], 160 | ...sectionData 161 | } 162 | }) 163 | 164 | const updateInvoiceEntry = (state, {entryIndex, entryData}) => { 165 | const updatedEntries = [ 166 | ...state.invoiceEntries, 167 | ] 168 | 169 | updatedEntries[entryIndex] = {...entryData} 170 | 171 | return { 172 | ...state, 173 | invoiceEntries: [ 174 | ...updatedEntries 175 | ] 176 | } 177 | } 178 | 179 | const removeInvoiceEntry = (state, {entryIndex}) => { 180 | const invoiceEntries = [...state.invoiceEntries] 181 | 182 | invoiceEntries.splice(entryIndex, 1) 183 | 184 | return { 185 | ...state, 186 | invoiceEntries 187 | } 188 | } 189 | 190 | const addInvoiceEntry = (state, {entryData}) => ({ 191 | ...state, 192 | invoiceEntries: [ 193 | ...state.invoiceEntries, 194 | {...entryData} 195 | ] 196 | }) 197 | 198 | const startNewInvoice = (state, {}) => ({ 199 | ...state, 200 | ...defaultInvoiceState 201 | }) 202 | 203 | export default (state = defaultState, {type, payload}) => { 204 | switch (type) { 205 | case types.SET_LOADING: return setLoading(state, payload) 206 | case types.SET_INVOICES: return setInvoices(state, payload) 207 | case types.SET_INVOICE: return setInvoice(state, payload) 208 | case types.UPDATE_INVOICE_SECTION: return updateInvoiceSection(state, payload) 209 | case types.UPDATE_INVOICE_ENTRY: return updateInvoiceEntry(state, payload) 210 | case types.REMOVE_INVOICE_ENTRY: return removeInvoiceEntry(state, payload) 211 | case types.ADD_INVOICE_ENTRY: return addInvoiceEntry(state, payload) 212 | case types.LOCK_INVOICE: return lockInvoice(state, payload) 213 | case types.START_NEW_INVOICE: return startNewInvoice(state, payload) 214 | case types.TOGGLE_SECTION_VISIBILITY: return toggleSectionVisibility(state, payload) 215 | case types.SET_INVOICE_NUMBER: return setInvoiceNo(state, payload) 216 | default: return state 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/redux/invoice/sagas.js: -------------------------------------------------------------------------------- 1 | import {put, delay, takeLatest, select} from 'redux-saga/effects' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import actions, {types} from './actions' 4 | import {defaultInvoiceEntry, selectors as invoiceSelector} from './reducer' 5 | import {constructTitle, today} from "../../utils/invoice" 6 | 7 | const storageConfig = { 8 | INVOICES: 'invoicesHistory' 9 | } 10 | 11 | export const getStoredInvoices = function *({payload: {}}) { 12 | yield put(actions.setLoading(true)) 13 | 14 | try { 15 | const invoices = JSON.parse(localStorage.getItem(storageConfig.INVOICES)) 16 | 17 | yield put(actions.setInvoices(invoices)) 18 | } catch (error) { 19 | yield put(actions.setError(`${error?.status} ${error?.message}`)) 20 | } finally { 21 | yield delay(300) 22 | yield put(actions.setLoading(false)) 23 | } 24 | } 25 | 26 | export const getStoredInvoice = function *({payload: {uuid}}) { 27 | yield put(actions.setLoading(true)) 28 | 29 | try { 30 | const invoices = JSON.parse(localStorage.getItem(storageConfig.INVOICES)) 31 | const invoice = invoices.find((invoice, index) => (String(invoice.uuid) === String(uuid) || Number(uuid) === index)) 32 | 33 | document.title = constructTitle(invoice) 34 | 35 | yield put(actions.setInvoice(invoice)) 36 | } catch (error) { 37 | yield put(actions.setError(`${error?.status} ${error?.message}`)) 38 | } finally { 39 | yield delay(300) 40 | yield put(actions.setLoading(false)) 41 | } 42 | } 43 | 44 | export const lockInvoice = function *({payload: {uuid}}) { 45 | yield put(actions.setLoading(true)) 46 | 47 | try { 48 | const invoices = JSON.parse(localStorage.getItem(storageConfig.INVOICES)) 49 | const invoiceIndex = invoices.findIndex((invoice, index) => (String(invoice.uuid) === String(uuid))) 50 | invoices[invoiceIndex].invoiceMeta.locked = true 51 | 52 | localStorage.setItem(storageConfig.INVOICES, JSON.stringify(invoices)) 53 | yield put(actions.setInvoices(invoices)) 54 | 55 | } catch (error) { 56 | yield put(actions.setError(`${error?.status} ${error?.message}`)) 57 | } finally { 58 | yield delay(300) 59 | yield put(actions.setLoading(false)) 60 | } 61 | } 62 | 63 | export const deleteInvoice = function *({payload: {uuid}}) { 64 | yield put(actions.setLoading(true)) 65 | 66 | try { 67 | const invoices = JSON.parse(localStorage.getItem(storageConfig.INVOICES)) 68 | const invoiceIndex = invoices.findIndex((invoice, index) => (String(invoice.uuid) === String(uuid))) 69 | invoices.splice(invoiceIndex, 1) 70 | 71 | localStorage.setItem(storageConfig.INVOICES, JSON.stringify(invoices)) 72 | yield put(actions.setInvoices(invoices)) 73 | 74 | } catch (error) { 75 | yield put(actions.setError(`${error?.status} ${error?.message}`)) 76 | } finally { 77 | yield delay(300) 78 | yield put(actions.startNewInvoice()) 79 | yield put(actions.setLoading(false)) 80 | } 81 | } 82 | 83 | export const storeNewInvoice = function *({payload: {invoice}}) { 84 | yield put(actions.setLoading(true)) 85 | 86 | // generating ID for this new invoice 87 | const newUuid = uuidv4() 88 | 89 | try { 90 | const invoices = localStorage.getItem(storageConfig.INVOICES) ? JSON.parse(localStorage.getItem(storageConfig.INVOICES)) : [] 91 | 92 | if (invoice.uuid) { 93 | const invoiceIndex = invoices.findIndex((item, index) => (String(invoice.uuid) === String(item.uuid))) 94 | invoices[invoiceIndex] = {...invoice} 95 | } else { 96 | const uniqueInvoice = { 97 | ...invoice, 98 | uuid: newUuid 99 | } 100 | 101 | invoices.push(uniqueInvoice) 102 | } 103 | 104 | localStorage.setItem(storageConfig.INVOICES, JSON.stringify(invoices)) 105 | 106 | yield put(actions.setInvoices(invoices)) 107 | yield put(actions.getInvoice(newUuid)) 108 | } catch (error) { 109 | yield put(actions.setError(`${error?.status} ${error?.message}`)) 110 | } finally { 111 | yield delay(300) 112 | yield put(actions.setLoading(false)) 113 | } 114 | } 115 | 116 | export const generateInvoiceNumber = function *({payload: {}}) { 117 | try { 118 | const invoiceMeta = (yield select(invoiceSelector.invoiceMeta)) 119 | const invoices = (yield select(invoiceSelector.invoices)) 120 | const invoiceNums = invoices.filter(invoice => invoice.invoiceMeta.invoiceSeries === invoiceMeta.invoiceSeries).map(invoice => invoice.invoiceMeta.invoiceNo) 121 | const maxLength = Math.max(...invoiceNums.map(num => num.length)) 122 | const maxNum = Math.max(...invoiceNums.map(num => Number(num))) 123 | const zeroCount = Number(maxLength) - Number(String(maxNum).length) 124 | let zeroFill = '' 125 | 126 | for (let i=0; i 0) { 130 | yield put(actions.setInvoiceNo(`${zeroFill}${Number(maxNum)+1}`)) 131 | } else { 132 | yield put(actions.setInvoiceNo('0001')) 133 | } 134 | } catch (error) { 135 | yield put(actions.setInvoiceNo('0001')) 136 | } 137 | } 138 | 139 | export const copyLatestSupplierDetails = function *({payload: {}}) { 140 | try { 141 | const invoices = (yield select(invoiceSelector.invoices)) 142 | const invoiceMeta = (yield select(invoiceSelector.invoiceMeta)) 143 | const lastInvoice = invoices[invoices.length -1] 144 | 145 | const latestProviderDetails = {...lastInvoice.provider} 146 | const lastInvoiceMeta = lastInvoice.invoiceMeta 147 | 148 | const newInvoiceMeta = { 149 | ...invoiceMeta, 150 | invoiceDate: today(), 151 | invoiceSeries: lastInvoiceMeta.invoiceSeries, 152 | invoiceNo: 0, 153 | currency: lastInvoiceMeta.currency, 154 | brandName: lastInvoiceMeta.brandName, 155 | brandSubName: lastInvoiceMeta.brandSubName, 156 | vatRate: lastInvoiceMeta.vatRate, 157 | } 158 | 159 | yield put(actions.updateInvoiceSection('provider', latestProviderDetails)) 160 | yield put(actions.updateInvoiceSection('invoiceMeta', newInvoiceMeta)) 161 | } catch (error) { 162 | console.log('Could not get previous invoice...') 163 | } 164 | } 165 | 166 | export const startNewInvoice = function *({payload: {}}) { 167 | yield put(actions.copyLatestSupplierDetails()) 168 | yield delay(200) 169 | yield put(actions.generateInvoiceNumber()) 170 | } 171 | 172 | export const newInvoiceEntry = function *({payload: {}}) { 173 | // TODO: copy units from previous entry 174 | 175 | const newInvoiceEntry = { 176 | ...defaultInvoiceEntry, 177 | dateProvided: today(), 178 | } 179 | 180 | yield put(actions.addInvoiceEntry(newInvoiceEntry)) 181 | } 182 | 183 | export default [ 184 | takeLatest(types.GET_STORED_INVOICES, getStoredInvoices), 185 | takeLatest(types.GET_STORED_INVOICE, getStoredInvoice), 186 | takeLatest(types.STORE_NEW_INVOICE, storeNewInvoice), 187 | takeLatest(types.LOCK_INVOICE, lockInvoice), 188 | takeLatest(types.DELETE_INVOICE, deleteInvoice), 189 | takeLatest(types.GENERATE_INVOICE_NUMBER, generateInvoiceNumber), 190 | takeLatest(types.COPY_LATEST_SUPPLIER_DETAILS, copyLatestSupplierDetails), 191 | takeLatest(types.NEW_INVOICE_ENTRY, newInvoiceEntry), 192 | takeLatest(types.START_NEW_INVOICE, startNewInvoice), 193 | ] 194 | -------------------------------------------------------------------------------- /src/components/InvoiceForm/index.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent, Fragment} from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | Alert, 5 | Box, 6 | Divider, 7 | Grid, 8 | Button, 9 | Typography, 10 | } from '@mui/material' 11 | 12 | import connectWithRedux from '../../decorators/connectWithRedux' 13 | import invoiceActions from '../../redux/invoice/actions' 14 | import {selectors as invoiceSelector} from '../../redux/invoice/reducer' 15 | import InvoiceParty from "../InvoiceParty" 16 | import InvoiceMeta from "../InvoiceMeta" 17 | import InvoiceEntries from "../InvoiceEntries" 18 | import SaveIcon from '@mui/icons-material/Save' 19 | import DeleteForeverIcon from '@mui/icons-material/DeleteForever' 20 | import LockIcon from '@mui/icons-material/Lock' 21 | import LockResetIcon from '@mui/icons-material/LockReset' 22 | import VisibilityIcon from '@mui/icons-material/Visibility' 23 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' 24 | import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' 25 | import labels from "../../translations" 26 | 27 | @connectWithRedux((state) => ({ 28 | isLoading: invoiceSelector.isLoading(state), 29 | error: invoiceSelector.error(state), 30 | uuid: invoiceSelector.uuid(state), 31 | invoiceMeta: invoiceSelector.invoiceMeta(state), 32 | provider: invoiceSelector.provider(state), 33 | customer: invoiceSelector.customer(state), 34 | invoiceEntries: invoiceSelector.invoiceEntries(state), 35 | displaySection: invoiceSelector.displaySection(state), 36 | }), { 37 | newInvoiceEntry: invoiceActions.newInvoiceEntry, 38 | removeInvoiceEntry: invoiceActions.removeInvoiceEntry, 39 | updateInvoiceEntry: invoiceActions.updateInvoiceEntry, 40 | updateInvoiceSection: invoiceActions.updateInvoiceSection, 41 | storeNewInvoice: invoiceActions.storeNewInvoice, 42 | lockInvoice: invoiceActions.lockInvoice, 43 | deleteInvoice: invoiceActions.deleteInvoice, 44 | toggleSectionVisibility: invoiceActions.toggleSectionVisibility, 45 | generateInvoiceNumber: invoiceActions.generateInvoiceNumber, 46 | }) 47 | 48 | class StoredInvoicesList extends PureComponent { 49 | static propTypes = { 50 | isLoading: PropTypes.bool, 51 | getInvoices: PropTypes.func, 52 | getInvoice: PropTypes.func, 53 | storeNewInvoice: PropTypes.func, 54 | updateInvoiceSection: PropTypes.func, 55 | toggleSectionVisibility: PropTypes.func, 56 | newInvoiceEntry: PropTypes.func, 57 | updateInvoiceEntry: PropTypes.func, 58 | removeInvoiceEntry: PropTypes.func, 59 | generateInvoiceNumber: PropTypes.func, 60 | deleteInvoice: PropTypes.func, 61 | lockInvoice: PropTypes.func, 62 | invoices: PropTypes.array 63 | } 64 | 65 | handleInvoiceUpdate = (dataSection) => (subjectField, subjectValue) => { 66 | // TODO: move logic to reducer, refactor action to send only section, fieldName, value 67 | const updatedSectionData = { 68 | ...this.props[dataSection], 69 | [subjectField]: subjectValue 70 | } 71 | 72 | this.props.updateInvoiceSection(dataSection, updatedSectionData) 73 | } 74 | 75 | handleEntryUpdate = (entryIndex, entryRow) => { 76 | this.props.updateInvoiceEntry(entryIndex, entryRow) 77 | } 78 | 79 | handleEntryRemove = (entryIndex) => { 80 | this.props.removeInvoiceEntry(entryIndex) 81 | } 82 | 83 | handleInvoiceSave = () => { 84 | const {invoiceMeta, invoiceEntries, provider, customer, uuid} = this.props 85 | 86 | const newInvoice = { 87 | invoiceMeta, 88 | invoiceEntries, 89 | provider, 90 | customer, 91 | uuid, 92 | } 93 | 94 | this.props.storeNewInvoice(newInvoice) 95 | } 96 | 97 | handleInvoiceLock = (uuid) => () => { 98 | this.props.lockInvoice(uuid) 99 | } 100 | 101 | handleInvoiceDelete = (uuid) => () => { 102 | this.props.deleteInvoice(uuid) 103 | } 104 | 105 | handleSectionVisibility = (section) => () => { 106 | this.props.toggleSectionVisibility(section) 107 | } 108 | 109 | render = () => { 110 | const {isLoading} = this.props 111 | const {provider, customer, invoiceMeta, invoiceEntries, uuid, displaySection} = this.props 112 | const locked = invoiceMeta?.locked 113 | 114 | return ( 115 | 116 | {uuid && 117 | 118 | {labels.note}: {labels.editingExistingInvoice} ${uuid} 119 | 120 | 121 | } 122 | {!uuid && 123 | 124 | {labels.note}: {labels.creatingNewInvoice} 125 | 126 | 127 | } 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | {labels.provider} 136 | 137 | 138 | 139 | 147 | 148 | 149 | 150 | {displaySection.provider && 151 | 156 | } 157 | 158 | 159 | 160 | 161 | 162 | {labels.customer} 163 | 164 | 165 | 166 | 174 | 182 | 183 | 184 | 185 | {displaySection.customer && 186 | 191 | } 192 | 193 | 194 | 195 | 196 | 197 | 204 | 205 | 206 | 207 | 208 | 209 | 217 | 218 | 219 | 220 | {uuid && 221 | 222 | 223 | {labels.note}: {labels.editingExistingInvoice} ${uuid} 224 | 225 | 226 | } 227 | {!uuid && 228 | 229 | 230 | {labels.note}: {labels.creatingNewInvoice} 231 | 232 | 233 | } 234 | 235 | {!locked && 236 | 237 | 240 | 241 | } 242 | {uuid && 243 | 244 | 248 | 249 | } 250 | {uuid && !locked && 251 | 252 | 256 | 257 | } 258 | 259 | 260 | 261 | 262 | ) 263 | } 264 | } 265 | 266 | export default StoredInvoicesList 267 | --------------------------------------------------------------------------------