├── .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 | | {labels.invoiceDate} |
14 | {labels.invoiceNumber} |
15 |
16 |
17 |
18 |
19 | | {meta.invoiceDate} |
20 | {meta.invoiceSeries}{meta.invoiceNo} |
21 |
22 |
23 |
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 | | {labels.date} |
15 | {labels.description} |
16 | {labels.qty} |
17 | {labels.rate} |
18 | {labels.total} |
19 |
20 |
21 |
22 | {entries?.map((entry, index) => {
23 | return (
24 |
25 | | {entry.dateProvided} |
26 | {entry.description} |
27 | {entry.qty}{entry.qtyType} |
28 | {invoiceMeta.currency}{entry.rate}{entry.qtyType && `/${entry.qtyType}`} |
29 | {invoiceMeta.currency}{entry.total} |
30 |
31 | )
32 | })}
33 |
34 |
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 |
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 | 
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 | 
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 | } onClick={this.handleAdd}>
83 | {labels.addNew}
84 |
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 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
{labels.invoice}
51 |
52 |
53 |
54 |
55 |
{labels.worksCompleted}
56 |
57 |
58 |
59 |
60 |
61 | {provider.companyVatNo && | {labels.vatBasis} | || | }
62 | {provider.companyVatNo && {labels.vatRate} | || | }
63 | {provider.companyVatNo && {labels.vatAmount} | || | }
64 |
65 |
66 |
67 |
68 | | {labels.total} |
69 | {invoiceMeta.currency}{parseFloat(total).toFixed(2)} |
70 |
71 |
72 | | {labels.vat} |
73 | {invoiceMeta.currency}{parseFloat(provider.companyVatNo ? vatAmount : 0).toFixed(2)} |
74 |
75 |
76 | | {labels.totalPayable} |
77 | {invoiceMeta.currency}{parseFloat(totalVat).toFixed(2)} |
78 |
79 |
80 |
81 | |
82 |
83 |
84 | {provider.companyVatNo &&
85 | | {invoiceMeta.currency}{parseFloat(vatBasis).toFixed(2)} |
86 | {invoiceMeta.vatRate}% |
87 | {invoiceMeta.currency}{parseFloat(vatAmount).toFixed(2)} |
88 |
||
}
89 |
90 |
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 | :}
143 | onClick={this.handleSectionVisibility('provider')}
144 | >
145 | {displaySection.provider ? labels.hideForm : labels.showForm}
146 |
147 |
148 |
149 |
150 | {displaySection.provider &&
151 |
156 | }
157 |
158 |
159 |
160 |
161 |
162 | {labels.customer}
163 |
164 |
165 |
166 | }
170 | onClick={this.handleSectionVisibility('customerPrefill')}
171 | >
172 | {labels.prefillCustomer}
173 |
174 | :}
178 | onClick={this.handleSectionVisibility('customer')}
179 | >
180 | {displaySection.customer ? labels.hideForm : labels.showForm}
181 |
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 | } onClick={this.handleInvoiceSave}>
238 | {labels.saveInvoice}
239 |
240 |
241 | }
242 | {uuid &&
243 |
244 | : } onClick={this.handleInvoiceLock(uuid)}>
246 | {locked ? labels.unlock : labels.lock}
247 |
248 |
249 | }
250 | {uuid && !locked &&
251 |
252 |
256 |
257 | }
258 |
259 |
260 |
261 |
262 | )
263 | }
264 | }
265 |
266 | export default StoredInvoicesList
267 |
--------------------------------------------------------------------------------