├── .all-contributorsrc
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── .prettierrc.json
├── README.md
├── craco.config.js
├── package-lock.json
├── package.json
├── public
├── electron.js
├── favicon.png
├── icon.png
├── index.html
├── invoicify.ttf
├── logo.png
├── manifest.json
├── pdf.worker.js
├── printPdf.js
└── robots.txt
└── src
├── assets
└── no-net.mp4
├── components
├── Alert
│ └── index.js
├── CustomizationComponents
│ ├── LeftPanel
│ │ ├── index.js
│ │ └── index.scss
│ ├── MiddleSection
│ │ ├── index.js
│ │ └── index.scss
│ ├── RightPanel
│ │ ├── index.js
│ │ └── index.scss
│ └── index.js
├── Header
│ ├── HeaderRightSection
│ │ └── index.js
│ ├── index.js
│ └── index.scss
├── HoverTotal
│ ├── index.js
│ └── index.scss
├── ImportProducts
│ ├── index.js
│ └── index.scss
├── Invoice
│ ├── index.js
│ └── index.scss
├── InvoiceItems
│ ├── index.js
│ └── index.scss
├── InvoiceItemsTable
│ ├── index.js
│ └── index.scss
├── InvoicePageFooter
│ ├── index.js
│ └── index.scss
├── ListEmpty
│ └── index.js
├── LockScreen
│ ├── index.js
│ └── index.scss
├── ProductForm
│ ├── index.js
│ └── index.scss
├── ProductsPage
│ ├── index.js
│ └── index.scss
├── SetPasswordModal
│ ├── index.js
│ └── index.scss
├── Settings
│ ├── index.js
│ └── index.scss
├── UpdatePanel
│ ├── DownloadSection
│ │ └── index.js
│ ├── ReleaseNotes
│ │ └── index.js
│ ├── index.js
│ └── index.scss
└── index.js
├── contexts
├── AuthContext
│ └── index.js
├── InvoiceContext
│ └── index.js
└── index.js
├── index.js
├── index.scss
├── logo.svg
├── pages
├── CustomizationPage
│ ├── index.js
│ └── index.scss
├── HomePage
│ ├── index.js
│ └── index.scss
├── InvoiceSettings
│ ├── index.js
│ └── index.scss
└── index.js
├── services
├── apiService.js
├── dbService.js
├── nodeService.js
├── pdfService.js
└── settingsService.js
└── utils
├── constants.js
└── utils.js
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "files": [
3 | "README.md"
4 | ],
5 | "imageSize": 100,
6 | "commit": false,
7 | "contributors": [
8 | {
9 | "login": "aashutoshrathi",
10 | "name": "Aashutosh Rathi",
11 | "avatar_url": "https://avatars.githubusercontent.com/u/21199234?v=4",
12 | "profile": "http://aashutosh.dev",
13 | "contributions": [
14 | "code",
15 | "doc",
16 | "ideas"
17 | ]
18 | },
19 | {
20 | "login": "mohitkyadav",
21 | "name": "Mohit Kumar Yadav",
22 | "avatar_url": "https://avatars.githubusercontent.com/u/25580776?v=4",
23 | "profile": "https://www.only4.dev/",
24 | "contributions": [
25 | "code"
26 | "bug",
27 | "ideas",
28 | ]
29 | }
30 | ],
31 | "contributorsPerLine": 7,
32 | "projectName": "invoicify-app",
33 | "projectOwner": "2AMDevs",
34 | "repoType": "github",
35 | "repoHost": "https://github.com",
36 | "skipCi": true
37 | }
38 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # This file is for unifying the coding style for different editors and IDEs
2 | # editorconfig.org
3 | root = true
4 |
5 | [*]
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | indent_style = space
11 | indent_size = 2
12 |
13 | [*.md]
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | public/pdf.worker.js
2 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true
5 | },
6 | extends: ["airbnb"],
7 | globals: {
8 | Atomics: "readonly",
9 | SharedArrayBuffer: "readonly"
10 | },
11 | parser: "babel-eslint",
12 | parserOptions: {
13 | ecmaFeatures: {
14 | jsx: true
15 | },
16 | ecmaVersion: 2018,
17 | sourceType: "module"
18 | },
19 | plugins: ["react",],
20 | rules: {
21 | "array-bracket-spacing": [2, "never"],
22 | "brace-style": [2, "1tbs", { allowSingleLine: false }],
23 | "camelcase": [1, {
24 | "allow": ["^UNSAFE_"],
25 | "ignoreDestructuring": true,
26 | "properties": "always",
27 | }],
28 | "import/order": [
29 | "error",
30 | {
31 | "groups": ["builtin", "external", "internal"],
32 | "pathGroups": [
33 | {
34 | "pattern": "react",
35 | "group": "external",
36 | "position": "before"
37 | }
38 | ],
39 | "pathGroupsExcludedImportTypes": ["react"],
40 | "newlines-between": "always",
41 | "alphabetize": {
42 | "order": "asc",
43 | "caseInsensitive": true
44 | }
45 | }
46 | ],
47 | "comma-spacing": [1, { before: false, after: true }],
48 | "comma-style": [1, "last"],
49 | "computed-property-spacing": [1, "never"],
50 | "eol-last": [2, "always"],
51 | "func-names": [2, "always"],
52 | "react/prop-types": [0],
53 | "consistent-return": 0,
54 | "import/no-extraneous-dependencies": 0,
55 | "react/destructuring-assignment": [0],
56 | "react/jsx-equals-spacing": [2, "never"],
57 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
58 | "react/jsx-first-prop-new-line": [1, "multiline"],
59 | "react/jsx-tag-spacing": [1, {"beforeSelfClosing": "always"}],
60 | "react/jsx-indent": [2, 2],
61 | "react/jsx-indent-props": [2, 2],
62 | "arrow-parens": [2, "always"],
63 | "no-multi-spaces": ["error", { exceptions: { "BinaryExpression": true }, "ignoreEOLComments": true }],
64 | "react/jsx-max-props-per-line": [1, { "when": "always" }],
65 | "react/jsx-props-no-spreading": [0],
66 | "react/jsx-closing-bracket-location": [2, "tag-aligned"],
67 | "react/jsx-curly-brace-presence": [0, { "props": "never", "children": "never" }],
68 | "jsx-quotes": [2, "prefer-double"],
69 | "indent": [2, 2],
70 | "key-spacing": [1, { beforeColon: false, afterColon: true }],
71 | "new-cap": 2,
72 | "no-inline-comments": 2,
73 | "no-multiple-empty-lines": 2,
74 | "quotes": [2, "single", "avoid-escape"],
75 | "semi-spacing": [0, { before: false, after: true }],
76 | "semi": [2, "never"],
77 | "space-before-function-paren": ["error", {
78 | "anonymous": "always",
79 | "named": "always",
80 | "asyncArrow": "always"
81 | }],
82 | "space-unary-ops": [2, {"words": true, "nonwords": false}],
83 | "spaced-comment": ["error", "always", {
84 | "line": {
85 | "markers": ["/"],
86 | "exceptions": ["-", "+"]
87 | },
88 | "block": {
89 | "markers": ["!"],
90 | "exceptions": ["*"],
91 | "balanced": true
92 | }
93 | }]
94 | }
95 | };
96 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build/release
2 |
3 | on: push
4 |
5 | jobs:
6 | release:
7 | runs-on: ${{ matrix.os }}
8 |
9 | strategy:
10 | matrix:
11 | os: [windows-latest]
12 |
13 | steps:
14 | - name: Check out Git repository
15 | uses: actions/checkout@v1
16 |
17 | - name: Install Node.js, NPM and Yarn
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: 10
21 |
22 | - name: Build/release Electron app
23 | uses: samuelmeuli/action-electron-builder@v1
24 | env:
25 | CI: false
26 | REACT_APP_API_URL: ${{ secrets.INVOICIFY_API_URL }}
27 | REACT_APP_RELEASE_NOTES_API: ${{ secrets.RELEASE_NOTES_API }}
28 | GH_TOKEN: ${{ secrets.github_token }}
29 | with:
30 | # (No need to define this secret in the repo settings)
31 | github_token: ${{ secrets.github_token }}
32 |
33 | # If the commit is tagged with a version (e.g. "v1.0.0"),
34 | # release the app after building
35 | release: ${{ startsWith(github.ref, 'refs/tags/v') }}
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 | /dist
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 | public/print.pdf
26 | electron-builder.yml
27 | .env
28 | .vscode/
29 |
30 | dev-app-update.yml
31 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "trailingComma": "all",
4 | "singleQuote": true,
5 | "printWidth": 50
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Invoicify
8 |
9 |
10 |
11 | An application aimed to make it easier for SMEs to migrate to digital billing without throwing their exisiting bill books.
12 |
13 |
14 | [](https://github.com/2AMDevs/invoicify-app)
15 | [](https://github.com/2AMDevs/invoicify-app/releases)
16 | [](https://github.com/2AMDevs/invoicify-app/issues)
17 | [](https://github.com/2AMDevs/invoicify-app)
18 |
19 | [](#contributors-)
20 |
21 |
22 | [](https://discord.gg/UgvYpNrHa6)
23 |
24 |
25 | ## ✨ Features
26 |
27 | - 📦 Variety of Preferences for Application
28 | - ⚙️ Fields customization on print and in-app.
29 | - 🌍 Customizable Currency and State GST Code.
30 | - 🎨 Powerful theme customization in every detail.
31 | - 🌈 Native OS like UX
32 | - 🔐 Password Protection and Email OTP based Password Reset.
33 | - ⏬ Automatic In-app updates
34 | - 🔀 Import Products from CSV & Export to CSV.
35 |
36 | ## 🖥 Platform Support
37 |
38 | | 
Windows | 
Linux | 
MacOS |
39 | | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
40 | | [v0.4.13](https://github.com/2AMDevs/invoicify-app/releases/tag/v0.4.13) ✅ | 🚧 (Build WIP) | 🚧 (Build WIP) |
41 |
42 | ## ⌨️ Development
43 |
44 | - Clone this repo or create your own fork and then clone.
45 | - Create your own `.env`, `.electron-builder.yml`
46 |
47 | ```bash
48 | #.env
49 | GH_TOKEN=
50 | REACT_APP_API_URL=
51 | REACT_APP_RELEASE_NOTES_API=
52 | ```
53 |
54 | ```bash
55 | #electron-builder.yml
56 | appId: devs.2am.Invoicify
57 | publish:
58 | provider: github
59 | token:
60 | ```
61 |
62 | - To start development install dependencies using `npm i`
63 | - Now start development app using `npm run start`
64 |
65 | ## 🤝 Contributing [](https://github.com/2AMDevs/invoicify-app/compare)
66 |
67 | We welcome all contributions.
68 |
69 | - You can submit any ideas as Pull Request or Issues.
70 | - If you'd like to improve code, make sure you stick to exisiting practices in code.
71 |
72 |
73 |
74 |
75 | ## 💬 Support
76 |
77 | For any queries message us on Discord Server [here](https://discord.gg/UgvYpNrHa6)
78 |
79 | ## Contributors ✨
80 |
81 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
82 |
83 |
84 |
85 |
86 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
99 |
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | webpack: {
3 | configure: {
4 | target: 'electron-renderer',
5 | },
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "invoicify",
3 | "version": "0.4.15",
4 | "author": "2AM Devs",
5 | "description": "Digitalizes your billing process",
6 | "private": true,
7 | "main": "./public/electron.js",
8 | "build": {
9 | "appId": "invoicify.2am",
10 | "publish": [
11 | {
12 | "provider": "github",
13 | "owner": "2AMDevs",
14 | "repo": "invoicify-app"
15 | }
16 | ],
17 | "extraResources": [
18 | "./public/**"
19 | ],
20 | "win": {
21 | "icon": "./public/icon.png"
22 | },
23 | "mac": {
24 | "icon": "./public/icon.png",
25 | "category": "public.app-category.utilities"
26 | }
27 | },
28 | "repository": {
29 | "type": "git",
30 | "url": "https://github.com/2AMDevs/invoicify-app.git"
31 | },
32 | "homepage": "./",
33 | "dependencies": {
34 | "@craco/craco": "^5.6.4",
35 | "@pdf-lib/fontkit": "^1.0.0",
36 | "@testing-library/jest-dom": "^5.16.5",
37 | "@testing-library/react": "^9.5.0",
38 | "@testing-library/user-event": "^7.2.1",
39 | "@uifabric/icons": "^7.3.58",
40 | "classnames": "^2.2.6",
41 | "convert-rupees-into-words": "^1.0.6",
42 | "cross-env": "^7.0.2",
43 | "electron-is-dev": "^1.2.0",
44 | "electron-updater": "^6.3.0",
45 | "github-markdown-css": "^4.0.0",
46 | "node-sass": "^9.0.0",
47 | "office-ui-fabric-react": "^7.145.0",
48 | "pdf-lib": "^1.9.0",
49 | "pdf-to-printer": "^1.4.1",
50 | "react": "^16.13.1",
51 | "react-dom": "^16.13.1",
52 | "react-markdown": "^5.0.3",
53 | "react-pdf": "^5.1.0",
54 | "react-router-dom": "^7.5.2",
55 | "react-scripts": "5.0.1",
56 | "read-excel-file": "^5.8.4"
57 | },
58 | "scripts": {
59 | "react-start": "craco start",
60 | "react-build": "craco build",
61 | "react-test": "craco test",
62 | "react-eject": "craco eject",
63 | "electron-build": "electron-builder",
64 | "electron-deploy": "electron-builder build --win --publish always",
65 | "build": "npm run react-build && npm run electron-build",
66 | "ship": "npm run react-build && npm run electron-deploy",
67 | "lint": "node_modules/eslint/bin/eslint.js src/**/*.js --fix",
68 | "lint-win": "node_modules/.bin/eslint src/**/*.js --fix",
69 | "start": "concurrently \"cross-env BROWSER=none npm run react-start\" \"wait-on http://localhost:3000 && electron .\""
70 | },
71 | "eslintConfig": {
72 | "extends": [
73 | "eslint:recommended",
74 | "plugin:react/recommended"
75 | ]
76 | },
77 | "browserslist": {
78 | "production": [
79 | ">0.2%",
80 | "not dead",
81 | "not op_mini all"
82 | ],
83 | "development": [
84 | "last 1 chrome version",
85 | "last 1 firefox version",
86 | "last 1 safari version"
87 | ]
88 | },
89 | "devDependencies": {
90 | "concurrently": "^5.2.0",
91 | "electron": "^22.3.25",
92 | "electron-builder": "^24.13.3",
93 | "electron-reload": "^1.5.0",
94 | "eslint": "^6.6.0",
95 | "eslint-config-airbnb": "^18.2.0",
96 | "eslint-plugin-import": "^2.23.4",
97 | "wait-on": "^7.2.0"
98 | },
99 | "peerDependencies": {
100 | "eslint-plugin-import": "^2.22.1"
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/public/electron.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | const {
6 | app, BrowserWindow, Menu, screen, ipcMain, dialog, shell,
7 | } = require('electron')
8 | const isDev = require('electron-is-dev')
9 | const { autoUpdater } = require('electron-updater')
10 | const readXlsxFile = require('read-excel-file/node')
11 |
12 | const { print, getPrinters, getDefaultPrinter } = require('./printPdf')
13 |
14 | if (isDev) {
15 | // eslint-disable-next-line global-require
16 | require('electron-reload')(__dirname, {
17 | electron: path.join(process.cwd(), 'node_modules', '.bin', 'electron.cmd'),
18 | })
19 | }
20 |
21 | let win
22 |
23 | const createWindow = () => {
24 | Menu.setApplicationMenu(null)
25 | const { width, height } = screen.getPrimaryDisplay().workAreaSize
26 | win = new BrowserWindow({
27 | width,
28 | height,
29 | frame: false,
30 | resizable: false,
31 | webPreferences: {
32 | nodeIntegration: true,
33 | devTools: !!isDev,
34 | plugins: true,
35 | },
36 | })
37 |
38 | win.loadURL(
39 | isDev
40 | ? 'http://localhost:3000'
41 | : `file://${path.join(__dirname, '../build/index.html')}`,
42 | )
43 |
44 | win.webContents.on('new-window', (e, url) => {
45 | e.preventDefault()
46 | shell.openExternal(url)
47 | })
48 |
49 | // TODO: Add Tweak to open this when 7 press on Home button, so that we can debug prod
50 | if (isDev) win.webContents.openDevTools()
51 | }
52 |
53 | // This method will be called when Electron has finished
54 | // initialization and is ready to create browser windows.
55 | // Some APIs can only be used after this event occurs.
56 | // app.whenReady().then(createWindow)
57 | app.on('ready', () => {
58 | createWindow()
59 |
60 | if (!isDev) {
61 | autoUpdater.setFeedURL({
62 | provider: 'github',
63 | owner: '2AMDevs',
64 | repo: 'invoicify-app',
65 | token: process.env.GH_TOKEN,
66 | })
67 | autoUpdater.checkForUpdates()
68 | }
69 | })
70 |
71 | // Quit when all windows are closed, except on macOS. There, it's common
72 | // for applications and their menu bar to stay active until the user quits
73 | // explicitly with Cmd + Q.
74 | app.on('window-all-closed', () => {
75 | if (process.platform !== 'darwin') {
76 | app.quit()
77 | }
78 | })
79 |
80 | app.on('activate', () => {
81 | // On macOS it's common to re-create a window in the app when the
82 | // dock icon is clicked and there are no other windows open.
83 | if (BrowserWindow.getAllWindows().length === 0) {
84 | createWindow()
85 | }
86 | })
87 |
88 | // In this file you can include the rest of your app's specific main process
89 | // code. You can also put them in separate files and require them here.
90 |
91 | const getFilePath = async (fileFilters, disableAllFiles = false) => {
92 | const filters = [
93 | ...(fileFilters || []),
94 | ...(!disableAllFiles ? [{ name: 'All Files', extensions: ['*'] }] : []),
95 | ]
96 | const file = await dialog.showOpenDialog({ properties: ['openFile'], filters })
97 | if (file) {
98 | return file.filePaths[0]
99 | }
100 | }
101 |
102 | const saveFile = async (fileFilters, disableAllFiles = true, data, fileName) => {
103 | const filters = [
104 | ...(fileFilters || []),
105 | ...(!disableAllFiles ? [{ name: 'All Files', extensions: ['*'] }] : []),
106 | ]
107 | const file = await dialog.showSaveDialog({
108 | title: 'Select the File Path to Save',
109 | buttonLabel: 'Save',
110 | defaultPath: fileName,
111 | filters,
112 | })
113 | if (file) {
114 | if (!file.canceled) {
115 | fs.writeFileSync(
116 | file.filePath.toString(),
117 | data,
118 | (err) => {
119 | if (err) throw err
120 | },
121 | )
122 | }
123 | }
124 | }
125 |
126 | ipcMain.on('bye-bye', () => {
127 | win.close()
128 | })
129 |
130 | ipcMain.on('toggle-fullscreen', () => {
131 | win.setFullScreen(!win.isFullScreen())
132 | })
133 |
134 | ipcMain.on('shut-up', () => {
135 | win.minimize()
136 | })
137 |
138 | ipcMain.handle('app:version', () => app.getVersion())
139 | ipcMain.handle('file:select', (_event, filters, disableAllFiles) => getFilePath([filters], disableAllFiles))
140 | ipcMain.handle('file:save', (_event, filters, disableAllFiles, data, fileName) => saveFile([filters], disableAllFiles, data, fileName))
141 | ipcMain.handle('get-printers', getPrinters)
142 | ipcMain.handle('printers:get-default', getDefaultPrinter)
143 | ipcMain.handle('file:is-valid', (_event, args) => fs.existsSync(args))
144 | ipcMain.handle('printers:print', async (_e, pdfBytes, selectedPrinter) => print(pdfBytes, selectedPrinter))
145 |
146 | ipcMain.handle('file:excel-to-json', async (_event, filters) => {
147 | const file = await getFilePath([filters])
148 | if (file) {
149 | return readXlsxFile(file)
150 | .then((rows) => rows)
151 | .catch(console.error)
152 | }
153 | })
154 |
155 | ipcMain.handle('file:read-buffer', async (_, filePath) => fs.readFileSync(filePath))
156 |
157 | ipcMain.handle('file:read-b64', async (_, filePath) => fs.readFileSync(filePath).toString('base64'))
158 |
159 | ipcMain.on('restart_app', () => {
160 | autoUpdater.quitAndInstall()
161 | })
162 |
163 | if (!isDev) {
164 | autoUpdater.on('update-available', (info) => {
165 | win.webContents.send('update:available', info)
166 | })
167 |
168 | autoUpdater.on('update-not-available', (info) => {
169 | win.webContents.send('update:notAvailable', info)
170 | })
171 |
172 | autoUpdater.on('download-progress', (progress) => {
173 | win.webContents.send('update:progress', progress)
174 | })
175 |
176 | autoUpdater.on('update-downloaded', (info) => {
177 | win.webContents.send('update:downloaded', info)
178 | })
179 | }
180 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/2AMDevs/invoicify-app/fa9997f7526aa1ad59047aeded8a2f174b0ac99a/public/favicon.png
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/2AMDevs/invoicify-app/fa9997f7526aa1ad59047aeded8a2f174b0ac99a/public/icon.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 | Invoicify
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/public/invoicify.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/2AMDevs/invoicify-app/fa9997f7526aa1ad59047aeded8a2f174b0ac99a/public/invoicify.ttf
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/2AMDevs/invoicify-app/fa9997f7526aa1ad59047aeded8a2f174b0ac99a/public/logo.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Invoicify",
3 | "name": "Invoicify App",
4 | "icons": [
5 | {
6 | "src": "favicon.png",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "logo.png",
12 | "type": "image/png",
13 | "sizes": "512x512"
14 | }
15 | ],
16 | "start_url": ".",
17 | "display": "standalone",
18 | "theme_color": "#000000",
19 | "background_color": "#ffffff"
20 | }
21 |
--------------------------------------------------------------------------------
/public/printPdf.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const fs = require('fs')
3 | const os = require('os')
4 | const path = require('path')
5 |
6 | const ptp = require('pdf-to-printer')
7 |
8 | /**
9 | * returns list of printers present on device
10 | */
11 | const getPrinters = async () => ptp.getPrinters()
12 |
13 | /**
14 | * returns default printer
15 | */
16 | const getDefaultPrinter = async () => ptp.getDefaultPrinter()
17 |
18 | /**
19 | * Print pdfBytes with selectedPrinter
20 | * @param {Uint8Array} pdfBytes PDF Content in Uint8 format
21 | * @param {string} selectedPrinter Name of Printer selected
22 | * @returns {boolean} true if printed successfully else false
23 | */
24 | const print = async (pdfBytes, selectedPrinter) => {
25 | const printer = selectedPrinter ?? await getDefaultPrinter()
26 | const filePath = path.join(os.tmpdir(), 'print.pdf')
27 | fs.writeFile(filePath, pdfBytes, () => {})
28 | try {
29 | await ptp.print(filePath, { printer })
30 | return true
31 | } catch (e) {
32 | return false
33 | }
34 | }
35 |
36 | module.exports = {
37 | print,
38 | getPrinters,
39 | getDefaultPrinter,
40 | }
41 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/assets/no-net.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/2AMDevs/invoicify-app/fa9997f7526aa1ad59047aeded8a2f174b0ac99a/src/assets/no-net.mp4
--------------------------------------------------------------------------------
/src/components/Alert/index.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { useId } from '@uifabric/react-hooks'
4 | import { PrimaryButton } from 'office-ui-fabric-react/lib/Button'
5 | import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'
6 |
7 | const dialogStyles = { main: { maxWidth: 450 } }
8 |
9 | const Alert = ({
10 | hide, title, subText, setHideAlert,
11 | }) => {
12 | const labelId = useId('dialogLabel')
13 | const subTextId = useId('subTextLabel')
14 |
15 | const dialogContentProps = {
16 | title,
17 | subText,
18 | type: DialogType.largeHeader,
19 | }
20 |
21 | const modalProps = React.useMemo(
22 | () => ({
23 | titleAriaId: labelId,
24 | subtitleAriaId: subTextId,
25 | isBlocking: false,
26 | styles: dialogStyles,
27 | }),
28 | [labelId, subTextId],
29 | )
30 |
31 | return (
32 |
45 | )
46 | }
47 |
48 | export default Alert
49 |
--------------------------------------------------------------------------------
/src/components/CustomizationComponents/LeftPanel/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import './index.scss'
4 |
5 | const LeftPanel = ({ items, setSelectedItem }) => (
6 |
7 |
8 | {items.map((item, idx) => (
9 |
18 | ))}
19 |
20 |
21 | )
22 |
23 | export default LeftPanel
24 |
--------------------------------------------------------------------------------
/src/components/CustomizationComponents/LeftPanel/index.scss:
--------------------------------------------------------------------------------
1 | .left-panel {
2 | display: flex;
3 | flex-direction: column;
4 | background-color: var(--color-dark);
5 | box-shadow: 0 0.3rem 0.6rem 0
6 | var(--color-black-op-20);
7 | width: 22%;
8 | overflow: hidden scroll;
9 | scrollbar-width: none;
10 |
11 | &::-webkit-scrollbar {
12 | display: none;
13 | }
14 |
15 | &__item {
16 | background: transparent;
17 | border: none;
18 | display: block;
19 | width: 100%;
20 | text-align: left;
21 | font-size: 1.8rem;
22 | font-weight: 600;
23 | line-height: 1.36;
24 | letter-spacing: 0.048rem;
25 | color: var(--ice-white);
26 | white-space: nowrap;
27 | overflow: hidden;
28 | text-overflow: ellipsis;
29 | border: none;
30 | border-bottom: solid 0.1rem
31 | var(--color-darker);
32 | will-change: background-color;
33 | transition: background-color 0.2s ease-out;
34 | cursor: pointer;
35 |
36 | &:focus {
37 | background-color: var(--color-darker);
38 | outline: none;
39 | }
40 |
41 | p {
42 | padding: 3rem 3.2rem;
43 | white-space: nowrap;
44 | overflow: hidden;
45 | max-width: 100%;
46 | text-overflow: ellipsis;
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/CustomizationComponents/MiddleSection/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { TextField, Toggle, Dropdown } from 'office-ui-fabric-react'
4 |
5 | import {
6 | fieldTypes, MASKED,
7 | } from '../../../utils/constants'
8 |
9 | import './index.scss'
10 | import { round } from '../../../utils/utils'
11 |
12 | const MiddleSection = ({
13 | setting, handleChange, idx,
14 | }) => {
15 | if (!setting) {
16 | return (
17 |
18 | Select a field from left panel to start customizing the invoice.
19 |
20 | )
21 | }
22 |
23 | return (
24 |
25 |
26 | handleChange(idx, 'name', val)}
29 | value={setting.name}
30 | disabled={setting.disableNameChange}
31 | />
32 | handleChange(idx, 'row', val)}
35 | value={setting.row}
36 | />
37 |
38 |
39 | handleChange(idx, 'required', val)}
43 | />
44 | handleChange(idx, 'disabled', val)}
48 | />
49 |
50 |
51 | handleChange(idx, 'x', val)}
54 | value={round(setting.x, 2)}
55 | />
56 | handleChange(idx, 'y', val)}
59 | value={round(setting.y, 2)}
60 | />
61 |
62 |
63 |
handleChange(idx, 'type', val.key)}
69 | />
70 | handleChange(idx, 'size', val)}
73 | value={setting.size}
74 | />
75 |
76 | {setting.type === MASKED
77 | ? (
78 | handleChange(idx, 'mask', val)}
82 | />
83 | ) : ''}
84 |
85 | )
86 | }
87 |
88 | export default MiddleSection
89 |
--------------------------------------------------------------------------------
/src/components/CustomizationComponents/MiddleSection/index.scss:
--------------------------------------------------------------------------------
1 | .middle-section {
2 | padding: 5rem;
3 | color: var(--ice-white);
4 |
5 | &__not-selected {
6 | padding: 5rem;
7 | text-align: center;
8 | font-size: 2rem;
9 | letter-spacing: 0.1rem;
10 | font-weight: 300;
11 | opacity: 0.5;
12 | margin-top: 20rem;
13 | margin-left: 20rem;
14 | }
15 |
16 | &__row {
17 | display: flex;
18 | align-items: center;
19 | margin-bottom: 2.4rem;
20 |
21 | & > div {
22 | &:not(:last-child) {
23 | margin-right: 1rem;
24 | }
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/CustomizationComponents/RightPanel/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 |
3 | import cn from 'classnames'
4 | import { Icon } from 'office-ui-fabric-react'
5 | import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'
6 | import { Document, Page } from 'react-pdf'
7 |
8 | import { getPdf } from '../../../services/pdfService'
9 | import { defaultPageSettings, PREVIEW } from '../../../utils/constants'
10 | import './index.scss'
11 |
12 | const scale = 0.75
13 | const offsets = {
14 | x: 8,
15 | y: defaultPageSettings.height - 66,
16 | }
17 |
18 | const RightPanel = ({ selectedItem, idx, handleChange }) => {
19 | const [pos, setPos] = useState({ x: 0, y: 0, changeRoot: false })
20 | const [pdfData, setPdfBytes] = useState('')
21 |
22 | const previewPDF = () => {
23 | getPdf({}, PREVIEW)
24 | .then((pdfBytes) => {
25 | if (pdfBytes?.error) {
26 | return
27 | }
28 | setPdfBytes(pdfBytes)
29 | })
30 | }
31 |
32 | useEffect(() => {
33 | previewPDF()
34 | }, [])
35 |
36 | const heightDragHandle = selectedItem?.size ?? 30
37 | const widthDragHandle = 60
38 |
39 | useEffect(() => {
40 | setPos({
41 | x: selectedItem ? (selectedItem.x * scale) - offsets.x : 0,
42 | y: selectedItem ? ((offsets.y - selectedItem.y) * scale) : 0,
43 | changeRoot: false,
44 | })
45 | }, [selectedItem, idx])
46 |
47 | useEffect(() => {
48 | if (idx !== undefined && pos.changeRoot) {
49 | handleChange(idx, 'x', offsets.x + (pos.x / scale))
50 | handleChange(idx, 'y', (offsets.y - (pos.y / scale)))
51 | }
52 | // eslint-disable-next-line
53 | }, [pos])
54 |
55 | const dragOver = (e) => {
56 | e.preventDefault()
57 | e.dataTransfer.dropEffect = 'move'
58 | }
59 |
60 | const handleDragEnd = (e) => {
61 | const rect = document.getElementById('drop-target').getBoundingClientRect()
62 | const posX = e.clientX - rect.left
63 | const posY = e.clientY - rect.top - (heightDragHandle / 2)
64 | const x = posX < 0 ? 0 : posX
65 | const y = posY < 0 ? 0 : posY
66 | setPos({
67 | x: Math.min(x, rect.width - widthDragHandle),
68 | y: Math.min(y, rect.height - heightDragHandle),
69 | changeRoot: true,
70 | })
71 | }
72 |
73 | return (
74 |
79 |
80 | Set position of the Field on invoice
81 |
82 |
87 | {pdfData?.length > 0 && (
88 |
95 | )}
96 | >
97 |
101 |
102 | )}
103 |
116 |
120 |
121 |
122 |
123 | )
124 | }
125 |
126 | export default RightPanel
127 |
--------------------------------------------------------------------------------
/src/components/CustomizationComponents/RightPanel/index.scss:
--------------------------------------------------------------------------------
1 | .right-panel {
2 | width: 30%;
3 | background-color: var(--color-dark);
4 | transform: scale(1.4) translateX(100%);
5 | will-change: transform;
6 | transition: transform 0.5s ease-out;
7 | padding: 1rem 3rem;
8 |
9 | &--active {
10 | transform: scale(1) translateX(0);
11 | box-shadow: 0 0.3rem 0.6rem 0
12 | var(--color-black-op-20);
13 | }
14 |
15 | &__header {
16 | font-size: 2.4rem;
17 | font-weight: 800;
18 | font-stretch: normal;
19 | font-style: normal;
20 | line-height: 1.38;
21 | letter-spacing: 0.096rem;
22 | text-align: left;
23 | color: var(--ice-white);
24 | }
25 |
26 | &__preview {
27 | position: relative;
28 |
29 | &__handle {
30 | position: absolute;
31 | cursor: move;
32 | cursor: grab;
33 | cursor: -moz-grab;
34 | cursor: -webkit-grab;
35 | width: 13rem;
36 | height: 4rem;
37 | border-radius: 0.8rem;
38 | box-shadow: 0 0.3rem 0.6rem 0
39 | var(--color-black-op-20);
40 | background-color: var(--purple);
41 |
42 | &--icn {
43 | padding: 0.2rem;
44 | }
45 | }
46 |
47 | .react-pdf__Document {
48 | width: 100%;
49 | margin-top: 3.8rem;
50 | border-radius: 1.2rem;
51 | border: dashed 0.4rem var(--ice-white);
52 | background-color: var(--color-white-op-20);
53 |
54 | .react-pdf__Page {
55 | width: 100% !important;
56 | height: auto !important;
57 |
58 | canvas {
59 | width: 100% !important;
60 | height: auto !important;
61 | }
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/CustomizationComponents/index.js:
--------------------------------------------------------------------------------
1 | import LeftPanel from './LeftPanel'
2 | import MiddleSection from './MiddleSection'
3 | import RightPanel from './RightPanel'
4 |
5 | export {
6 | LeftPanel,
7 | MiddleSection,
8 | RightPanel,
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/Header/HeaderRightSection/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useCallback } from 'react'
2 |
3 | import { CommandBarButton } from 'office-ui-fabric-react'
4 | import { Panel } from 'office-ui-fabric-react/lib/Panel'
5 | import { useHistory } from 'react-router-dom'
6 |
7 | import { useAuthContext } from '../../../contexts'
8 | import { getProducts } from '../../../services/dbService'
9 | import { minimizeApp, quitApp, toggleFullScreen } from '../../../services/nodeService'
10 | import ProductsPage from '../../ProductsPage'
11 | import Settings from '../../Settings'
12 | import UpdatesPanel from '../../UpdatePanel'
13 |
14 | const HeaderRightSection = ({ refreshCompanyName }) => {
15 | const history = useHistory()
16 | const [authState, updateAuthState] = useAuthContext()
17 |
18 | const [productsCount, setProductsCount] = useState(getProducts()?.length)
19 |
20 | const [isProductsOpen, setIsProductsOpen] = useState(false)
21 |
22 | const [isSettingsOpen, setIsSettingsOpen] = useState(false)
23 |
24 | const [isUpdateOpen, setIsUpdateOpen] = useState(false)
25 |
26 | const openProductsPanel = useCallback(() => setIsProductsOpen(true), [])
27 |
28 | const dismissProductsPanel = useCallback(() => setIsProductsOpen(false), [])
29 |
30 | const openSettingsPanel = useCallback(() => setIsSettingsOpen(true), [])
31 |
32 | const dismissSettingsPanel = useCallback(() => setIsSettingsOpen(false), [])
33 |
34 | const openUpdatePanel = useCallback(() => setIsUpdateOpen(true), [])
35 | const dismissUpdatePanel = useCallback(() => setIsUpdateOpen(false), [])
36 |
37 | const refreshProductsCount = () => {
38 | setProductsCount(getProducts().length || 0)
39 | }
40 |
41 | const reloadStuff = () => history.push('/')
42 |
43 | const lockIt = () => {
44 | updateAuthState({ isAuthenticated: false })
45 | if (window.location.hash !== '#/') reloadStuff()
46 | }
47 |
48 | useEffect(() => {
49 | const keyDownHandler = (e) => {
50 | if (e.ctrlKey) {
51 | const { key, repeat } = e
52 | if (repeat) return
53 | if (key.toLowerCase() === 'l') lockIt()
54 | }
55 | }
56 | document.addEventListener('keydown', keyDownHandler, true)
57 | return () => document.removeEventListener('keydown', keyDownHandler, true)
58 | })
59 |
60 | return (
61 |
62 | {authState.isAuthenticated && (
63 |
69 | )}
70 | {authState.isAuthenticated && (
71 |
76 | )}
77 | {authState.isAuthenticated && (
78 |
83 | )}
84 | {localStorage.version && (
85 |
91 | )}
92 |
97 |
102 |
107 |
115 |
116 |
117 |
126 |
127 |
128 |
137 | {
140 | dismissSettingsPanel()
141 | reloadStuff()
142 | }}
143 | />
144 |
145 |
146 | )
147 | }
148 |
149 | export default HeaderRightSection
150 |
--------------------------------------------------------------------------------
/src/components/Header/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import cn from 'classnames'
4 | import {
5 | CommandBarButton,
6 | } from 'office-ui-fabric-react'
7 | import { Text } from 'office-ui-fabric-react/lib/Text'
8 | import { Link } from 'react-router-dom'
9 |
10 | import { useAuthContext } from '../../contexts'
11 | import { getFromStorage } from '../../services/dbService'
12 | import HeaderRightSection from './HeaderRightSection'
13 | import './index.scss'
14 |
15 | const Header = ({ className, ...restProps }) => {
16 | const [authState] = useAuthContext()
17 | const [companyName, setCompanyName] = useState(getFromStorage('companyName'))
18 |
19 | const refreshCompanyName = () => setCompanyName(getFromStorage('companyName'))
20 |
21 | return (
22 |
23 |
27 | {authState.isAuthenticated && (
28 |
29 |
33 |
39 |
40 |
44 |
50 |
51 | {getFromStorage('enableBeta') && (
52 |
56 |
62 |
63 | )}
64 |
65 | )}
66 |
72 | {companyName}
73 |
74 |
75 |
76 |
77 | )
78 | }
79 |
80 | export default Header
81 |
--------------------------------------------------------------------------------
/src/components/Header/index.scss:
--------------------------------------------------------------------------------
1 | .header-container {
2 | width: 100%;
3 | position: fixed;
4 | z-index: 1;
5 | top: 0;
6 | }
7 |
8 | .header {
9 | display: flex;
10 | flex-direction: row;
11 | justify-content: space-between;
12 | font-size: 2rem;
13 | background-color: var(--color-bg);
14 | height: 5rem;
15 | position: relative;
16 |
17 | &::after {
18 | content: '';
19 | position: absolute;
20 | bottom: 0;
21 | width: 100%;
22 | height: 0.2rem;
23 | background: var(--after-header-gradient);
24 | }
25 |
26 | &__left-section {
27 | display: flex;
28 | }
29 |
30 | &__right-section {
31 | display: flex;
32 |
33 | &__products-panel,
34 | &__settings-panel {
35 | .ms-Panel-main {
36 | width: 50rem;
37 | }
38 |
39 | &__header {
40 | padding-left: 1rem;
41 | }
42 |
43 | .settings {
44 | padding: 1rem;
45 | }
46 | }
47 |
48 | &__update-panel {
49 | .ms-Panel-main {
50 | width: 40rem;
51 | }
52 | }
53 | }
54 |
55 | &__link {
56 | text-decoration: none;
57 | height: 100%;
58 | display: flex;
59 | align-items: center;
60 |
61 | &__btn {
62 | height: 100%;
63 | padding: 0 0.5rem;
64 |
65 | &__exit:hover {
66 | background-color: var(--color-red-exit);
67 | }
68 |
69 | &__exit:hover > span > i {
70 | color: var(--color-white) !important;
71 | }
72 |
73 | &__mini:hover > span > i {
74 | color: var(--color-white) !important;
75 | }
76 | }
77 | }
78 | }
79 |
80 | .companyName {
81 | color: var(--color-white);
82 | padding: 0.5em;
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/HoverTotal/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { DefaultButton } from 'office-ui-fabric-react'
4 | import { TextField } from 'office-ui-fabric-react/lib/TextField'
5 |
6 | import { PAY_METHOD } from '../../utils/constants'
7 | import { titleCase } from '../../utils/utils'
8 | import './index.scss'
9 |
10 | const HoverTotal = ({ hoverCard, invoiceFooter, updateInvoiceFooter }) => {
11 | const dismissCard = () => {
12 | if (hoverCard.current) hoverCard.current.dismiss()
13 | }
14 |
15 | return (
16 |
20 | {Object.values(PAY_METHOD).map((key, idx) => (
21 | updateInvoiceFooter({ [key]: val })}
32 | />
33 | ))}
34 |
39 |
40 | )
41 | }
42 |
43 | export default HoverTotal
44 |
--------------------------------------------------------------------------------
/src/components/HoverTotal/index.scss:
--------------------------------------------------------------------------------
1 | .hover-card {
2 | display: 'flex';
3 | align-items: 'center';
4 | justify-content: 'center';
5 | padding: 1rem;
6 |
7 | &__submmit-btn {
8 | margin-top: 1rem;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/ImportProducts/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { useBoolean, useId } from '@uifabric/react-hooks'
4 | import { CommandBarButton } from 'office-ui-fabric-react'
5 | import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'
6 | import { Dialog, DialogFooter, DialogType } from 'office-ui-fabric-react/lib/Dialog'
7 | import { DirectionalHint, TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'
8 |
9 | import { setProducts } from '../../services/dbService'
10 | import { SELECT_FILE_TYPE } from '../../utils/constants'
11 | import { makeHash } from '../../utils/utils'
12 | import './index.scss'
13 |
14 | const dialogContentProps = {
15 | type: DialogType.normal,
16 | title: 'Save new Products?',
17 | closeButtonAriaLabel: 'Close',
18 | subText: 'Do you want to append new products or replace current products?',
19 | }
20 |
21 | const ImportProducts = ({ refreshProductItems }) => {
22 | const [newProducts, setNewProducts] = useState([])
23 |
24 | const [hideDialog, { toggle: toggleHideDialog }] = useBoolean(true)
25 |
26 | const labelId = useId('dialogLabel')
27 |
28 | const subTextId = useId('subTextLabel')
29 |
30 | const modalProps = React.useMemo(
31 | () => ({
32 | titleAriaId: labelId,
33 | subtitleAriaId: subTextId,
34 | isBlocking: true,
35 | }),
36 | [labelId, subTextId],
37 | )
38 |
39 | const handleImportBtnClick = () => {
40 | // eslint-disable-next-line global-require
41 | const { ipcRenderer } = require('electron')
42 | ipcRenderer.invoke('file:excel-to-json', SELECT_FILE_TYPE.EXCEL).then((res) => {
43 | const newLocalProducts = res?.map((item) => ({
44 | name: item[0],
45 | type: item[1],
46 | price: item[2],
47 | id: makeHash(),
48 | }))
49 | if (newLocalProducts?.length) {
50 | setNewProducts(newLocalProducts)
51 | toggleHideDialog()
52 | }
53 | // eslint-disable-next-line no-console
54 | }).catch(console.error)
55 | }
56 |
57 | const saveNewProducts = (replace) => {
58 | setProducts([...newProducts], replace)
59 | setNewProducts([])
60 | toggleHideDialog()
61 | if (refreshProductItems) refreshProductItems()
62 | }
63 |
64 | return (
65 |
66 |
72 |
80 |
81 |
98 |
99 | )
100 | }
101 |
102 | export default ImportProducts
103 |
--------------------------------------------------------------------------------
/src/components/ImportProducts/index.scss:
--------------------------------------------------------------------------------
1 | .import-products {
2 | &__btn {
3 | height: 5rem;
4 | font-size: 1.7rem;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/Invoice/index.js:
--------------------------------------------------------------------------------
1 | import React, {
2 | useCallback, useEffect, useRef, useState,
3 | } from 'react'
4 |
5 | import { useConstCallback } from '@uifabric/react-hooks'
6 | import { CommandBarButton, DatePicker } from 'office-ui-fabric-react'
7 | import { HoverCard, HoverCardType } from 'office-ui-fabric-react/lib/HoverCard'
8 | import { Panel } from 'office-ui-fabric-react/lib/Panel'
9 | import { Stack } from 'office-ui-fabric-react/lib/Stack'
10 | import { MaskedTextField, TextField } from 'office-ui-fabric-react/lib/TextField'
11 | import { Toggle } from 'office-ui-fabric-react/lib/Toggle'
12 |
13 | import { useInvoiceContext } from '../../contexts'
14 | import { currency, getFromStorage, getProducts } from '../../services/dbService'
15 | import { getPdf, printPDF } from '../../services/pdfService'
16 | import { getInvoiceSettings } from '../../services/settingsService'
17 | import {
18 | DATE, defaultPrintSettings, ISET, MASKED, PAY_METHOD, PREVIEW, PRINT, ZERO,
19 | } from '../../utils/constants'
20 | import {
21 | makeHash, groupBy, incrementor, parseDate,
22 | } from '../../utils/utils'
23 | import Alert from '../Alert'
24 | import HoverTotal from '../HoverTotal'
25 | import InvoiceItems from '../InvoiceItems'
26 | import InvoiceItemsTable from '../InvoiceItemsTable'
27 | import InvoicePageFooter from '../InvoicePageFooter'
28 | import './index.scss'
29 |
30 | const deviceWidth = document.documentElement.clientWidth
31 | const stackTokens = { childrenGap: 15 }
32 |
33 | const columnProps = {
34 | tokens: { childrenGap: deviceWidth * 0.05 },
35 | styles: { root: { width: deviceWidth * 0.3, display: 'flex', justifyContent: 'space-between' } },
36 | }
37 |
38 | const PdfPathError = {
39 | title: 'Error in Preview PDF Path',
40 | subText: 'Go to Settings -> Check if path to Bill PDF is valid.',
41 | }
42 |
43 | const Invoice = ({ showPdfPreview }) => {
44 | const [invoiceState, updateInvoiceState] = useInvoiceContext()
45 |
46 | const stableInvoiceUpdate = useCallback(
47 | updateInvoiceState,
48 | [],
49 | )
50 |
51 | const [invoiceItems, setInvoiceItems] = useState(invoiceState.invoiceItems ?? [])
52 | const [isInvoiceItemFormOpen, setIsInvoiceItemFormOpen] = useState(false)
53 | const [isGrossWeightNetWeight, setIsGrossWeightNetWeight] = useState(false)
54 |
55 | const openInvoiceItemsPanel = useConstCallback(() => setIsInvoiceItemFormOpen(true))
56 |
57 | const dismissInvoiceItemsPanel = useConstCallback(() => setIsInvoiceItemFormOpen(false))
58 |
59 | const invoiceSettings = getInvoiceSettings()
60 |
61 | const [hideAlert, setHideAlert] = useState(true)
62 | const [alertDetails, setAlertDetails] = useState({})
63 |
64 | const defaultInvoiceFields = () => {
65 | const defaultInvoice = {}
66 | defaultPrintSettings.forEach((item) => {
67 | defaultInvoice[item.name] = ''
68 | })
69 |
70 | return {
71 | ...defaultInvoice,
72 | 'Invoice Number': getFromStorage('invoiceNumber'),
73 | 'Invoice Date': new Date(),
74 | }
75 | }
76 |
77 | const defaultInvoiceFooter = {
78 | grossTotal: ZERO,
79 | cgst: ZERO,
80 | sgst: ZERO,
81 | igst: ZERO,
82 | totalAmount: ZERO,
83 | oldPurchase: ZERO,
84 | grandTotal: ZERO,
85 | [PAY_METHOD.CHEQUE]: ZERO,
86 | [PAY_METHOD.CREDIT]: ZERO,
87 | [PAY_METHOD.CARD]: ZERO,
88 | [PAY_METHOD.UPI]: ZERO,
89 | [PAY_METHOD.CASH]: ZERO,
90 | interState: false,
91 | }
92 |
93 | const [invoice, setInvoice] = useState(invoiceState.invoice ?? defaultInvoiceFields())
94 | const [invoiceFooter, setInvoiceFooter] = useState(invoiceState.invoiceFooter
95 | ?? defaultInvoiceFooter)
96 | const [currentInvoiceItemIndex, setCurrentInvoiceItemIndex] = useState(null)
97 |
98 | useEffect(() => {
99 | stableInvoiceUpdate({
100 | invoice, invoiceItems, invoiceFooter,
101 | })
102 | }, [invoice, invoiceItems, invoiceFooter, stableInvoiceUpdate])
103 |
104 | const hoverCard = useRef(null)
105 |
106 | const fetchPDF = async (mode = PRINT) => getPdf(
107 | { meta: invoice, items: invoiceItems, footer: invoiceFooter }, mode,
108 | )
109 |
110 | const resetForm = () => {
111 | setInvoiceItems([])
112 | setInvoice(defaultInvoiceFields())
113 | setInvoiceFooter(defaultInvoiceFooter)
114 | }
115 |
116 | const moveAhead = () => {
117 | localStorage.invoiceNumber = incrementor(invoice['Invoice Number'])
118 | resetForm()
119 | }
120 |
121 | const printAndMove = (_, includeBill) => {
122 | fetchPDF(includeBill && PREVIEW).then((pdfBytes) => {
123 | if (pdfBytes?.error) {
124 | setAlertDetails(PdfPathError)
125 | setHideAlert(false)
126 | return false
127 | }
128 | return printPDF(pdfBytes)
129 | }).then((oneGone) => {
130 | if (oneGone && getFromStorage('printBoth')) {
131 | fetchPDF(PREVIEW).then((pdfBytes) => {
132 | if (pdfBytes?.error) {
133 | setAlertDetails(PdfPathError)
134 | setHideAlert(false)
135 | return
136 | }
137 | printPDF(pdfBytes).then((fin) => {
138 | if (fin) {
139 | moveAhead()
140 | }
141 | })
142 | })
143 | } else if (oneGone) {
144 | moveAhead()
145 | }
146 | })
147 | }
148 |
149 | const printWithBill = (e) => {
150 | printAndMove(e, true)
151 | }
152 |
153 | const previewPDF = () => {
154 | fetchPDF(PREVIEW).then((pdfBytes) => {
155 | if (pdfBytes?.error) {
156 | setAlertDetails(PdfPathError)
157 | setHideAlert(false)
158 | return
159 | }
160 | showPdfPreview(pdfBytes)
161 | })
162 | }
163 |
164 | const addInvoiceItem = (invoiceItem) => {
165 | setInvoiceItems([...invoiceItems, invoiceItem])
166 | }
167 |
168 | const updateInvoiceFooter = (change) => {
169 | let updatedInvoiceFooter = invoiceFooter
170 | if (change) {
171 | updatedInvoiceFooter = { ...invoiceFooter, ...change }
172 | }
173 | const {
174 | oldPurchase, grossTotal, cheque, card, upi, interState, credit,
175 | } = updatedInvoiceFooter
176 | const calcSettings = getInvoiceSettings(ISET.CALC)
177 | const cgst = interState
178 | ? 0 : grossTotal * 0.01 * currency(calcSettings.cgst)
179 | const sgst = interState
180 | ? 0 : grossTotal * 0.01 * currency(calcSettings.sgst)
181 | const igst = interState
182 | ? grossTotal * 0.01 * currency(calcSettings.igst) : 0
183 | const totalAmount = currency(grossTotal + cgst + sgst + igst)
184 | setInvoiceFooter({
185 | ...updatedInvoiceFooter,
186 | grossTotal: currency(grossTotal),
187 | cgst,
188 | sgst,
189 | igst,
190 | totalAmount,
191 | grandTotal: currency(totalAmount - oldPurchase),
192 | cash: currency(totalAmount - oldPurchase - card - cheque - upi - credit),
193 | })
194 | }
195 |
196 | const calcInvoiceFooter = (items) => {
197 | let grossTotal = ZERO
198 | let oldPurchase = ZERO
199 | items.forEach((item) => {
200 | if (!item.isOldItem) {
201 | grossTotal += currency(item.totalPrice)
202 | } else {
203 | oldPurchase += currency(item.totalPrice)
204 | }
205 | })
206 |
207 | updateInvoiceFooter({ grossTotal, oldPurchase })
208 | }
209 |
210 | const removeInvoiceItem = (id) => {
211 | const filteredItems = invoiceItems.filter((item) => item.id !== id)
212 | setInvoiceItems(filteredItems)
213 | calcInvoiceFooter(filteredItems)
214 | dismissInvoiceItemsPanel()
215 | }
216 |
217 | const dismissInvoiceItemsPanelAndRemoveEmptyItems = () => {
218 | // remove items without baap on panel dismiss
219 | const filteredItems = invoiceItems.filter((item) => {
220 | if (!item.isOldItem) return !!item.product
221 | return !!item.type
222 | })
223 | setInvoiceItems(filteredItems)
224 | dismissInvoiceItemsPanel()
225 | calcInvoiceFooter(filteredItems)
226 | }
227 |
228 | const updateInvoiceItem = (index, valueObject) => {
229 | let grossTotal = ZERO
230 | let oldPurchase = ZERO
231 |
232 | setInvoiceItems(invoiceItems.map((item, i) => {
233 | if (i === index) {
234 | const newItem = { ...item, ...valueObject }
235 | if (valueObject.product && item.product !== valueObject.product) {
236 | newItem.price = getProducts(newItem.product).price
237 | }
238 | if (valueObject.isOldItem) {
239 | newItem.quantity = 1
240 | newItem.product = null
241 | newItem.other = ZERO
242 | newItem.mkg = ZERO
243 | }
244 | // The logic is price*weight + %MKG + other
245 | const totalPrice = currency(currency(newItem.price) * newItem.weight
246 | * (1 + 0.01 * currency(newItem.mkg)) + currency(newItem.other))
247 |
248 | if (!newItem.isOldItem) {
249 | grossTotal += totalPrice
250 | } else {
251 | oldPurchase += totalPrice
252 | }
253 |
254 | return {
255 | ...newItem,
256 | totalPrice,
257 | }
258 | }
259 |
260 | if (!item.isOldItem) {
261 | grossTotal += currency(item.totalPrice)
262 | } else {
263 | oldPurchase += currency(item.totalPrice)
264 | }
265 | return item
266 | }))
267 | updateInvoiceFooter({ grossTotal, oldPurchase })
268 | }
269 |
270 | const addNewInvoiceItem = () => {
271 | const newItemId = makeHash()
272 | addInvoiceItem({
273 | id: newItemId,
274 | product: null,
275 | quantity: ZERO,
276 | weight: ZERO,
277 | price: ZERO,
278 | mkg: ZERO,
279 | gWeight: ZERO,
280 | other: ZERO,
281 | totalPrice: ZERO,
282 | // old item fields
283 | isOldItem: false,
284 | type: null,
285 | purity: ZERO,
286 | })
287 | setCurrentInvoiceItemIndex(invoiceItems.length)
288 | openInvoiceItemsPanel()
289 | }
290 |
291 | const editInvoiceItem = (id) => {
292 | setCurrentInvoiceItemIndex(invoiceItems.findIndex((item) => item.id === id))
293 | openInvoiceItemsPanel()
294 | }
295 |
296 | const groupedSettings = groupBy(invoiceSettings, 'row')
297 |
298 | const getFilteredInvoiceItems = () => invoiceItems
299 | .filter((item) => !item.isOldItem && item.product)
300 |
301 | const getOldInvoiceItems = () => invoiceItems.filter((item) => item.isOldItem && item.type)
302 |
303 | const validateInvoiceField = (field) => {
304 | if (field.disabled) return true
305 |
306 | if (!invoice[field.name]) return false
307 |
308 | if (field.inputLength) return invoice[field.name].length === field.inputLength
309 |
310 | if (field.regex
311 | && !new RegExp(field.regex).test(invoice[field.name].toUpperCase())) return false
312 |
313 | return true
314 | }
315 |
316 | const validateMandatoryMeta = () => !invoiceSettings
317 | .some((field) => field.required && !validateInvoiceField(field))
318 |
319 | const onParseDateFromString = (newValue, value) => {
320 | const previousValue = value || new Date()
321 | try {
322 | const parsedDate = parseDate(newValue, getFromStorage('dateSep'))
323 | return parsedDate
324 | } catch (e) {
325 | return previousValue
326 | }
327 | }
328 |
329 | return (
330 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions
331 |
332 |
337 |
341 |
346 | {Object.keys(groupedSettings).map((row) => (
347 | 1}
349 | key={row}
350 | {...columnProps}
351 | >
352 | {groupedSettings[row].map((field) => {
353 | const props = {
354 | label: field.name,
355 | key: field.name,
356 | value: invoice[field.name],
357 | prefix: field.prefix,
358 | onChange: (_, val) => {
359 | if (field.inputLength && val.length > field.inputLength) return
360 | setInvoice({ ...invoice, [field.name]: val })
361 | if (field.name.includes('GST') && getFromStorage('nativeGstinPrefix') && val.length > 2) {
362 | setInvoiceFooter({
363 | ...invoiceFooter,
364 | interState: invoice[field.name].substr(0, 2) !== getFromStorage('nativeGstinPrefix'),
365 | })
366 | }
367 | },
368 | onGetErrorMessage: (value) => {
369 | if (!value) return
370 | if (!validateInvoiceField(field)) return `Invalid Value For ${field.name}`
371 | },
372 | required: field.required,
373 | disabled: field.disabled,
374 | }
375 | return (
376 | // eslint-disable-next-line no-nested-ternary
377 | field.type === DATE
378 | ? (
379 | {}}
382 | value={invoice[field.name] ?? new Date()}
383 | ariaLabel="Select a date"
384 | allowTextInput
385 | parseDateFromString={(v) => onParseDateFromString(v, invoice[field.name])}
386 | onSelectDate={(date) => {
387 | setInvoice({ ...invoice, [field.name]: date })
388 | }}
389 | />
390 | ) : field.type === MASKED
391 | ? (
392 |
400 | ) :
401 | )
402 | })}
403 |
404 | ))}
405 |
406 |
409 |
415 |
420 |
421 | {getOldInvoiceItems().length > 0 && (
422 |
428 | )}
429 |
430 |
435 | updateInvoiceFooter({ interState: value })}
439 | />
440 |
449 |
458 | item.isOldItem)
462 | || (!invoiceItems.some((item) => item.isOldItem)
463 | && !getFromStorage('oldPurchaseFreedom'))}
464 | value={invoiceFooter.oldPurchase}
465 | onChange={(_, value) => {
466 | updateInvoiceFooter({ oldPurchase: value })
467 | }}
468 | min="0"
469 | prefix="₹"
470 | />
471 | HoverTotal(
477 | { hoverCard, invoiceFooter, updateInvoiceFooter },
478 | ),
479 | }}
480 | componentRef={hoverCard}
481 | >
482 |
492 |
493 |
494 |
495 |
496 |
503 |
512 |
524 |
525 |
526 | )
527 | }
528 |
529 | export default Invoice
530 |
--------------------------------------------------------------------------------
/src/components/Invoice/index.scss:
--------------------------------------------------------------------------------
1 | .invoice {
2 | height: 100%;
3 |
4 | &__container {
5 | padding: 0 2rem;
6 | }
7 |
8 | &__add-item-btn{
9 | height: 4rem;
10 | width: 15rem;
11 | }
12 |
13 | &__hover-card {
14 | .ms-Callout-main {
15 | box-shadow: 0 0 1rem var(--color-black);
16 | }
17 | }
18 |
19 | &__item-panel {
20 | .ms-Panel-main {
21 | width: 50rem;
22 | }
23 |
24 | &__header {
25 | padding: 1rem;
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/InvoiceItems/index.js:
--------------------------------------------------------------------------------
1 | import React, {
2 | useCallback, useRef, useState,
3 | } from 'react'
4 |
5 | import { CommandBarButton, Icon } from 'office-ui-fabric-react'
6 | import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'
7 | import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'
8 | import { ComboBox, SelectableOptionMenuItemType } from 'office-ui-fabric-react/lib/index'
9 | import { Stack } from 'office-ui-fabric-react/lib/Stack'
10 | import { TextField } from 'office-ui-fabric-react/lib/TextField'
11 |
12 | import { currency, getProducts, getProductTypes } from '../../services/dbService'
13 | import { groupBy } from '../../utils/utils'
14 | import './index.scss'
15 |
16 | const columnProps = {
17 | styles: { root: { width: '100%', display: 'flex', justifyContent: 'space-between' } },
18 | }
19 |
20 | const InvoiceItems = ({
21 | currentInvoiceItem, currentInvoiceItemIndex, removeInvoiceItem, updateInvoiceItem,
22 | dismissInvoiceItemsPanel, addNewInvoiceItem, isGrossWeightNetWeight, setIsGrossWeightNetWeight,
23 | }) => {
24 | const [itemsFilterValue, setItemsFilterValue] = useState('')
25 |
26 | const itemsComboBoxRef = useRef(null)
27 |
28 | const onChangeField = (itemIndex, stateKey, value) => {
29 | const updateObject = {
30 | [stateKey]: value,
31 | ...((stateKey === 'gWeight' && currentInvoiceItem.isOldItem)
32 | && { weight: currency(value * currentInvoiceItem.purity * 0.01) }),
33 | ...((stateKey === 'gWeight' && !currentInvoiceItem.isOldItem && isGrossWeightNetWeight)
34 | && { weight: value }),
35 | }
36 | updateInvoiceItem(itemIndex, updateObject)
37 | setItemsFilterValue('')
38 | }
39 |
40 | const generateProductOptions = (id) => {
41 | const products = getProducts().map((op) => ({
42 | ...op,
43 | text: `${op.name} - ${op.type}`,
44 | key: op.id,
45 | isSelected: id === op.id,
46 | title: `${op.name} - ${op.type}`,
47 | }))
48 | const optionsWithCategory = groupBy(products, 'type')
49 | const options = []
50 | Object.keys(optionsWithCategory).forEach((category, index) => {
51 | if (Object.values(optionsWithCategory)[index]) {
52 | options.push({
53 | key: `Header${index}`,
54 | text: category,
55 | itemType: SelectableOptionMenuItemType.Header,
56 | })
57 | options.push(...Object.values(optionsWithCategory)[index])
58 | }
59 | })
60 | return options
61 | }
62 |
63 | const openComboboxDropdown = useCallback(() => itemsComboBoxRef.current?.focus(true), [])
64 |
65 | const filterComboBoxOptions = (product) => (generateProductOptions(product) || [])
66 | .filter((op) => op.text.toLowerCase().includes(itemsFilterValue.toLowerCase().trim()))
67 |
68 | const addAnotherItem = () => {
69 | addNewInvoiceItem()
70 | // move focus to item selection and open it if adding another item
71 | if (itemsComboBoxRef.current) itemsComboBoxRef.current.focus(true)
72 | }
73 |
74 | return (
75 |
76 | {currentInvoiceItem && (
77 |
78 | onChangeField(currentInvoiceItemIndex, 'isOldItem', isChecked)}
82 | />
83 | setIsGrossWeightNetWeight(isChecked)}
88 | />
89 |
90 |
94 | {!currentInvoiceItem.isOldItem && (
95 | <>
96 | {
107 | if (option) {
108 | onChangeField(currentInvoiceItemIndex, 'product', option.id)
109 | }
110 | }}
111 | onKeyUp={(e) => {
112 | if (!e.key.includes('Arrow')) {
113 | setItemsFilterValue(e.target.value)
114 | }
115 | }}
116 | onFocus={openComboboxDropdown}
117 | required
118 | style={{ maxWidth: 300 }}
119 | />
120 | onChangeField(currentInvoiceItemIndex, 'quantity', value)}
128 | required
129 | />
130 | >
131 | )}
132 | {currentInvoiceItem.isOldItem && (
133 | <>
134 | onChangeField(currentInvoiceItemIndex, 'type', selectedType.text)}
144 | />
145 | onChangeField(currentInvoiceItemIndex, 'purity', value)}
153 | required
154 | />
155 | >
156 | )}
157 |
158 |
162 | onChangeField(currentInvoiceItemIndex, 'gWeight', value)}
170 | suffix="gms"
171 | />
172 | onChangeField(currentInvoiceItemIndex, 'weight', value)}
180 | suffix="gms"
181 | />
182 |
183 |
184 |
185 |
189 |
193 | onChangeField(currentInvoiceItemIndex, 'price', value)}
200 | min="0"
201 | prefix="₹"
202 | required
203 | />
204 | {!currentInvoiceItem.isOldItem && (
205 | onChangeField(currentInvoiceItemIndex, 'mkg', value)}
212 | min="0"
213 | suffix="%"
214 | />
215 | )}
216 |
217 |
221 | {!currentInvoiceItem.isOldItem && (
222 | onChangeField(currentInvoiceItemIndex, 'other', value)}
229 | min="0"
230 | prefix="₹"
231 | />
232 | )}
233 |
243 |
244 |
245 |
246 | )}
247 |
248 |
249 |
253 | Changes are saved automatically
254 |
255 |
256 |
257 |
262 |
268 | removeInvoiceItem(currentInvoiceItem.id)}
272 | />
273 |
274 |
275 | )
276 | }
277 |
278 | export default InvoiceItems
279 |
--------------------------------------------------------------------------------
/src/components/InvoiceItems/index.scss:
--------------------------------------------------------------------------------
1 | .invoice-items {
2 | padding: 0.5rem;
3 |
4 | &__item {
5 | display: flex;
6 | padding: 0.5rem 0.5rem 0.5rem;
7 | flex-wrap: wrap;
8 | border-radius: 1rem;
9 | align-items: center;
10 | margin: 1rem 0;
11 |
12 | &__weight-check {
13 | margin-left: 1rem;
14 | }
15 |
16 | &__field {
17 | width: 20rem;
18 | }
19 |
20 | &__icon {
21 | width: 2.5rem;
22 | margin-top: 2.5rem;
23 | }
24 |
25 | &__info {
26 | display: flex;
27 | align-items: center;
28 | padding: 0 0.5rem;
29 |
30 | &--icn {
31 | margin-right: 1rem;
32 | }
33 | }
34 |
35 | &--add-btn {
36 | padding: 1.2rem 0;
37 | background-color: transparent;
38 |
39 | button {
40 | height: 4rem;
41 | }
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/InvoiceItemsTable/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { IconButton } from 'office-ui-fabric-react'
4 | import {
5 | DetailsList,
6 | DetailsListLayoutMode,
7 | SelectionMode,
8 | } from 'office-ui-fabric-react/lib/DetailsList'
9 |
10 | import { getProducts } from '../../services/dbService'
11 | import { invoiceItemsTableColumns, oldInvoiceItemsTableColumns } from '../../utils/constants'
12 | import ListEmpty from '../ListEmpty'
13 | import './index.scss'
14 |
15 | const InvoiceItemsTable = ({
16 | items, oldItemsTable, removeInvoiceItem, editInvoiceItem,
17 | }) => {
18 | const actionColumn = {
19 | key: 'action-column',
20 | name: 'Actions',
21 | minWidth: 65,
22 | maxWidth: 65,
23 | isPadded: false,
24 | onRender: (item) => (
25 | <>
26 | removeInvoiceItem(item.id)}
31 | checked={false}
32 | />
33 | editInvoiceItem(item.id)}
37 | checked={false}
38 | />
39 | >
40 | ),
41 | }
42 |
43 | const itemColumn = {
44 | key: 'column1',
45 | name: 'Item',
46 | maxWidth: 150,
47 | minWidth: 150,
48 | isResizable: true,
49 | data: 'string',
50 | isPadded: true,
51 | onRender: (item) => (
52 | <>
53 | {item && item.product && `${getProducts(item.product).name} - ${getProducts(item.product).type}`}
54 | >
55 | ),
56 | }
57 |
58 | const oldItemNameColumn = {
59 | key: 'column1',
60 | name: 'Type',
61 | fieldName: 'type',
62 | isResizable: true,
63 | maxWidth: 120,
64 | minWidth: 120,
65 | data: 'string',
66 | isPadded: true,
67 | }
68 |
69 | const columns = [
70 | oldItemsTable ? oldItemNameColumn : itemColumn,
71 | ...(oldItemsTable ? oldInvoiceItemsTableColumns : invoiceItemsTableColumns),
72 | actionColumn,
73 | ]
74 |
75 | return (
76 |
77 | {items && items.length > 0 ? (
78 | `${item.name} - ${index}`}
84 | setKey="multiple"
85 | layoutMode={DetailsListLayoutMode.justified}
86 | isHeaderVisible
87 | enterModalSelectionOnTouch
88 | />
89 | ) : (
90 |
94 | )}
95 |
96 | )
97 | }
98 |
99 | export default InvoiceItemsTable
100 |
--------------------------------------------------------------------------------
/src/components/InvoiceItemsTable/index.scss:
--------------------------------------------------------------------------------
1 | .invoice-item-table {
2 | display: flex;
3 | align-items: center;
4 | flex-direction: column;
5 | height: 60vh;
6 | width: 65vw;
7 | overflow: hidden scroll;
8 |
9 | &__no-items {
10 | margin-top: 2rem;
11 | margin-bottom: 2rem;
12 | padding: 3rem;
13 | color: var(--color-white);
14 | border-radius: 1rem;
15 | background-color: var(--color-black-op-20);
16 | text-align: center;
17 | }
18 |
19 | &__no-items_icon {
20 | font-size: 5rem;
21 | }
22 |
23 | &__no-items_text {
24 | margin-top: 1rem;
25 | font-size: 2rem;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/InvoicePageFooter/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { DefaultButton } from 'office-ui-fabric-react'
4 |
5 | import { getFromStorage, updatePrinterList } from '../../services/dbService'
6 | import './index.scss'
7 |
8 | class InvoicePageFooter extends React.Component {
9 | constructor (props) {
10 | super(props)
11 |
12 | this.state = {
13 | printer: localStorage.printer,
14 | updating: false,
15 | }
16 | }
17 |
18 | componentDidMount () {
19 | document.addEventListener('keydown', this.keyDownHandler, true)
20 | }
21 |
22 | componentWillUnmount () {
23 | document.removeEventListener('keydown', this.keyDownHandler, true)
24 | }
25 |
26 | setPrinter = (printer) => this.setState({ printer })
27 |
28 | setUpdating = (updating) => this.setState({ updating })
29 |
30 | setDefaultPrinter = (key) => {
31 | this.setPrinter(key)
32 | localStorage.printer = key
33 | }
34 |
35 | keyDownHandler = (e) => {
36 | if (e.shiftKey && e.ctrlKey) {
37 | if (this.props.disablePrintButton) return
38 |
39 | const { key, repeat } = e
40 | if (repeat) return
41 | if (key.toLowerCase() === 'p' && this.props.printWithBill) this.props.printWithBill()
42 | return
43 | }
44 |
45 | if (e.ctrlKey) {
46 | const { key, repeat } = e
47 | if (repeat) return
48 |
49 | // toLowerCase here is mandatory otherwise, hotkeys won't work with capslock on. 😁
50 | switch (key.toLowerCase()) {
51 | case 's':
52 | this.props.previewPDF()
53 | break
54 | case 'p':
55 | if (this.props.disablePrintButton) return
56 | this.props.printAndMove()
57 | break
58 | case 'r':
59 | this.props.resetForm()
60 | break
61 | default:
62 | }
63 | }
64 | }
65 |
66 | updateOptions = async () => {
67 | this.setUpdating(!this.state.updating)
68 | await updatePrinterList()
69 | this.setUpdating(!this.state.updating)
70 | }
71 |
72 | render () {
73 | const menuProps = {
74 | items: getFromStorage('printers') && JSON.parse(getFromStorage('printers')).map((e) => ({
75 | ...e,
76 | onClick: () => this.setDefaultPrinter(e.key),
77 | isChecked: this.state.printer === e.key,
78 | })),
79 | }
80 |
81 | return (
82 |
83 |
84 |
97 |
107 |
115 |
122 |
123 |
130 |
131 | )
132 | }
133 | }
134 |
135 | export default InvoicePageFooter
136 |
--------------------------------------------------------------------------------
/src/components/InvoicePageFooter/index.scss:
--------------------------------------------------------------------------------
1 | .invoice-page-footer {
2 | position: fixed;
3 | bottom: 0;
4 | display: flex;
5 | align-items: center;
6 | font-size: 2rem;
7 | background-color: var(--color-bg);
8 | height: 8rem;
9 | width: 100%;
10 | box-shadow: 5px 0 5px 2px var(--color-black);
11 | justify-content: space-between;
12 |
13 | &__button_left {
14 | margin-left: 2rem;
15 | height: 4rem;
16 | }
17 |
18 | &__button_right {
19 | margin-right: 2rem;
20 | height: 4rem;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ListEmpty/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { FontIcon } from 'office-ui-fabric-react/lib/Icon'
4 |
5 | const ListEmpty = ({ type, source }) => (
6 |
7 |
11 |
{`No ${type} in ${source}`}
12 |
13 | )
14 |
15 | export default ListEmpty
16 |
--------------------------------------------------------------------------------
/src/components/LockScreen/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 |
3 | import { ActionButton } from 'office-ui-fabric-react'
4 | import { FontIcon } from 'office-ui-fabric-react/lib/Icon'
5 | import { TextField } from 'office-ui-fabric-react/lib/TextField'
6 |
7 | import { useAuthContext } from '../../contexts'
8 | import { getFromStorage } from '../../services/dbService'
9 | import { getB64File } from '../../services/nodeService'
10 | import SetPassword from '../SetPasswordModal'
11 |
12 | import './index.scss'
13 |
14 | const LockScreen = () => {
15 | const [authState, updateAuthState] = useAuthContext()
16 | const [time, setTime] = useState(new Date())
17 | const [userInput, setUserInput] = useState('')
18 | const [bgImg, setBgImg] = useState('')
19 | const [errorMessage, setError] = useState('')
20 | const [hidePasswordDialog, setHidePasswordDialog] = useState(true)
21 |
22 | useEffect(() => {
23 | const interval = setInterval(() => {
24 | setTime(new Date())
25 | }, 1000)
26 | return () => clearInterval(interval)
27 | }, [])
28 |
29 | useEffect(() => {
30 | const bgPath = getFromStorage('customLockBg')
31 |
32 | if (bgPath) {
33 | getB64File(bgPath).then((b64Img) => !bgImg && setBgImg(b64Img))
34 | }
35 | }, [bgImg])
36 |
37 | const unlock = (event) => {
38 | if (event.key === 'Enter') {
39 | if (userInput === getFromStorage('password')) updateAuthState({ isAuthenticated: !authState.isAuthenticated })
40 | else {
41 | setError('Incorrect password entered!')
42 | }
43 | }
44 | }
45 |
46 | return (
47 |
51 |
52 |
56 |
57 |
58 |
59 |
60 | Hey!
61 |
66 | 👋🏻
67 |
68 | {' '}
69 | {getFromStorage('companyName')}
70 |
71 |
72 |
{localStorage.password ? 'Enter Password' : 'Press Enter'}
73 |
{
79 | setUserInput(val)
80 | if (!val.length) setError('')
81 | }}
82 | onKeyPress={unlock}
83 | errorMessage={errorMessage}
84 | />
85 | {localStorage.password && (
86 | <>
87 | setHidePasswordDialog(false)}
92 | styles={{ root: { marginTop: '2rem' } }}
93 | />
94 | {!hidePasswordDialog && (
95 |
100 | )}
101 | >
102 | )}
103 |
104 |
105 |
106 |
110 | {time.toDateString()}
111 |
112 |
113 | {time.toLocaleTimeString()}
114 |
115 |
116 |
117 | )
118 | }
119 |
120 | export default LockScreen
121 |
--------------------------------------------------------------------------------
/src/components/LockScreen/index.scss:
--------------------------------------------------------------------------------
1 | .lock-screen {
2 | position: relative;
3 | display: flex;
4 | flex-direction: column;
5 | align-items: center;
6 | height: 100%;
7 | color: white;
8 |
9 | background-attachment: fixed;
10 | background-size: cover;
11 | background: #2c3e50; /* fallback for old browsers */
12 | background-color: -webkit-linear-gradient(
13 | to top,
14 | #3498db,
15 | #2c3e50
16 | ); /* Chrome 10-25, Safari 5.1-6 */
17 | background-color: linear-gradient(
18 | to top,
19 | #3498db,
20 | #2c3e50
21 | ); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
22 |
23 | &__clock {
24 | position: absolute;
25 | bottom: 3rem;
26 | left: 3rem;
27 | padding: 4rem;
28 | -webkit-backdrop-filter: blur(8px);
29 | backdrop-filter: blur(8px);
30 | background-color: var(--color-black-op-20);
31 | border-radius: 1rem;
32 |
33 | &__icn {
34 | font-size: 3rem;
35 | margin-right: 2rem;
36 | margin-top: 0.5rem;
37 | align-self: center;
38 | }
39 | }
40 |
41 | &__hero-icn {
42 | -webkit-backdrop-filter: blur(8px);
43 | backdrop-filter: blur(8px);
44 | background-color: var(--color-black-op-20);
45 | margin: 6rem 0;
46 | padding: 2.5rem;
47 | border-radius: 50%;
48 | }
49 |
50 | &__items {
51 | display: flex;
52 | flex-direction: column;
53 | align-items: center;
54 | padding: 5rem;
55 | -webkit-backdrop-filter: blur(8px);
56 | backdrop-filter: blur(8px);
57 | background-color: var(--color-black-op-20);
58 | border-radius: 1rem;
59 | transform: scale(1);
60 |
61 | .ms-TextField-fieldGroup {
62 | background-color: var(--color-black-op-50);
63 | transition: transform 0.3s ease-in-out;
64 |
65 | // &:focus-within {
66 | // transform: scaleX(1.1);
67 | // }
68 | }
69 | }
70 | }
71 |
72 | .pretty-huge {
73 | font-size: 10rem;
74 | }
75 |
76 | .huge {
77 | font-size: 6rem;
78 | }
79 |
80 | .not-so-huge {
81 | font-size: 3rem;
82 | }
83 |
84 | .human-size {
85 | font-size: 1.5rem;
86 | margin-bottom: 0.7rem;
87 | letter-spacing: 0.1ch;
88 | }
89 |
90 | .okayish {
91 | font-size: 4.5rem;
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/ProductForm/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import {
4 | DefaultButton, IconButton, Modal,
5 | Stack, TextField,
6 | } from 'office-ui-fabric-react'
7 | import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'
8 |
9 | import { getProductTypes, upsertProduct } from '../../services/dbService'
10 | import { makeHash } from '../../utils/utils'
11 | import './index.scss'
12 |
13 | const ProductForm = ({
14 | isModalOpen, hideModal, fetchItems, product,
15 | }) => {
16 | const [name, setName] = useState(product?.name ?? '')
17 | const [type, setType] = useState(product?.type ?? '')
18 | const [price, setPrice] = useState(product?.price ?? 0)
19 |
20 | const changeName = (_, val) => setName(val)
21 | const changePrice = (_, val) => setPrice(val)
22 |
23 | const changeType = (_, val) => setType(val.key)
24 |
25 | const resetForm = () => {
26 | setName('')
27 | setType('')
28 | setPrice('')
29 | }
30 |
31 | const saveForm = () => {
32 | const id = product?.id ?? makeHash()
33 | upsertProduct({
34 | name, id, type, price,
35 | })
36 | if (fetchItems) fetchItems()
37 | if (hideModal) hideModal()
38 | resetForm()
39 | }
40 |
41 | return (
42 |
43 |
49 |
50 | Create new product
51 |
56 |
57 |
58 |
59 |
66 |
74 |
83 |
84 |
88 | 0}
94 | />
95 |
100 |
101 |
102 |
103 |
104 |
105 | )
106 | }
107 |
108 | export default ProductForm
109 |
--------------------------------------------------------------------------------
/src/components/ProductForm/index.scss:
--------------------------------------------------------------------------------
1 | .product-form {
2 | &__modal {
3 | width: 50rem;
4 | }
5 |
6 | &__title {
7 | font-size: 2rem;
8 | font-weight: 300;
9 | display: flex;
10 | justify-content: space-between;
11 | align-items: center;
12 | padding: 1rem 2rem;
13 | border-bottom: 0.2rem solid var(--color-white-op-20);
14 | }
15 |
16 | &__body {
17 | padding: 2rem;
18 |
19 | .ms-TextField {
20 | margin-bottom: 2rem;
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ProductsPage/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { CommandBarButton, IconButton } from 'office-ui-fabric-react'
4 | import {
5 | DetailsList,
6 | DetailsListLayoutMode,
7 | SelectionMode,
8 | } from 'office-ui-fabric-react/lib/DetailsList'
9 | import { TeachingBubble } from 'office-ui-fabric-react/lib/TeachingBubble'
10 |
11 | import {
12 | deleteProducts, getProducts, getProductsJSON, setProducts,
13 | } from '../../services/dbService'
14 | import { saveCSV } from '../../services/nodeService'
15 | import { getInvoiceDate } from '../../services/pdfService'
16 | import { productTableColumns, SELECT_FILE_TYPE } from '../../utils/constants'
17 | import ImportProducts from '../ImportProducts'
18 | import ListEmpty from '../ListEmpty'
19 | import ProductForm from '../ProductForm'
20 | import './index.scss'
21 |
22 | class ProductsPage extends React.Component {
23 | constructor (props) {
24 | super(props)
25 |
26 | this.actionColumn = {
27 | key: 'action-column',
28 | name: 'Actions',
29 | minWidth: 70,
30 | maxWidth: 70,
31 | isRowHeader: true,
32 | isResizable: true,
33 | isSorted: false,
34 | isSortedDescending: false,
35 | data: 'number',
36 | isPadded: false,
37 | onRender: (item) => (
38 | <>
39 | this.deleteProduct(item)}
44 | checked={false}
45 | />
46 | this.onItemClick(item)}
50 | checked={false}
51 | />
52 | >
53 | ),
54 | }
55 | this.initialState = {
56 | columns: [
57 | ...productTableColumns,
58 | this.actionColumn,
59 | ],
60 | items: getProducts(),
61 | isProductFormOpen: false,
62 | currentItem: null,
63 | teachingBubbleVisible: false,
64 | targetBtn: null,
65 | }
66 |
67 | this.state = { ...this.initialState }
68 | }
69 |
70 | onColumnClick = (_, column) => {
71 | if (column.key === this.actionColumn.key) return
72 |
73 | const { columns, items } = this.state
74 | const newColumns = columns.slice()
75 | const currColumn = newColumns.filter((currCol) => column.key === currCol.key)[0]
76 | newColumns.forEach((newCol) => {
77 | if (newCol === currColumn) {
78 | currColumn.isSortedDescending = !currColumn.isSortedDescending
79 | currColumn.isSorted = true
80 | } else {
81 | newCol.isSorted = false
82 | newCol.isSortedDescending = true
83 | }
84 | })
85 | const newItems = this.copyAndSort(items, currColumn.fieldName, currColumn.isSortedDescending)
86 | this.setState({
87 | columns: newColumns,
88 | items: newItems,
89 | })
90 | }
91 |
92 | deleteProduct = (item) => {
93 | this.toggleTeachingBubbleVisible(item.id)
94 | }
95 |
96 | finalDelete = () => {
97 | if (this.state.targetBtn) {
98 | deleteProducts([this.state.targetBtn])
99 | this.refreshProductItems()
100 | this.toggleTeachingBubbleVisible()
101 | }
102 | }
103 |
104 | copyAndSort= (items, columnKey, isSortedDescending) => {
105 | const key = columnKey
106 | return items.slice(0).sort(
107 | (a, b) => ((isSortedDescending ? a[key] < b[key] : a[key] > b[key]) ? 1 : -1),
108 | )
109 | }
110 |
111 | refreshProductItems = () => {
112 | this.setState({ items: getProducts() })
113 | if (this.props.refreshProductsCount) this.props.refreshProductsCount()
114 | }
115 |
116 | deleteAllProducts = () => {
117 | setProducts([], true)
118 | this.refreshProductItems()
119 | }
120 |
121 | onItemClick = (item) => this.setState({ currentItem: item, isProductFormOpen: true })
122 |
123 | hideProductForm = () => this.setState({ isProductFormOpen: false, currentItem: null })
124 |
125 | showProductForm = () => this.setState({ isProductFormOpen: true })
126 |
127 | toggleTeachingBubbleVisible = (id) => this.setState((prevState) => ({
128 | teachingBubbleVisible: !prevState.teachingBubbleVisible,
129 | targetBtn: typeof id === 'string' ? id : null,
130 | }))
131 |
132 | exportProduct = () => saveCSV(
133 | getProductsJSON(),
134 | SELECT_FILE_TYPE.EXCEL,
135 | true,
136 | `Products-${getInvoiceDate()}.csv`,
137 | )
138 |
139 | render () {
140 | return (
141 |
142 | {this.state.isProductFormOpen && (
143 |
149 | )}
150 |
151 | {this.state.teachingBubbleVisible && (
152 |
159 | Do you really wana delete this product?
160 |
161 | )}
162 |
163 |
164 |
171 |
172 |
179 |
186 |
187 |
188 | {this.state.items && this.state.items.length > 0 ? (
189 |
`${item.name} - ${index}`}
196 | setKey="multiple"
197 | layoutMode={DetailsListLayoutMode.justified}
198 | isHeaderVisible
199 | enterModalSelectionOnTouch
200 | />
201 | ) : (
202 |
206 | )}
207 |
208 | )
209 | }
210 | }
211 |
212 | export default ProductsPage
213 |
--------------------------------------------------------------------------------
/src/components/ProductsPage/index.scss:
--------------------------------------------------------------------------------
1 | .products-page {
2 | display: flex;
3 | align-items: center;
4 | flex-direction: column;
5 |
6 | &__header {
7 | margin-top: 1rem;
8 | width: 100%;
9 | display: flex;
10 | justify-content: space-between;
11 |
12 | &__btn {
13 | height: 5rem;
14 | font-size: 1.7rem;
15 | }
16 | }
17 |
18 | .invoice-item-table__no-items {
19 | margin-top: 30vh;
20 | }
21 |
22 | &__no-items {
23 | font-size: 5rem;
24 | margin-top: 2rem;
25 | padding: 3rem;
26 | color: var(--color-white);
27 | border-radius: 1rem;
28 | background-color: var(--color-black-op-20);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/SetPasswordModal/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { useId } from '@uifabric/react-hooks'
4 | import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'
5 | import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'
6 | import { TextField } from 'office-ui-fabric-react/lib/TextField'
7 |
8 | import NoNet from '../../assets/no-net.mp4'
9 | import { createUser, verifyOtp } from '../../services/apiService'
10 | import { getFromStorage } from '../../services/dbService'
11 | import { validateEmail } from '../../utils/utils'
12 | import './index.scss'
13 |
14 | const dialogContentProps = {
15 | type: DialogType.largeHeader,
16 | title: `${getFromStorage('password')?.length ? 'Change' : 'Set'} Password`,
17 | closeButtonAriaLabel: 'Close',
18 | }
19 |
20 | const SetPassword = ({ hideDialog, setHideDialog, isForgotPasswordPage }) => {
21 | const labelId = useId('changePassword')
22 | /** State */
23 | const [newPassword, setnewPassword] = useState('')
24 | const [email, setEmail] = useState(localStorage.email)
25 | const [otpSent, setOtpSent] = useState(false)
26 | const [otp, setOtp] = useState('')
27 | const [otpError, setOtpError] = useState('')
28 | const [emailError, setEmailError] = useState('')
29 | const [isEmailVerified, setIsEmailVerified] = useState(false)
30 | const [oldPass, setOldPass] = useState('')
31 | const [oldPassVerified, setOldPassVerified] = useState(isForgotPasswordPage)
32 | const [passwordErr, setPasswordErr] = useState('')
33 |
34 | const modalProps = React.useMemo(
35 | () => ({
36 | titleAriaId: labelId,
37 | isBlocking: true,
38 | className: 'set-password-modal',
39 | }),
40 | [labelId],
41 | )
42 |
43 | const changePassword = () => {
44 | localStorage.password = newPassword
45 | setHideDialog(true)
46 | }
47 |
48 | const emailVerificationError = (error) => {
49 | setIsEmailVerified(false)
50 | setEmailError(error ? error.message : 'Something went wrong')
51 | }
52 |
53 | const submitEmail = () => {
54 | if (!email) return
55 |
56 | const name = getFromStorage('companyName')
57 | createUser(name, email).then((res) => {
58 | if (res.status === 'OK') {
59 | localStorage.email = email
60 | setOtpSent(true)
61 | setOtpError('')
62 | } else {
63 | emailVerificationError(res.error)
64 | }
65 | }).catch((e) => {
66 | emailVerificationError(e)
67 | })
68 | }
69 |
70 | const submitOtp = () => {
71 | if (!email || !otp) return
72 |
73 | verifyOtp(email, otp).then((res) => {
74 | if (res.status === 'OK') {
75 | setIsEmailVerified(true)
76 | setOtpSent(false)
77 | setOtpError('')
78 | } else {
79 | setOtpError(res.error.message)
80 | }
81 | }).catch(() => setOtpError('An error occurred while verifying the otp'))
82 | }
83 |
84 | return (
85 |
203 | )
204 | }
205 |
206 | export default SetPassword
207 |
--------------------------------------------------------------------------------
/src/components/SetPasswordModal/index.scss:
--------------------------------------------------------------------------------
1 | .set-password-modal {
2 | .ms-Dialog-main {
3 | width: 50rem;
4 | min-width: 50rem;
5 | }
6 |
7 | &__email-row {
8 | display: flex;
9 | justify-content: space-between;
10 | align-items: center;
11 |
12 | &__input {
13 | flex: 1;
14 | height: 8.1rem;
15 | max-width: 30rem;
16 | }
17 |
18 | &__submit-btn {
19 | margin-left: 2rem;
20 | margin-top: 0.8rem;
21 | width: 12rem;
22 | }
23 | }
24 | }
25 |
26 | .no-net {
27 | align-self: center;
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Settings/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 |
3 | import { useId } from '@uifabric/react-hooks'
4 | import { DefaultButton, IconButton } from 'office-ui-fabric-react'
5 | import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'
6 | import { Stack } from 'office-ui-fabric-react/lib/Stack'
7 | import { TeachingBubble } from 'office-ui-fabric-react/lib/TeachingBubble'
8 | import { TextField } from 'office-ui-fabric-react/lib/TextField'
9 | import { Toggle } from 'office-ui-fabric-react/lib/Toggle'
10 | import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'
11 |
12 | import { getFromStorage } from '../../services/dbService'
13 | import { isValidPath } from '../../services/nodeService'
14 | import { resetSettings } from '../../services/settingsService'
15 | import {
16 | COMPANY_NAME, ERROR, FILE_TYPE, SELECT_FILE_TYPE,
17 | } from '../../utils/constants'
18 | import SetPassword from '../SetPasswordModal'
19 | import './index.scss'
20 |
21 | // eslint-disable-next-line global-require
22 | const { ipcRenderer } = require('electron')
23 |
24 | const stackTokens = { childrenGap: 15 }
25 | const stackStyles = { root: { width: '40rem' } }
26 |
27 | const Settings = ({ refreshCompanyName, reloadPage }) => {
28 | const [checkingPath, setCheckingPath] = useState(false)
29 | const [hideDialog, setHideDialog] = useState(true)
30 | const [previewBill, setPreviewBill] = useState(getFromStorage('previewPDFUrl'))
31 | const [previewBillErr, setPreviewBillErr] = useState('')
32 | const [productType, setProductType] = useState(getFromStorage('productType'))
33 | const [invoiceNumber, setInvoiceNumber] = useState(getFromStorage('invoiceNumber'))
34 | const [companyName, setCompanyName] = useState(getFromStorage('companyName'))
35 | const [hindiDate, setHindiDate] = useState(getFromStorage('hindiDate'))
36 | const [showFullMonth, setShowFullMonth] = useState(getFromStorage('showFullMonth'))
37 | const [font, setFont] = useState(getFromStorage('customFont'))
38 | const [bg, setBg] = useState(getFromStorage('customLockBg'))
39 | const [gstinPrefix, setGstinPrefix] = useState(getFromStorage('nativeGstinPrefix'))
40 | const [dateSep, setDateSep] = useState(getFromStorage('dateSep'))
41 | const [currency, setCurrency] = useState(getFromStorage('currency'))
42 | const [showAuthModal, setShowAuthModal] = useState(false)
43 | const [resetSettingsPasswordError, setResetSettingsPasswordError] = useState('')
44 | const [resetSettingsPassword, setResetSettingsPassword] = useState('')
45 | const [printBoth, setPrintBoth] = useState(getFromStorage('printBoth'))
46 | const [enableBeta, setEnableBeta] = useState(getFromStorage('enableBeta'))
47 |
48 | const billTooltipId = useId('billTooltipId')
49 | const fontTooltipId = useId('fontTooltipId')
50 | const bgTooltipId = useId('bgTooltipId')
51 |
52 | useEffect(() => {
53 | // eslint-disable-next-line func-names
54 | (async function () {
55 | setCheckingPath(true)
56 | if (previewBill) setPreviewBillErr(await isValidPath(previewBill) ? '' : ERROR.FILE_MOVED)
57 | setCheckingPath(false)
58 | }())
59 | }, [previewBill])
60 |
61 | const onDateLangChange = (_, checked) => {
62 | localStorage.hindiDate = checked
63 | setHindiDate(checked)
64 | }
65 |
66 | const onChangePrintBoth = (_, c) => {
67 | localStorage.printBoth = c
68 | setPrintBoth(c)
69 | }
70 |
71 | const onChangeBetaFeatures = (_, c) => {
72 | localStorage.enableBeta = c
73 | setEnableBeta(c)
74 | }
75 |
76 | const onMonthShowChange = (_, checked) => {
77 | localStorage.showFullMonth = checked
78 | setShowFullMonth(checked)
79 | }
80 |
81 | const onCurrencyChange = (_, val) => {
82 | localStorage.currency = val
83 | setCurrency(val)
84 | }
85 |
86 | const onDateSepChange = (_, val) => {
87 | if (val.length > 1) return
88 | localStorage.dateSep = val
89 | setDateSep(val)
90 | }
91 |
92 | const onGstinPrefixChange = (_, val) => {
93 | if (val.length > 2) return
94 | localStorage.nativeGstinPrefix = val
95 | setGstinPrefix(val)
96 | }
97 |
98 | const onNameChange = (_, newValue) => {
99 | localStorage.companyName = newValue
100 | setCompanyName(newValue)
101 | if (refreshCompanyName) refreshCompanyName()
102 | }
103 |
104 | const onInvoiceNoChange = (_, newValue) => {
105 | localStorage.invoiceNumber = newValue
106 | setInvoiceNumber(newValue)
107 | }
108 |
109 | const fileSelected = async (type) => {
110 | let filters = SELECT_FILE_TYPE.PDF
111 | if (type === FILE_TYPE.FONT) filters = SELECT_FILE_TYPE.FONT
112 | if (type === FILE_TYPE.IMG) filters = SELECT_FILE_TYPE.IMG
113 |
114 | const path = await ipcRenderer.invoke('file:select', filters, true)
115 | if (path) {
116 | if (type === FILE_TYPE.PDF) {
117 | setPreviewBill(path)
118 | } else if (type === FILE_TYPE.FONT) {
119 | setFont(path)
120 | } else if (type === FILE_TYPE.IMG) {
121 | setBg(path)
122 | }
123 | localStorage.setItem(type, path)
124 | }
125 | }
126 |
127 | const resetLocalStorageItem = (key) => {
128 | if (key === FILE_TYPE.PDF) {
129 | setPreviewBill('')
130 | } else if (key === FILE_TYPE.FONT) {
131 | setFont('')
132 | } else if (key === FILE_TYPE.IMG) {
133 | setBg('')
134 | }
135 |
136 | localStorage.setItem(key, '')
137 | }
138 |
139 | const resetAndUpdate = () => {
140 | resetSettings()
141 | }
142 |
143 | const verifyAndReset = () => {
144 | if (resetSettingsPassword === getFromStorage('password')) {
145 | resetAndUpdate()
146 | setResetSettingsPasswordError(null)
147 | setShowAuthModal(false)
148 | reloadPage()
149 | } else {
150 | setResetSettingsPasswordError('Incorrect Password')
151 | }
152 | }
153 |
154 | const onProductTypeChange = (_, newValue) => {
155 | localStorage.productType = newValue
156 | setProductType(newValue)
157 | }
158 |
159 | if (checkingPath) {
160 | return (
161 |
162 |
166 |
167 | )
168 | }
169 |
170 | return (
171 |
172 |
177 |
182 |
188 |
192 |
198 |
204 |
209 |
210 |
214 |
220 |
226 |
233 |
238 |
239 |
243 |
251 | fileSelected(FILE_TYPE.PDF)}
257 | />
258 | {previewBill && (
259 |
263 | resetLocalStorageItem(FILE_TYPE.PDF)}
268 | />
269 |
270 | )}
271 |
272 |
276 |
283 |
284 | fileSelected(FILE_TYPE.FONT)}
290 | />
291 | {font && (
292 |
296 | resetLocalStorageItem(FILE_TYPE.FONT)}
301 | />
302 |
303 | )}
304 |
305 |
309 |
316 | fileSelected(FILE_TYPE.IMG)}
322 | />
323 | {bg && (
324 |
328 | resetLocalStorageItem(FILE_TYPE.IMG)}
333 | />
334 |
335 | )}
336 |
337 | setHideDialog(false)}
342 | styles={{ root: { width: '18rem' } }}
343 | />
344 | setShowAuthModal(true)}
350 | styles={{ root: { width: '18rem' } }}
351 | />
352 |
353 |
360 |
361 |
362 |
363 | ©
364 | {' '}
365 | {new Date().getFullYear()}
366 | {' '}
367 | {COMPANY_NAME}
368 |
369 |
375 | Privacy Policy
376 |
377 |
383 | About
384 |
385 | {!hideDialog && (
386 |
390 | )}
391 |
392 | {showAuthModal && (
393 |
{
398 | verifyAndReset()
399 | },
400 | }}
401 | secondaryButtonProps={{
402 | text: 'Cancel',
403 | onClick: () => {
404 | setResetSettingsPasswordError('')
405 | setShowAuthModal(false)
406 | },
407 | }}
408 | onDismiss={() => {
409 | setResetSettingsPasswordError('')
410 | setShowAuthModal(false)
411 | }}
412 | headline="Authenticate yourself to reset 🔐"
413 | >
414 | setResetSettingsPassword(val)}
418 | errorMessage={resetSettingsPasswordError}
419 | />
420 |
421 | )}
422 |
423 | )
424 | }
425 |
426 | export default Settings
427 |
--------------------------------------------------------------------------------
/src/components/Settings/index.scss:
--------------------------------------------------------------------------------
1 | .settings-loader {
2 | height: calc(100vh - 10rem);
3 | display: flex;
4 | justify-content: center;
5 | align-items: center;
6 | }
7 |
8 | .invoice-page {
9 | &__path-input {
10 | flex: 1;
11 | }
12 |
13 | &__select-btn {
14 | width: 14rem;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/UpdatePanel/DownloadSection/index.js:
--------------------------------------------------------------------------------
1 | import 'github-markdown-css'
2 | import React, { useEffect, useState } from 'react'
3 |
4 | import {
5 | Icon,
6 | PrimaryButton, ProgressIndicator, Separator, Stack, Text,
7 | } from 'office-ui-fabric-react'
8 |
9 | import { editUpdateInfo, getUpdateInfo } from '../../../services/dbService'
10 | import { restartApp } from '../../../services/nodeService'
11 | import { getReadableSize } from '../../../utils/utils'
12 | import '../index.scss'
13 |
14 | const { ipcRenderer } = require('electron')
15 |
16 | const DownloadSection = () => {
17 | const [upGress, setUpdateProgress] = useState(getUpdateInfo()?.progress ?? '')
18 | const [updateInfo, setUpdateInfo] = useState(getUpdateInfo()?.info ?? '')
19 | const [newVersion, setNewVersion] = useState(getUpdateInfo()?.info?.version ?? ' ')
20 |
21 | useEffect(() => {
22 | const progressHandler = (_e, progress) => {
23 | const snapshot = {
24 | ...progress,
25 | transferred: getReadableSize(progress.transferred),
26 | total: getReadableSize(progress.total),
27 | speed: getReadableSize(progress.bytesPerSecond, true),
28 | }
29 | setUpdateProgress(snapshot)
30 | editUpdateInfo(snapshot, 'progress')
31 | }
32 | ipcRenderer.on('update:progress', progressHandler)
33 | return () => ipcRenderer.removeListener('update:progress', progressHandler)
34 | }, [])
35 |
36 | useEffect(() => {
37 | const downloadedHandler = (_e, info) => {
38 | setUpdateInfo(info)
39 | editUpdateInfo(info, 'info')
40 | setNewVersion(`v${info.version} `)
41 | }
42 | ipcRenderer.on('update:downloaded', downloadedHandler)
43 | return () => ipcRenderer.removeListener('update:downloaded', downloadedHandler)
44 | }, [upGress])
45 |
46 | return (
47 | <>
48 | { (
49 | <>
50 |
51 |
52 | Downloads
53 |
57 |
58 |
59 |
60 |
61 | {!(upGress || updateInfo) && (
62 | <>
63 |
64 | Your application is up to date!
65 | {' '}
66 |
70 | 🥂
71 |
72 |
73 | Hey! We are working hard to push next update.
74 | {' '}
75 |
79 | 💪🏻
80 |
81 | Thanks for checking!
82 |
86 | 🙌🏻
87 |
88 |
89 | >
90 | )}
91 | {upGress && (
92 | <>
93 |
100 |
101 | >
102 | )}
103 | {updateInfo && (
104 |
112 | )}
113 |
114 | >
115 | )}
116 | >
117 | )
118 | }
119 |
120 | export default DownloadSection
121 |
--------------------------------------------------------------------------------
/src/components/UpdatePanel/ReleaseNotes/index.js:
--------------------------------------------------------------------------------
1 | import 'github-markdown-css'
2 | import React, { useEffect, useState } from 'react'
3 |
4 | import { Spinner, SpinnerSize } from 'office-ui-fabric-react'
5 | import ReactMarkdown from 'react-markdown'
6 | import '../index.scss'
7 |
8 | const ReleaseNotes = ({ tag }) => {
9 | const [notes, setNotes] = useState([])
10 | const [loading, setLoading] = useState(true)
11 | const [error, setError] = useState(false)
12 | useEffect(() => {
13 | const getData = async () => {
14 | try {
15 | setError(false)
16 | const data = await fetch(`${process.env.REACT_APP_RELEASE_NOTES_API}${tag}`)
17 | const jsonData = await data.json()
18 | // this is trick to get newline, yeah!
19 | jsonData.body.replace(/\r/gi, ' ')
20 | setNotes([jsonData])
21 | setLoading(false)
22 | } catch (e) {
23 | setLoading(false)
24 | setError(true)
25 | }
26 | }
27 | getData()
28 | }, [tag])
29 |
30 | if (loading) {
31 | return (
32 |
33 |
36 |
37 | )
38 | }
39 |
40 | if (error) {
41 | return (
42 |
43 |
Unable to fetch release notes from servers!
44 |
45 | )
46 | }
47 |
48 | return (
49 |
50 |
51 | {(notes?.map((release) => (
52 |
56 |
57 | {release.body}
58 |
59 |
60 |
61 | )))}
62 |
63 |
64 | )
65 | }
66 |
67 | export default ReleaseNotes
68 |
--------------------------------------------------------------------------------
/src/components/UpdatePanel/index.js:
--------------------------------------------------------------------------------
1 | import 'github-markdown-css'
2 | import React from 'react'
3 |
4 | import { Icon, Separator, Stack } from 'office-ui-fabric-react'
5 |
6 | import DownloadSection from './DownloadSection'
7 | import './index.scss'
8 | import ReleaseNotes from './ReleaseNotes'
9 |
10 | const UpdatesPanel = ({ tag }) => (
11 | <>
12 |
13 |
14 |
15 | Release Notes
16 |
20 |
21 |
22 |
23 |
24 | >
25 | )
26 |
27 | export default UpdatesPanel
28 |
--------------------------------------------------------------------------------
/src/components/UpdatePanel/index.scss:
--------------------------------------------------------------------------------
1 | .markdown-body {
2 | color: white;
3 | font-size: 1em;
4 | }
5 |
6 | .full-height {
7 | height: 50vh;
8 | margin: auto;
9 | display: flex;
10 | justify-content: center;
11 | }
12 |
13 | .separator-stack {
14 | margin-top: 1em;
15 | font-size: 1.4em;
16 |
17 | &__icon {
18 | vertical-align: middle;
19 | }
20 |
21 | &__icon:before {
22 | content: "\00a0 ";
23 | }
24 | }
25 |
26 | .update-card {
27 | backdrop-filter: blur(0.9rem);
28 | -webkit-backdrop-filter: blur(0.9rem);
29 |
30 | background-blend-mode: exclusion;
31 |
32 | background: rgba(68, 68, 68, 0.6);
33 |
34 | padding: 1.2rem;
35 | }
36 |
37 | .center-self {
38 | align-self: center;
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | import Alert from './Alert'
2 | import Header from './Header'
3 | import HoverTotal from './HoverTotal'
4 | import ImportProducts from './ImportProducts'
5 | import Invoice from './Invoice'
6 | import InvoiceItems from './InvoiceItems'
7 | import InvoiceItemsTable from './InvoiceItemsTable'
8 | import InvoicePageFooter from './InvoicePageFooter'
9 | import ListEmpty from './ListEmpty'
10 | import LockScreen from './LockScreen'
11 | import ProductForm from './ProductForm'
12 | import ProductsPage from './ProductsPage'
13 | import SetPassword from './SetPasswordModal'
14 | import Settings from './Settings'
15 | import UpdatePanel from './UpdatePanel'
16 |
17 | export {
18 | Alert,
19 | Header,
20 | ImportProducts,
21 | Invoice,
22 | InvoiceItems,
23 | InvoiceItemsTable,
24 | InvoicePageFooter,
25 | ProductForm,
26 | ProductsPage,
27 | ListEmpty,
28 | Settings,
29 | HoverTotal,
30 | LockScreen,
31 | SetPassword,
32 | UpdatePanel,
33 | }
34 |
35 | export * from './CustomizationComponents'
36 |
--------------------------------------------------------------------------------
/src/contexts/AuthContext/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext, createContext } from 'react'
2 |
3 | const AuthStateContext = createContext()
4 | const AuthUpdateContext = createContext()
5 |
6 | const AuthStateProvider = ({ children }) => {
7 | // add more fields to the authState object if you need
8 | const [authState, setAuthState] = useState({
9 | isAuthenticated: false,
10 | })
11 |
12 | const updateAuth = (newAuthState) => {
13 | // if using current state structure
14 | // newAuthState = { isAuthenticated: true }
15 | setAuthState(newAuthState)
16 | }
17 |
18 | return (
19 |
20 |
21 | {children}
22 |
23 |
24 | )
25 | }
26 |
27 | const useAuthContext = () => [useContext(AuthStateContext), useContext(AuthUpdateContext)]
28 |
29 | export {
30 | AuthStateContext, AuthStateProvider, useAuthContext,
31 | }
32 |
--------------------------------------------------------------------------------
/src/contexts/InvoiceContext/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext, createContext } from 'react'
2 |
3 | const InvoiceStateContext = createContext()
4 | const InvoiceUpdateContext = createContext()
5 |
6 | const InvoiceStateProvider = ({ children }) => {
7 | const [invoiceState, setInvoiceState] = useState({})
8 |
9 | const updateInvoice = (newInvoiceState) => {
10 | setInvoiceState(newInvoiceState)
11 | }
12 |
13 | return (
14 |
15 |
16 | {children}
17 |
18 |
19 | )
20 | }
21 |
22 | const useInvoiceContext = () => [useContext(InvoiceStateContext), useContext(InvoiceUpdateContext)]
23 |
24 | export {
25 | InvoiceStateContext, InvoiceStateProvider, useInvoiceContext,
26 | }
27 |
--------------------------------------------------------------------------------
/src/contexts/index.js:
--------------------------------------------------------------------------------
1 | export * from './InvoiceContext'
2 | export * from './AuthContext'
3 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { initializeIcons } from '@uifabric/icons'
4 | import { loadTheme } from 'office-ui-fabric-react'
5 | import ReactDOM from 'react-dom'
6 | import {
7 | HashRouter as Router,
8 | Route,
9 | } from 'react-router-dom'
10 |
11 | import { Header } from './components'
12 | import { InvoiceStateProvider, AuthStateProvider } from './contexts'
13 | import { HomePage, InvoiceSettings, CustomizationPage } from './pages'
14 | import { initializeSettings } from './services/settingsService'
15 | import { darkThemePalette } from './utils/constants'
16 |
17 | import './index.scss'
18 |
19 | loadTheme({
20 | palette: darkThemePalette,
21 | })
22 | initializeIcons()
23 | initializeSettings()
24 |
25 | ReactDOM.render(
26 |
27 |
28 |
29 |
32 |
33 |
34 |
39 |
40 |
44 |
48 |
49 |
50 |
51 | ,
52 | document.getElementById('root'),
53 | )
54 |
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600&display=swap');
2 | $font-family: 'Manrope', sans-serif;
3 |
4 | // color variables
5 | :root {
6 | --ice-white: #e5f1fa;
7 | --color-white: white;
8 | --color-white-op-80: rgba(255, 255, 255, 0.8);
9 | --color-white-op-50: rgba(255, 255, 255, 0.5);
10 | --color-white-op-20: rgba(255, 255, 255, 0.2);
11 | --color-white-op-10: rgba(255, 255, 255, 0.1);
12 |
13 | --color-black: rgba(0, 0, 0, 1);
14 | --color-black-op-80: rgba(0, 0, 0, 0.8);
15 | --color-black-op-50: rgba(0, 0, 0, 0.5);
16 | --color-black-op-30: rgba(0, 0, 0, 0.3);
17 | --color-black-op-20: rgba(0, 0, 0, 0.2);
18 | --color-black-op-10: rgba(0, 0, 0, 0.1);
19 | --color-red-exit: rgba(238, 62, 79, 0.75);
20 |
21 | --color-bg: #23272a;
22 | --color-dark: #3e4046;
23 | --color-darker: #2b2d36;
24 | --color-primary: #36a5f9;
25 |
26 | --purple: #7262f4;
27 |
28 | --color-scrollbar-background: #36393f;
29 | --color-scrollbar-thumb: #36a4f973;
30 |
31 | --after-header-gradient: -webkit-gradient(
32 | linear,
33 | 0 0,
34 | 100% 0,
35 | from(var(--color-scrollbar-thumb)),
36 | to(var(--color-scrollbar-thumb)),
37 | color-stop(50%, var(--color-primary))
38 | );
39 | }
40 |
41 | *,
42 | *::after,
43 | *::before {
44 | margin: 0;
45 | padding: 0;
46 | box-sizing: inherit;
47 | font-family: 'Manrope', sans-serif;
48 | letter-spacing: 0.28px;
49 | }
50 |
51 | *::-webkit-scrollbar {
52 | width: 1.1rem;
53 | border-radius: 1rem;
54 | }
55 |
56 | *::-webkit-scrollbar-track {
57 | background-color: var(
58 | --color-scrollbar-background
59 | );
60 | border-radius: 1rem;
61 | }
62 |
63 | *::-webkit-scrollbar-thumb {
64 | border: 0.2rem solid transparent;
65 | background-color: var(--color-scrollbar-thumb);
66 | border-radius: 1rem;
67 | background-clip: content-box;
68 | }
69 |
70 | main {
71 | height: calc(100vh);
72 | }
73 |
74 | html {
75 | font-size: 62.5% !important; // 1rem = 10px
76 | font-family: -apple-system, BlinkMacSystemFont,
77 | 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
78 | 'Cantarell', 'Fira Sans', 'Droid Sans',
79 | 'Helvetica Neue', sans-serif;
80 | -webkit-font-smoothing: antialiased;
81 | -moz-osx-font-smoothing: grayscale;
82 | background-color: var(--color-bg);
83 | }
84 |
85 | code {
86 | font-family: source-code-pro, Menlo, Monaco,
87 | Consolas, 'Courier New', monospace;
88 | }
89 |
90 | .hidden {
91 | display: none;
92 | }
93 |
94 | .no-box-shadow {
95 | box-shadow: none;
96 | }
97 |
98 | .ms-Panel-navigation {
99 | position: relative;
100 | padding-bottom: 1rem;
101 |
102 | &::after {
103 | content: '';
104 | position: absolute;
105 | bottom: 0;
106 | width: 100%;
107 | height: 0.2rem;
108 | background: var(--after-header-gradient);
109 | }
110 | }
111 |
112 | // keyframes animations and classes
113 | @keyframes slideUp {
114 | 0% {
115 | opacity: 0;
116 | transform: translateY(5%);
117 | }
118 |
119 | 100% {
120 | opacity: 1;
121 | transform: translateY(0);
122 | }
123 | }
124 |
125 | .animation-slide-up {
126 | animation: slideUp 0.5s forwards;
127 | }
128 |
129 | @keyframes scaleUp {
130 | 0% {
131 | transform: scale(0.9);
132 | opacity: 0;
133 | }
134 | 100% {
135 | transform: scale(1);
136 | opacity: 1;
137 | }
138 | }
139 |
140 | .animation-scale-up {
141 | animation: scaleUp 0.5s forwards;
142 | }
143 |
144 | @keyframes scaleDown {
145 | 0% {
146 | transform: scale(1.2);
147 | opacity: 0;
148 | }
149 | 100% {
150 | transform: scale(1);
151 | opacity: 1;
152 | }
153 | }
154 |
155 | .animation-scale-down {
156 | animation: scaleDown 0.5s forwards;
157 | }
158 |
159 | .outside-link {
160 | color: wheat;
161 | text-decoration: none;
162 | }
163 |
164 | .row-flex {
165 | display: flex;
166 | flex-direction: row;
167 | }
168 |
169 | @keyframes fadeUp {
170 | 0% {
171 | opacity: 0;
172 | }
173 | 100% {
174 | opacity: 1;
175 | }
176 | }
177 |
178 | .ms-Overlay {
179 | animation: fadeUp forwards 0.5s;
180 | -webkit-backdrop-filter: blur(0.8rem);
181 | backdrop-filter: blur(0.8rem);
182 | background-color: var(--color-black-op-20);
183 | }
184 |
185 | .wave {
186 | animation-name: wave-animation; /* Refers to the name of your @keyframes element below */
187 | animation-duration: 2.5s; /* Change to speed up or slow down */
188 | animation-iteration-count: infinite; /* Never stop waving :) */
189 | transform-origin: 70% 70%; /* Pivot around the bottom-left palm */
190 | display: inline-block;
191 | }
192 |
193 | @keyframes wave-animation {
194 | 0% {
195 | transform: rotate(0deg);
196 | }
197 | 10% {
198 | transform: rotate(14deg);
199 | } /* The following five values can be played with to make the waving more or less extreme */
200 | 20% {
201 | transform: rotate(-8deg);
202 | }
203 | 30% {
204 | transform: rotate(14deg);
205 | }
206 | 40% {
207 | transform: rotate(-4deg);
208 | }
209 | 50% {
210 | transform: rotate(10deg);
211 | }
212 | 60% {
213 | transform: rotate(0deg);
214 | } /* Reset for the last half to pause */
215 | 100% {
216 | transform: rotate(0deg);
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
87 |
--------------------------------------------------------------------------------
/src/pages/CustomizationPage/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { LeftPanel, MiddleSection, RightPanel } from '../../components'
4 | import { getInvoiceSettings } from '../../services/settingsService'
5 | import { ISET } from '../../utils/constants'
6 |
7 | import './index.scss'
8 |
9 | const CustomizationPage = () => {
10 | const [selectedItem, setSelectedItem] = useState()
11 | const [selectedItemIdx, setSelectedItemIdx] = useState()
12 | // eslint-disable-next-line no-unused-vars
13 | const [currentSettings, setCurrentSettings] = useState(getInvoiceSettings())
14 | const [printSettings, setPrintSettings] = useState(getInvoiceSettings(ISET.PRINT))
15 | const [calcSettings, setCalcSettings] = useState(getInvoiceSettings(ISET.CALC))
16 | const [footerPrintSettings, setFooterPrintSettings] = useState(getInvoiceSettings(ISET.FOOTER))
17 |
18 | const getNewSettings = (type) => {
19 | if (type === ISET.PRINT) return { ...printSettings }
20 | if (type === ISET.CALC) return { ...calcSettings }
21 | if (type === ISET.FOOTER) return { ...footerPrintSettings }
22 | return [...currentSettings]
23 | }
24 |
25 | const handleChange = (index, key, value, type = ISET.MAIN) => {
26 | const newSettings = getNewSettings(type)
27 |
28 | if (type !== ISET.MAIN) {
29 | newSettings[key] = value
30 | if (type === ISET.PRINT) {
31 | setPrintSettings(newSettings)
32 | } else if (type === ISET.CALC) {
33 | setCalcSettings(newSettings)
34 | } else if (type === ISET.FOOTER) {
35 | setFooterPrintSettings(newSettings)
36 | }
37 | } else {
38 | newSettings[index][key] = value
39 | setCurrentSettings(newSettings)
40 | }
41 | localStorage.setItem(type, JSON.stringify(newSettings))
42 | }
43 |
44 | const onSelect = (item, idx) => {
45 | setSelectedItem(item)
46 | setSelectedItemIdx(idx)
47 | }
48 |
49 | return (
50 |
51 |
56 |
61 |
66 |
67 | )
68 | }
69 |
70 | export default CustomizationPage
71 |
--------------------------------------------------------------------------------
/src/pages/CustomizationPage/index.scss:
--------------------------------------------------------------------------------
1 | .customization-page {
2 | display: flex;
3 | justify-content: space-between;
4 | height: calc(100% - 5rem);
5 | padding-top: 5rem;
6 | color: var(--color-white);
7 | background-color: var(--color-darker);
8 | overflow: hidden;
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/HomePage/index.js:
--------------------------------------------------------------------------------
1 | import './index.scss'
2 | import React, { useState } from 'react'
3 |
4 | import { useConstCallback } from '@uifabric/react-hooks'
5 | import { Panel } from 'office-ui-fabric-react/lib/Panel'
6 | import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'
7 | import { Document, Page } from 'react-pdf'
8 |
9 | import { Invoice, LockScreen } from '../../components'
10 | import { useAuthContext } from '../../contexts'
11 |
12 | const HomePage = () => {
13 | const [preview, setPreview] = useState('')
14 | const [authState] = useAuthContext()
15 | const [isPreviewOpen, setIsPreviewOpen] = useState(false)
16 |
17 | const openPreviewPanel = useConstCallback(() => setIsPreviewOpen(true))
18 |
19 | const dismissPreviewPanel = useConstCallback(() => setIsPreviewOpen(false))
20 |
21 | const showPdfPreview = (pdfBytes) => {
22 | setPreview(pdfBytes)
23 | openPreviewPanel()
24 | }
25 |
26 | if (!authState.isAuthenticated) {
27 | return
28 | }
29 |
30 | return (
31 |
34 |
37 |
45 | {preview?.length
46 | ? (
47 |
58 | )}
59 | >
60 |
64 |
65 | ) : (
66 |
67 |
Invoice Preview
68 |
69 | )}
70 |
71 |
72 | )
73 | }
74 |
75 | export default HomePage
76 |
--------------------------------------------------------------------------------
/src/pages/HomePage/index.scss:
--------------------------------------------------------------------------------
1 | .home-page {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: space-between;
5 | height: calc(100% - 10rem);
6 | padding-top: 10rem;
7 |
8 | &__preview-panel {
9 | .ms-Panel-main {
10 | width: 50rem;
11 | }
12 |
13 | &__doc {
14 | margin-top: 3rem;
15 | }
16 | }
17 |
18 | &__content {
19 | width: 100%;
20 | height: 100%;
21 | display: flex;
22 | align-items: center;
23 | color: var(--color-white);
24 | font-size: 2rem;
25 | }
26 |
27 | .preview-area {
28 | height: 53.5rem;
29 | width: 38rem;
30 | border: 1px dashed var(--color-white);
31 | color: var(--color-white);
32 | display: grid;
33 | font-size: 3rem;
34 | place-items: center;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/InvoiceSettings/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'
4 | import { Icon } from 'office-ui-fabric-react/lib/Icon'
5 | import { Separator } from 'office-ui-fabric-react/lib/Separator'
6 | import { Stack } from 'office-ui-fabric-react/lib/Stack'
7 | import { TextField } from 'office-ui-fabric-react/lib/TextField'
8 | import { Toggle } from 'office-ui-fabric-react/lib/Toggle'
9 | import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'
10 |
11 | import { getFromStorage } from '../../services/dbService'
12 | import { getInvoiceSettings } from '../../services/settingsService'
13 | import {
14 | fieldTypes, ISET, MASKED,
15 | } from '../../utils/constants'
16 | import { titleCase } from '../../utils/utils'
17 | import './index.scss'
18 |
19 | const deviceWidth = document.documentElement.clientWidth
20 | const token = {
21 | tokens: { childrenGap: deviceWidth * 0.02 },
22 | styles: { root: { width: deviceWidth * 0.9 } },
23 | }
24 |
25 | const InvoiceSettings = () => {
26 | const [currentSettings, setCurrentSettings] = useState(getInvoiceSettings())
27 | const [printSettings, setPrintSettings] = useState(getInvoiceSettings(ISET.PRINT))
28 | const [calcSettings, setCalcSettings] = useState(getInvoiceSettings(ISET.CALC))
29 | const [footerPrintSettings, setFooterPrintSettings] = useState(getInvoiceSettings(ISET.FOOTER))
30 | const [opFree, setOpFree] = useState(getFromStorage('oldPurchaseFreedom'))
31 |
32 | const getNewSettings = (type) => {
33 | if (type === ISET.PRINT) return { ...printSettings }
34 | if (type === ISET.CALC) return { ...calcSettings }
35 | if (type === ISET.FOOTER) return { ...footerPrintSettings }
36 | return [...currentSettings]
37 | }
38 |
39 | const onChangeOpFree = (_, c) => {
40 | localStorage.oldPurchaseFreedom = c
41 | setOpFree(c)
42 | }
43 |
44 | const handleChange = (index, key, value, type = ISET.MAIN) => {
45 | const newSettings = getNewSettings(type)
46 |
47 | if (type !== ISET.MAIN) {
48 | newSettings[key] = value
49 | if (type === ISET.PRINT) {
50 | setPrintSettings(newSettings)
51 | } else if (type === ISET.CALC) {
52 | setCalcSettings(newSettings)
53 | } else if (type === ISET.FOOTER) {
54 | setFooterPrintSettings(newSettings)
55 | }
56 | } else {
57 | newSettings[index][key] = value
58 | setCurrentSettings(newSettings)
59 | }
60 | localStorage.setItem(type, JSON.stringify(newSettings))
61 | }
62 |
63 | return (
64 |
65 | Invoice Meta
66 |
67 | {currentSettings.map((setting, idx) => (
68 |
74 | handleChange(idx, 'name', val)}
77 | value={setting.name}
78 | disabled={setting.disableNameChange}
79 | />
80 | handleChange(idx, 'row', val)}
83 | value={setting.row}
84 | />
85 | handleChange(idx, 'x', val)}
88 | value={setting.x}
89 | />
90 | handleChange(idx, 'y', val)}
93 | value={setting.y}
94 | />
95 | handleChange(idx, 'required', val)}
99 | />
100 | handleChange(idx, 'disabled', val)}
104 | />
105 | handleChange(idx, 'type', val.key)}
111 | />
112 | handleChange(idx, 'size', val)}
115 | value={setting.size}
116 | />
117 | { setting.type === MASKED
118 | ? (
119 | handleChange(idx, 'mask', val)}
123 | />
124 | ) : ''}
125 |
126 | ))}
127 |
128 |
129 | Invoice Item & Copy Mark
130 |
131 |
132 |
137 | {Object.keys(printSettings).map((key) => (
138 | handleChange(0, key, parseFloat(val), ISET.PRINT)}
142 | value={printSettings[key]}
143 | />
144 | ))}
145 |
146 |
147 |
148 | Invoice Footer
149 |
150 |
151 |
154 | {Object.keys(footerPrintSettings).map((key) => (
155 |
160 | {Object.keys(footerPrintSettings[key]).map((subkey) => (
161 | handleChange(0, key,
165 | {
166 | ...footerPrintSettings[key],
167 | // eslint-disable-next-line no-restricted-globals
168 | [subkey]: isNaN(parseFloat(val)) ? 0 : parseFloat(val),
169 | }, ISET.FOOTER)}
170 | value={footerPrintSettings[key][subkey]}
171 | />
172 | ))}
173 |
174 | ))}
175 |
176 |
177 |
178 | Calculation Settings
179 |
180 |
181 |
186 | {Object.keys(calcSettings).map((key) => (
187 | handleChange(0, key, val, ISET.CALC)}
191 | value={calcSettings[key]}
192 | />
193 | ))}
194 |
199 | Return Item without meta
200 | {' '}
201 |
202 |
203 |
204 |
205 | )}
206 | />
207 |
208 |
209 |
210 | )
211 | }
212 |
213 | export default InvoiceSettings
214 |
--------------------------------------------------------------------------------
/src/pages/InvoiceSettings/index.scss:
--------------------------------------------------------------------------------
1 | .invoice-settings {
2 | padding: 6rem 2rem 0 2rem;
3 | }
4 |
--------------------------------------------------------------------------------
/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import CustomizationPage from './CustomizationPage'
2 | import HomePage from './HomePage'
3 | import InvoiceSettings from './InvoiceSettings'
4 |
5 | export { HomePage, InvoiceSettings, CustomizationPage }
6 |
--------------------------------------------------------------------------------
/src/services/apiService.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Creates a user in the db
3 | * @param {string} name of the company or individual
4 | * @param {email} email used to set/reset password with OTP
5 | * @returns {Promise}
6 | */
7 | const createUser = (name, email) => {
8 | const url = `${process.env.REACT_APP_API_URL}/users`
9 | return fetch(url, {
10 | method: 'POST',
11 | headers: {
12 | 'Content-Type': 'application/json',
13 | },
14 | body: JSON.stringify({ name, email }),
15 | }).then((response) => response.json())
16 | }
17 |
18 | /**
19 | * Verifies user email a user in the db
20 | * @param {email} email used to set/reset password with OTP
21 | * @param {otp} otp sent to the email
22 | * @param {sessionId} sessionId of current session
23 | * @returns {Promise}
24 | */
25 | const verifyOtp = (email, otp, sessionId) => {
26 | const url = `${process.env.REACT_APP_API_URL}/users/verify`
27 | return fetch(url, {
28 | method: 'POST',
29 | headers: {
30 | 'Content-Type': 'application/json',
31 | },
32 | body: JSON.stringify({ email, otp, sessionId }),
33 | }).then((response) => response.json())
34 | }
35 |
36 | export {
37 | createUser, verifyOtp,
38 | }
39 |
--------------------------------------------------------------------------------
/src/services/dbService.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-restricted-globals */
2 | import { getBoolFromString } from '../utils/utils'
3 | import { getPrintersList } from './nodeService'
4 |
5 | /**
6 | * @param {string} key Name of DB key to access
7 | * @param {string} [type] Type of DB attribute (num | json).
8 | * String and bool-strings handled automatically
9 | * @return returns the value of key from DB casted to
10 | * type specified
11 | */
12 | const getFromStorage = (key, type) => {
13 | const value = localStorage[key]
14 | if (type === 'num') {
15 | const intVal = parseInt(value, 10)
16 | return isNaN(intVal) ? 1 : intVal
17 | }
18 | if (type === 'json') {
19 | try {
20 | return JSON.parse(value)
21 | } catch (e) {
22 | return null
23 | }
24 | }
25 |
26 | return getBoolFromString(value)
27 | }
28 |
29 | /** @return Return Parsed Array of Products from DB */
30 | const getProductsJSON = () => getFromStorage('products', 'json') || []
31 |
32 | /**
33 | * @return Fetches Product Type Array String from DB
34 | * and converts it into usable key, text object
35 | */
36 | const getProductTypes = () => getFromStorage('productType')?.split(',')?.map((type) => ({
37 | key: type.trim(),
38 | text: type.trim(),
39 | })) || []
40 |
41 | /**
42 | * @param {string} val Name of DB key to access
43 | * @param {boolean} [format] Flag on whether or not to format the amount.
44 | * @return String containing Currency Symbol as per DB
45 | * then number formatted as per Indian Currency standard
46 | */
47 | const currency = (val, format) => {
48 | const parsedCurrency = isNaN(parseFloat(val))
49 | ? 0 : Math.round(parseFloat(val) * 100) / 100
50 |
51 | return format ? `${getFromStorage('currency') || ''} ${new Intl.NumberFormat('en-IN', {
52 | currency: 'INR',
53 | }).format(parsedCurrency)}` : parsedCurrency
54 | }
55 |
56 | /**
57 | * Adds/Replace Array of Products in DB
58 | * @param {Array} newProducts Array of Product Objects
59 | * @param {boolean} [replace] Flag indicating whether to replace all.
60 | */
61 | const setProducts = (newProducts, replace) => {
62 | const products = (getProductsJSON())
63 | localStorage.setItem('products',
64 | JSON.stringify(replace
65 | ? newProducts
66 | : [...products, ...newProducts]))
67 | }
68 |
69 | /**
70 | * Alters the Product with same ID or
71 | * inserts a new one if doesn't exist
72 | * @param {object} product Product Object to be added in DB
73 | */
74 | const upsertProduct = (product) => {
75 | const products = getProductsJSON()
76 | let isFound = false
77 |
78 | const newProducts = products.map((p) => {
79 | if (p.id === product.id) {
80 | isFound = true
81 | return product
82 | }
83 |
84 | return p
85 | })
86 |
87 | if (!isFound) newProducts.push(product)
88 |
89 | setProducts(newProducts, true)
90 | }
91 |
92 | /**
93 | * Deletes Product with id in ids array from DB
94 | * @param {Array} ids Array of Product IDs
95 | */
96 | const deleteProducts = (ids) => {
97 | const products = (getProductsJSON())
98 | .filter((p) => !ids.includes(p.id))
99 | setProducts(products, true)
100 | }
101 |
102 | /**
103 | * Returns Product with id if `id` is given
104 | * else returns Array of all Products
105 | * @param {string} [id] id of Product to be fetched
106 | * @return Single Product with id as `id` or
107 | * array of Products
108 | */
109 | const getProducts = (id) => {
110 | const products = getProductsJSON()
111 | if (!id) {
112 | return products
113 | }
114 | const [product] = products.filter((p) => p.id === id)
115 | return product
116 | }
117 |
118 | /**
119 | * @async
120 | * Fetches Printers from Client PC and modifies
121 | * the list so that we can use it to render options
122 | * @return Array of Fabric UI Friendly Objects
123 | * helping in rendering Printer Options
124 | */
125 | const printerList = async () => {
126 | const list = await getPrintersList()
127 | const getIcon = (name) => {
128 | if (name.includes('Fax')) return 'fax'
129 | if (name.includes('to PDF')) return 'pdf'
130 | if (name.includes('OneNote')) return 'OneNoteLogo16'
131 | if (name.includes('Cloud')) return 'Cloud'
132 | return 'print'
133 | }
134 | return list.map((key) => ({
135 | key,
136 | text: key,
137 | canCheck: true,
138 | iconProps: { iconName: getIcon(key) },
139 | }))
140 | }
141 |
142 | /**
143 | * @async
144 | * Sets the Printers List to DB
145 | */
146 | const updatePrinterList = async () => {
147 | localStorage.printers = JSON.stringify(await printerList())
148 | }
149 |
150 | /**
151 | * Fetch Update Details from storage
152 | * @return JSON Object with Update Details
153 | */
154 | const getUpdateInfo = () => getFromStorage('updateInfo', 'json')
155 |
156 | /**
157 | * Updates Update Object in Storage
158 | * @param {Object} data, Object to be updated
159 | * @param {string} [key], key to be updated
160 | * @return JSON Object with Update Details
161 | */
162 | const editUpdateInfo = (data, key) => {
163 | let currentObj = getUpdateInfo() ?? {}
164 | if (key) {
165 | currentObj[key] = data
166 | } else {
167 | currentObj = data
168 | }
169 | localStorage.setItem('updateInfo', JSON.stringify(currentObj))
170 | }
171 |
172 | export {
173 | getFromStorage, getProductTypes, currency,
174 | setProducts, upsertProduct, deleteProducts, getProducts,
175 | updatePrinterList, getUpdateInfo, editUpdateInfo,
176 | getProductsJSON,
177 | }
178 |
--------------------------------------------------------------------------------
/src/services/nodeService.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require('electron')
2 |
3 | /** Toggles Fullscreen state of application */
4 | const toggleFullScreen = () => ipcRenderer.send('toggle-fullscreen')
5 |
6 | /** Force Sayonara! */
7 | const quitApp = () => ipcRenderer.send('bye-bye')
8 |
9 | /** Minimizes Client Application to Taskbar */
10 | const minimizeApp = () => ipcRenderer.send('shut-up')
11 |
12 | /** Restarts App, known to be called after update */
13 | const restartApp = () => ipcRenderer.send('restart_app')
14 |
15 | /**
16 | * @async
17 | * Retrievs Installed Printers List from OS
18 | * @return Array of Strings (Printer Names)
19 | */
20 | const getPrintersList = async () => ipcRenderer.invoke('get-printers')
21 |
22 | /**
23 | * @async
24 | * Checks if path given exists in User's machine
25 | * @param {string} path Path to file/folder
26 | * @return true if exits and valid else false
27 | */
28 | const isValidPath = async (path) => path && ipcRenderer.invoke('file:is-valid', path)
29 |
30 | /**
31 | * @async
32 | * Opens Save File Modal to save CSV with data in it
33 | * @param {Object} data JSON Object to be added as CSV
34 | * @param {boolean} fileFilter File Types to be allowed while saving
35 | * @param {boolean} disableAllFiles Boolean to check whether to allow *
36 | */
37 | const saveCSV = async (data, fileFilter, disableAllFiles, fileName) => {
38 | // Don't export fields like 'id'
39 | const keys = Object.keys(data[0]).filter((k) => k !== 'id')
40 | // Print all keys first
41 | let csvData = `${keys.join(',')}\n`
42 |
43 | // Now Print each row in file
44 | data.forEach((d) => {
45 | keys.forEach((k) => {
46 | csvData += `${d[k]},`
47 | })
48 | csvData = `${csvData.slice(0, -1)}\n`
49 | })
50 |
51 | ipcRenderer.invoke('file:save', fileFilter, disableAllFiles, csvData, fileName)
52 | }
53 |
54 | /**
55 | * @async
56 | * Reads File Content using Node and turns them in Buffer
57 | * @param {string} Path to File (in public/)
58 | * @return Buffer content of file
59 | */
60 | const getFileBuffer = async (file) => ipcRenderer.invoke('file:read-buffer', file)
61 |
62 | /**
63 | * @async
64 | * Reads File Content using Node and turns them in Base64
65 | * @param {string} Path to File
66 | * @return Base64 content of file
67 | */
68 | const getB64File = async (file) => ipcRenderer.invoke('file:read-b64', file)
69 |
70 | /**
71 | * @async
72 | * Fetches application version from App Context
73 | * @return String containing version of App (Eg: v0.4.2)
74 | */
75 | const getAppVersion = async () => ipcRenderer.invoke('app:version')
76 |
77 | /**
78 | * @async
79 | * Fetches Default Printer from OS
80 | * @return Name of Default Printer
81 | */
82 | const getDefPrinter = async () => ipcRenderer.invoke('printers:get-default')
83 |
84 | /**
85 | * @async
86 | * Prints `content` buffer using `printer`
87 | * @param {UInt8} content Buffer of PDF to be printed
88 | * @param {string} printer Name of Printer to be used
89 | */
90 | const printIt = async (content, printer) => ipcRenderer.invoke('printers:print', content, printer)
91 |
92 | export {
93 | toggleFullScreen, quitApp, restartApp,
94 | minimizeApp, getPrintersList, isValidPath, getFileBuffer,
95 | getAppVersion, getDefPrinter, printIt, getB64File, saveCSV,
96 | }
97 |
--------------------------------------------------------------------------------
/src/services/pdfService.js:
--------------------------------------------------------------------------------
1 | import fontkit from '@pdf-lib/fontkit'
2 | import * as toWords from 'convert-rupees-into-words'
3 | import { PDFDocument } from 'pdf-lib'
4 |
5 | import {
6 | COMPANY_NAME,
7 | CUSTOM_FONT, DATE, defaultPageSettings,
8 | FILE_TYPE, ISET, MAX_ITEM_WIDTH,
9 | PAY_METHOD, PREVIEW, PRINT,
10 | } from '../utils/constants'
11 | import { getBoolFromString } from '../utils/utils'
12 | import { currency, getFromStorage, getProducts } from './dbService'
13 | import { getFileBuffer, isValidPath, printIt } from './nodeService'
14 | import { getInvoiceSettings } from './settingsService'
15 |
16 | /**
17 | * @async
18 | * @return Buffer of Selected Font
19 | */
20 | const getFontBuffer = async () => {
21 | const selectedFont = getFromStorage(FILE_TYPE.FONT)
22 | return (selectedFont !== CUSTOM_FONT && await isValidPath(selectedFont))
23 | ? getFileBuffer(selectedFont)
24 | : fetch(CUSTOM_FONT).then((res) => res.arrayBuffer())
25 | }
26 |
27 | /**
28 | * @param {Date} date Date to be modified
29 | * @return Formmatted Date in HI/EN as per user pref.
30 | */
31 | const getInvoiceDate = (date = new Date()) => {
32 | const options = {
33 | year: 'numeric', month: 'long', day: 'numeric',
34 | }
35 | const hindiDate = getFromStorage('hindiDate')
36 | const showFullMonth = getFromStorage('showFullMonth')
37 | return showFullMonth
38 | ? date.toLocaleDateString(`${hindiDate ? 'hi' : 'en'}-IN`, options)
39 | : `${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}`
40 | }
41 |
42 | /**
43 | * @param {string} previewPath Path of BG of PDF
44 | * @param {boolean} isPreviewMode represents
45 | * @return pdfDoc generated by pdf-lib
46 | */
47 | const getPdfDoc = async (previewPath, isPreviewMode) => {
48 | let pdfDoc
49 | const isValid = await isValidPath(previewPath)
50 | if (isPreviewMode) {
51 | if (!isValid) {
52 | return { error: 'Please fix Preview PDF Path in Settings' }
53 | }
54 | const existingPdfBytes = await getFileBuffer(previewPath)
55 | pdfDoc = await PDFDocument.load(existingPdfBytes)
56 | } else {
57 | pdfDoc = await PDFDocument.create()
58 | }
59 | pdfDoc.registerFontkit(fontkit)
60 | return pdfDoc
61 | }
62 |
63 | /**
64 | * @param {string} mode Mode of PDF
65 | * @param {object} page Page object of PDF
66 | * @param {object} printSettings Get printing settings
67 | * @param {string} font Base64 buffer of font
68 | * @param {number} fontSize represents font size
69 | */
70 | const printDuplicate = async (mode, page, printSettings, font, fontSize) => {
71 | const copyText = `[${mode === PREVIEW ? 'Duplicate' : 'Original'} Invoice]`
72 | page.drawText(copyText, {
73 | x: printSettings.copyTypeXEnd - (font.widthOfTextAtSize(copyText, fontSize)),
74 | y: printSettings.copyTypeY,
75 | size: fontSize,
76 | font,
77 | })
78 | }
79 |
80 | /**
81 | * @param {object} page Page object of PDF
82 | * @param {object} meta Invoice Header info
83 | * @param {string} font Base64 buffer of font
84 | * @param {number} fontSize represents font size
85 | */
86 | const makeInvoiceHeader = (page, meta, font, fontSize) => {
87 | getInvoiceSettings().forEach((field) => {
88 | if (meta[field.name]) {
89 | const value = field.type === DATE
90 | ? getInvoiceDate(meta[field.name])
91 | : meta[field.name].toString()
92 | page.drawText(value, {
93 | x: parseFloat(field.x),
94 | y: parseFloat(field.y),
95 | size: parseFloat(field.size) ?? fontSize,
96 | font,
97 | })
98 | }
99 | })
100 | }
101 |
102 | /**
103 | * @param {object} page Page object of PDF
104 | * @param {Array} items Detailed list of invoice item
105 | * @param {object} printSettings Get printing settings
106 | * @param {string} font Base64 buffer of font
107 | * @param {number} fontSize represents font size
108 | */
109 | const printItems = (page, items, printSettings, font, fontSize) => {
110 | const commonFont = { font, size: fontSize }
111 | let idx = -1
112 | items.filter((it) => !it.isOldItem).forEach((item, serial) => {
113 | idx += 1
114 | const commonStuff = (x, text, fromStart) => {
115 | const stringifiedText = text.toString()
116 | const adjustment = !fromStart ? (font.widthOfTextAtSize(stringifiedText, fontSize)) : 0
117 | return [stringifiedText,
118 | {
119 | x: parseFloat(x - adjustment),
120 | y: parseFloat(printSettings.itemStartY - idx * printSettings.diffBetweenItemsY),
121 | ...commonFont,
122 | },
123 | ]
124 | }
125 |
126 | const product = getProducts(item.product)
127 | if (product?.name) {
128 | page.drawText(...commonStuff(45, (serial + 1)), true)
129 | // type at the end of col
130 | page.drawText(...commonStuff(190, `[${product?.type}]`, true))
131 | page.drawText(...commonStuff(232, item.quantity))
132 | page.drawText(...commonStuff(283, item.gWeight))
133 | page.drawText(...commonStuff(333, item.weight))
134 | page.drawText(...commonStuff(380, `${currency(item.price, true)}/-`))
135 | page.drawText(...commonStuff(428, `${currency(item.mkg)}%`))
136 | page.drawText(...commonStuff(478, `${currency(item.other, true)}/-`))
137 | page.drawText(...commonStuff(560, `${currency(item.totalPrice, true)}/-`))
138 |
139 | if (font.widthOfTextAtSize(`${product?.name}`, fontSize) > MAX_ITEM_WIDTH) {
140 | let toPrint = ''
141 | const bits = product?.name?.split(' ')
142 | let maxWidth = MAX_ITEM_WIDTH
143 | bits.forEach((bit) => {
144 | if (font.widthOfTextAtSize(`${toPrint} ${bit}`, fontSize) > maxWidth) {
145 | page.drawText(...commonStuff(70, toPrint, true))
146 | toPrint = ''
147 | idx += 1
148 | maxWidth = MAX_ITEM_WIDTH + font.widthOfTextAtSize(`[${product?.type}]`, fontSize)
149 | }
150 | toPrint += `${toPrint.length ? ' ' : ''}${bit}`
151 | })
152 | if (toPrint) {
153 | page.drawText(...commonStuff(70, toPrint, true))
154 | }
155 | } else {
156 | page.drawText(...commonStuff(70, `${product?.name}`, true))
157 | }
158 | }
159 | })
160 | }
161 |
162 | /**
163 | * @param {object} page Page object of PDF
164 | * @param {object} footer Footer details
165 | * @param {object} printSettings Get printing settings
166 | * @param {string} font Base64 buffer of font
167 | * @param {number} fontSize represents font size
168 | */
169 | const printFooter = (page, footer, printSettings, font, fontSize) => {
170 | const commonFont = { font, size: fontSize }
171 | const footerCommonParts = (y, key, x) => {
172 | const text = `${currency(footer[key])}/-`
173 | return [
174 | text,
175 | {
176 | x: x ?? parseFloat(printSettings.endAmountsX - font.widthOfTextAtSize(text, fontSize)),
177 | y,
178 | ...commonFont,
179 | },
180 | ]
181 | }
182 |
183 | page.drawText(...footerCommonParts(230, 'grossTotal'))
184 | page.drawText(...footerCommonParts(210, 'cgst'))
185 | page.drawText(...footerCommonParts(190, 'sgst'))
186 | page.drawText(...footerCommonParts(170, 'igst'))
187 | page.drawText(...footerCommonParts(148, 'totalAmount'))
188 | page.drawText(...footerCommonParts(128, 'oldPurchase'))
189 | page.drawText(...footerCommonParts(108, 'grandTotal'))
190 |
191 | const calcSettings = getInvoiceSettings(ISET.CALC)
192 |
193 | const towWordsText = getBoolFromString(calcSettings.roundOffToWords)
194 | ? Math.round(Math.abs(footer.grandTotal)) : Math.abs(footer.grandTotal)
195 |
196 | page.drawText(`${footer.grandTotal < 0 ? 'Minus ' : ''}${toWords(towWordsText)}`, {
197 | x: 92,
198 | y: 88,
199 | ...commonFont,
200 | })
201 | }
202 |
203 | /**
204 | * @param {object} page Page object of PDF
205 | * @param {Array} items Detailed list of invoice item
206 | * @param {object} commonFont represents font size & buffer
207 | */
208 | const printOldPurchase = (page, items, commonFont) => {
209 | const oldItems = {}
210 | const oldItemList = items.filter((it) => it.isOldItem)
211 | oldItemList.forEach((item) => {
212 | oldItems.names = (oldItems.names?.length ? `${oldItems.names}, ` : '') + item.type
213 | oldItems.purity = `${oldItems.purity?.length ? `${oldItems.purity}, ` : ''}${item.purity}%`
214 | oldItems.price = `${oldItems.price?.length ? `${oldItems.price}, ` : ''}${currency(item.price)}/-`
215 | oldItems.gWeight = `${oldItems.gWeight?.length ? `${oldItems.gWeight}, ` : ''}${item.gWeight}g`
216 | oldItems.weight = `${oldItems.weight?.length ? `${oldItems.weight}, ` : ''}${item.weight}g`
217 | oldItems.totalPrice = `${oldItems.totalPrice?.length ? `${oldItems.totalPrice}, ` : ''}${currency(item.totalPrice)}/-`
218 | })
219 |
220 | if (oldItemList.length) {
221 | page.drawText(oldItems.names, {
222 | x: 67,
223 | y: 150,
224 | ...commonFont,
225 | })
226 |
227 | page.drawText(oldItems.purity, {
228 | x: 73,
229 | y: 130,
230 | ...commonFont,
231 | })
232 | page.drawText(oldItems.price, {
233 | x: 216,
234 | y: 130,
235 | ...commonFont,
236 | })
237 | page.drawText(oldItems.gWeight, {
238 | x: 110,
239 | y: 109,
240 | ...commonFont,
241 | })
242 | page.drawText(oldItems.weight, {
243 | x: 248,
244 | y: 109,
245 | ...commonFont,
246 | })
247 | page.drawText(oldItems.totalPrice, {
248 | x: 348,
249 | y: 109,
250 | ...commonFont,
251 | })
252 | }
253 | }
254 |
255 | /**
256 | * @param {object} page Page object of PDF
257 | * @param {object} footer Footer details
258 | * @param {string} font Base64 buffer of font
259 | * @param {number} fontSize represents font size
260 | */
261 | const printPaymentDist = (page, footer, font, fontSize) => {
262 | const commonFont = { font, size: fontSize }
263 | const startX = 210
264 | const yLimit = 190
265 | let startY = 220
266 | let prevPayLen = 0
267 | let sameLine = false
268 | Object.values(PAY_METHOD).forEach((item) => {
269 | const isCN = item === PAY_METHOD.CHEQUENO
270 | if (isCN && !footer[item]) {
271 | startY -= 15
272 | } else if (footer[item]) {
273 | const textContent = `${isCN ? 'Chq No:' : item.toUpperCase()}: ${+footer[item]} ${isCN ? '' : '/-'}`
274 | const currX = startX + ((isCN || sameLine) ? (prevPayLen + 10) : 0)
275 |
276 | if (isCN && !footer[PAY_METHOD.CHEQUE]) {
277 | // eslint-disable-next-line no-console
278 | console.log('No Cheques Please')
279 | } else {
280 | page.drawText(
281 | textContent, {
282 | x: currX,
283 | y: startY,
284 | ...commonFont,
285 | },
286 | )
287 |
288 | if (isCN) {
289 | page.drawLine({
290 | start: {
291 | x: currX,
292 | y: startY - 2.3,
293 | },
294 | end: {
295 | y: startY - 2.3,
296 | x: currX + 90,
297 | },
298 | thickness: 2,
299 | opacity: 0.75,
300 | })
301 | }
302 | sameLine = (startY === yLimit)
303 | startY -= ((item === PAY_METHOD.CHEQUE) || sameLine ? 0 : 15)
304 | prevPayLen = font.widthOfTextAtSize(textContent, fontSize)
305 | }
306 | }
307 | })
308 | }
309 |
310 | /**
311 | * @param {object} invoiceDetails All details of Invoice in State
312 | * like, meta, itemDetail, footer, all pay details etc.
313 | * @param {string} mode Mode of PDF generated
314 | * @return Base64 of Generated PDF(s)
315 | */
316 | const getPdf = async (invoiceDetails, mode = PRINT) => {
317 | const { meta, items, footer } = invoiceDetails
318 | const previewPath = getFromStorage(FILE_TYPE.PDF)
319 | const isPreviewMode = (mode === PREVIEW) && previewPath
320 |
321 | const pdfDoc = await getPdfDoc(previewPath, isPreviewMode)
322 |
323 | const { fontSize } = defaultPageSettings
324 | const font = await pdfDoc.embedFont(await getFontBuffer(), { subset: true })
325 | const commonFont = { font, size: fontSize }
326 | const page = isPreviewMode ? pdfDoc.getPages()[0] : pdfDoc.addPage()
327 |
328 | const printSettings = getInvoiceSettings(ISET.PRINT)
329 |
330 | // Print Invoice Copy Type
331 | printDuplicate(mode, page, printSettings, font, fontSize)
332 |
333 | // Print Invoice Header
334 | if (meta) {
335 | makeInvoiceHeader(page, meta, font, fontSize)
336 | }
337 |
338 | // Print Items
339 | if (items) {
340 | printItems(page, items, printSettings, font, fontSize)
341 | // oldPurchase Stuff
342 | printOldPurchase(page, items, commonFont)
343 | }
344 |
345 | // Print Footer
346 | if (footer) {
347 | printFooter(page, footer, printSettings, font, fontSize)
348 | // Print Distribution
349 | printPaymentDist(page, footer, font, fontSize)
350 | }
351 |
352 | pdfDoc.setTitle('Invoice Preview')
353 | pdfDoc.setAuthor(COMPANY_NAME)
354 |
355 | // Serialize the PDFDocument to base64
356 | return pdfDoc.save()
357 | }
358 |
359 | const printPDF = (pdfBytes) => printIt(pdfBytes, getFromStorage('printer'))
360 |
361 | export { getPdf, printPDF, getInvoiceDate }
362 |
--------------------------------------------------------------------------------
/src/services/settingsService.js:
--------------------------------------------------------------------------------
1 | import {
2 | calculationSettings,
3 | COMPANY_NAME, CUSTOM_FONT, defaultPrintSettings, footerPrintSettings, ISET, morePrintSettings,
4 | } from '../utils/constants'
5 | import { getFromStorage, updatePrinterList } from './dbService'
6 | import { getAppVersion, getDefPrinter } from './nodeService'
7 |
8 | const initializeSettings = async () => {
9 | localStorage.companyName = localStorage.companyName ?? COMPANY_NAME
10 | localStorage.invoiceNumber = localStorage.invoiceNumber ?? 1
11 | localStorage.products = localStorage.products ?? '[]'
12 | localStorage.password = localStorage.password ?? ''
13 | localStorage.showFullMonth = localStorage.showFullMonth ?? true
14 | localStorage.printBoth = localStorage.printBoth ?? true
15 | localStorage.oldPurchaseFreedom = localStorage.oldPurchaseFreedom ?? true
16 | localStorage.email = localStorage.email ?? ''
17 | localStorage.productType = localStorage.productType ?? 'G, S'
18 | localStorage.customFont = localStorage.customFont ?? CUSTOM_FONT
19 | localStorage.customLockBg = localStorage.customLockBg ?? ''
20 | localStorage.currency = localStorage.currency ?? '₹'
21 | localStorage.dateSep = localStorage.dateSep ?? '-'
22 | localStorage.invoiceSettings = localStorage.invoiceSettings
23 | ?? JSON.stringify(defaultPrintSettings)
24 |
25 | localStorage.printer = localStorage.printer ?? await getDefPrinter()
26 | localStorage.morePrintSettings = JSON.stringify({
27 | ...morePrintSettings,
28 | ...(localStorage.morePrintSettings && JSON.parse(localStorage.morePrintSettings)),
29 | })
30 | localStorage.footerPrintSettings = JSON.stringify({
31 | ...footerPrintSettings,
32 | ...(localStorage.footerPrintSettings && JSON.parse(localStorage.footerPrintSettings)),
33 | })
34 | localStorage.calculationSettings = JSON.stringify({
35 | ...calculationSettings,
36 | ...(localStorage.calculationSettings && JSON.parse(localStorage.calculationSettings)),
37 | })
38 | localStorage.version = await getAppVersion()
39 | localStorage.updateInfo = ''
40 | localStorage.nativeGstinPrefix = localStorage.nativeGstinPrefix ?? '08'
41 | await updatePrinterList()
42 | }
43 |
44 | const resetSettings = () => {
45 | const { password, products } = localStorage
46 | localStorage.clear()
47 | localStorage.password = password
48 | localStorage.products = products
49 | initializeSettings()
50 | }
51 |
52 | const getInvoiceSettings = (type = ISET.MAIN) => getFromStorage(type, 'json') ?? []
53 |
54 | export { getInvoiceSettings, resetSettings, initializeSettings }
55 |
--------------------------------------------------------------------------------
/src/utils/constants.js:
--------------------------------------------------------------------------------
1 | const PREVIEW = 'preview'
2 | const PRINT = 'print'
3 | const DATE = 'Date'
4 | const TEXT = 'Text'
5 | const MASKED = 'Masked'
6 | const CUSTOM_FONT = 'invoicify.ttf'
7 | const COMPANY_NAME = '2AM Devs'
8 | const ZERO = parseFloat(0)
9 | const UPDATE_RESTART_MSG = 'Update Downloaded. It will be installed on restart. Restart now?'
10 |
11 | const ISET = {
12 | MAIN: 'invoiceSettings',
13 | PRINT: 'morePrintSettings',
14 | CALC: 'calculationSettings',
15 | FOOTER: 'footerPrintSettings',
16 | }
17 |
18 | const ERROR = {
19 | FILE_MOVED: 'File Selected is either moved or renamed.',
20 | }
21 |
22 | const PAY_METHOD = {
23 | CASH: 'cash',
24 | CHEQUE: 'cheque',
25 | CHEQUENO: 'chequeNumber',
26 | CREDIT: 'credit',
27 | UPI: 'upi',
28 | CARD: 'card',
29 | }
30 |
31 | const FILE_TYPE = {
32 | PDF: 'previewPDFUrl',
33 | FONT: 'customFont',
34 | IMG: 'customLockBg',
35 | }
36 |
37 | const SELECT_FILE_TYPE = {
38 | EXCEL: { name: 'Spreadsheets', extensions: ['xlsx', 'xls', 'csv'] },
39 | PDF: { name: 'PDF', extensions: ['pdf'] },
40 | FONT: { name: 'Fonts', extensions: ['ttf', 'otf'] },
41 | IMG: { name: 'Pictures', extensions: ['png', 'jpeg', 'gif', 'jpg'] },
42 | }
43 |
44 | const MAX_ITEM_WIDTH = 117
45 |
46 | const darkThemePalette = {
47 | themePrimary: '#209cfa',
48 | themeLighterAlt: '#01060a',
49 | themeLighter: '#051928',
50 | themeLight: '#0a2f4b',
51 | themeTertiary: '#135d96',
52 | themeSecondary: '#1d89dc',
53 | themeDarkAlt: '#36a5fa',
54 | themeDark: '#55b3fb',
55 | themeDarker: '#81c7fc',
56 | neutralLighterAlt: '#23272A',
57 | neutralLighter: '#72767d',
58 | neutralLight: '#4f545c',
59 | neutralQuaternaryAlt: '#0d0d0d',
60 | neutralQuaternary: '#c7c7c7',
61 | neutralTertiaryAlt: '#72767d',
62 | neutralTertiary: '#b9bbbe',
63 | neutralSecondary: '#fcfcfc',
64 | neutralPrimaryAlt: '#fdfdfd',
65 | neutralPrimary: '#fafafa',
66 | neutralDark: '#fefefe',
67 | black: '#fefefe',
68 | white: '#23272A',
69 | }
70 |
71 | const productTableColumns = [
72 | {
73 | key: 'column1',
74 | name: 'Name',
75 | fieldName: 'name',
76 | minWidth: 210,
77 | maxWidth: 350,
78 | isRowHeader: true,
79 | isResizable: true,
80 | isSorted: false,
81 | isSortedDescending: false,
82 | data: 'string',
83 | isPadded: true,
84 | },
85 | {
86 | key: 'column2',
87 | name: 'Type',
88 | fieldName: 'type',
89 | minWidth: 20,
90 | maxWidth: 20,
91 | isRowHeader: true,
92 | isResizable: true,
93 | isSorted: false,
94 | isSortedDescending: false,
95 | data: 'string',
96 | isPadded: true,
97 | },
98 | {
99 | key: 'column3',
100 | name: 'Price',
101 | fieldName: 'price',
102 | minWidth: 40,
103 | maxWidth: 40,
104 | isRowHeader: true,
105 | isResizable: true,
106 | isSorted: false,
107 | isSortedDescending: false,
108 | data: 'string',
109 | isPadded: true,
110 | },
111 | ]
112 |
113 | const commonInvoiceTableColumns = [
114 | {
115 | key: 'column3',
116 | name: 'Weight',
117 | fieldName: 'gWeight',
118 | maxWidth: 35,
119 | minWidth: 35,
120 | isResizable: true,
121 | data: 'string',
122 | isPadded: true,
123 | },
124 | {
125 | key: 'column4',
126 | name: 'Net Weight',
127 | fieldName: 'weight',
128 | maxWidth: 50,
129 | minWidth: 50,
130 | isResizable: true,
131 | data: 'string',
132 | isPadded: true,
133 | },
134 | {
135 | key: 'column5',
136 | name: 'Rate',
137 | fieldName: 'price',
138 | maxWidth: 55,
139 | minWidth: 55,
140 | isResizable: true,
141 | data: 'string',
142 | isPadded: true,
143 | },
144 | ]
145 |
146 | const totalColumn = {
147 | key: 'column8',
148 | name: 'Total (₹)',
149 | fieldName: 'totalPrice',
150 | maxWidth: 60,
151 | minWidth: 60,
152 | isResizable: true,
153 | data: 'string',
154 | isPadded: false,
155 | }
156 |
157 | const invoiceItemsTableColumns = [
158 | {
159 | key: 'column2',
160 | name: 'Pcs',
161 | fieldName: 'quantity',
162 | isResizable: true,
163 | maxWidth: 30,
164 | minWidth: 30,
165 | data: 'string',
166 | isPadded: true,
167 | },
168 | ...commonInvoiceTableColumns,
169 | {
170 | key: 'column6',
171 | name: 'MKG (%)',
172 | fieldName: 'mkg',
173 | maxWidth: 35,
174 | minWidth: 35,
175 | isResizable: true,
176 | data: 'string',
177 | isPadded: true,
178 | },
179 | {
180 | key: 'column7',
181 | name: 'Other (₹)',
182 | fieldName: 'other',
183 | maxWidth: 60,
184 | minWidth: 60,
185 | isResizable: true,
186 | data: 'string',
187 | isPadded: true,
188 | },
189 | totalColumn,
190 | ]
191 |
192 | const oldInvoiceItemsTableColumns = [
193 | {
194 | key: 'column2',
195 | name: 'Purity',
196 | fieldName: 'purity',
197 | isResizable: true,
198 | maxWidth: 50,
199 | minWidth: 50,
200 | data: 'string',
201 | isPadded: true,
202 | },
203 | ...commonInvoiceTableColumns,
204 | totalColumn,
205 | ]
206 |
207 | const defaultPageSettings = { width: 595.42, height: 895.04, fontSize: 11 }
208 |
209 | const fieldTypes = [
210 | { key: DATE, text: DATE },
211 | { key: TEXT, text: TEXT },
212 | { key: MASKED, text: MASKED },
213 | ]
214 |
215 | const defaultPrintSettings = [
216 | {
217 | name: 'Invoice Number',
218 | x: 90,
219 | y: 694,
220 | required: true,
221 | disabled: true,
222 | type: TEXT,
223 | row: 1,
224 | size: defaultPageSettings.fontSize,
225 | disableNameChange: true,
226 | },
227 | {
228 | name: 'Invoice Date',
229 | x: 473,
230 | y: 694,
231 | required: true,
232 | disabled: false,
233 | type: DATE,
234 | row: 1,
235 | size: defaultPageSettings.fontSize,
236 | disableNameChange: true,
237 | },
238 | {
239 | name: 'Customer Name',
240 | x: 60,
241 | y: 668,
242 | required: true,
243 | disabled: false,
244 | type: TEXT,
245 | row: 2,
246 | size: defaultPageSettings.fontSize,
247 | },
248 | {
249 | name: 'GSTIN',
250 | x: 100,
251 | y: 641,
252 | required: false,
253 | disabled: false,
254 | type: TEXT,
255 | inputLength: 15,
256 | regex: '^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$',
257 | row: 3,
258 | size: defaultPageSettings.fontSize,
259 | },
260 | {
261 | name: 'Mobile',
262 | x: 473,
263 | y: 641,
264 | required: true,
265 | disabled: false,
266 | type: TEXT,
267 | inputLength: 10,
268 | prefix: '+91',
269 | regex: '\\+?\\d[\\d -]{8,12}\\d',
270 | startIndex: 3,
271 | row: 3,
272 | size: defaultPageSettings.fontSize,
273 | },
274 | {
275 | name: 'Address',
276 | x: 257,
277 | y: 641,
278 | required: false,
279 | disabled: false,
280 | type: TEXT,
281 | row: 4,
282 | size: defaultPageSettings.fontSize,
283 | },
284 | ]
285 |
286 | const morePrintSettings = {
287 | itemStartY: 590,
288 | diffBetweenItemsY: 15,
289 | diffBetweenAmountsY: 20,
290 | endAmountsX: 560,
291 | copyTypeXEnd: 569,
292 | copyTypeY: 820,
293 | }
294 |
295 | const footerPrintSettings = {
296 | [PAY_METHOD.CASH]: {
297 | x: 242,
298 | y: 220,
299 | },
300 | [PAY_METHOD.CHEQUE]: {
301 | x: 349,
302 | y: 220,
303 | },
304 | [PAY_METHOD.CHEQUENO]: {
305 | x: 303,
306 | y: 207,
307 | },
308 | [PAY_METHOD.CARD]: {
309 | x: 238,
310 | y: 188,
311 | },
312 | [PAY_METHOD.UPI]: {
313 | x: 324,
314 | y: 188,
315 | },
316 | }
317 |
318 | const calculationSettings = {
319 | cgst: 1.5,
320 | sgst: 1.5,
321 | igst: 3,
322 | roundOffToWords: true,
323 | }
324 |
325 | export {
326 | PRINT, PREVIEW, darkThemePalette, invoiceItemsTableColumns, ISET, FILE_TYPE, PAY_METHOD,
327 | productTableColumns, defaultPrintSettings, morePrintSettings, calculationSettings,
328 | defaultPageSettings, fieldTypes, DATE, TEXT, MASKED, CUSTOM_FONT, ZERO, UPDATE_RESTART_MSG,
329 | SELECT_FILE_TYPE, oldInvoiceItemsTableColumns, ERROR, MAX_ITEM_WIDTH, COMPANY_NAME,
330 | footerPrintSettings,
331 | }
332 |
--------------------------------------------------------------------------------
/src/utils/utils.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-plusplus */
2 | /* eslint-disable no-param-reassign */
3 | /* eslint-disable no-restricted-globals */
4 | /**
5 | * @param {string} value Suspect boolean string
6 | * @return boolean value if string is correct or the string itself
7 | */
8 | const getBoolFromString = (value) => {
9 | switch (value) {
10 | case 'true':
11 | return true
12 | case 'false':
13 | return false
14 | default:
15 | return value
16 | }
17 | }
18 |
19 | /** @return Generates an UUID (hash🍀) */
20 | const makeHash = () => ('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
21 | // eslint-disable-next-line no-bitwise
22 | const r = Math.random() * 16 | 0
23 | // eslint-disable-next-line no-mixed-operators, no-bitwise
24 | const v = c === 'x' ? r : (r & 0x3 | 0x8)
25 | return v.toString(16)
26 | }))
27 |
28 | const groupBy = (array, key) => array.reduce((result, currentValue) => {
29 | // eslint-disable-next-line no-param-reassign
30 | (result[currentValue[key]] = result[currentValue[key]] || []).push(currentValue)
31 | return result
32 | }, {})
33 |
34 | /**
35 | * @param {string} string String to be transformed
36 | * @returns Title Cased String
37 | */
38 | const titleCase = (string) => string.replace(/([A-Z])/g, ' $1')
39 | .replace(/^./, (str) => str.toUpperCase())
40 |
41 | /**
42 | * @param {string} string Suspect Decimal Value
43 | * @return Decimal Value of Number type or 0
44 | */
45 | const quantize = (val) => (isNaN(parseFloat(val))
46 | ? 0 : +val)
47 |
48 | /**
49 | * @borrows https://stackoverflow.com/a/51271494/7326407
50 | * @param {string} str String to be incremented. Eg: MKY42
51 | * @return String with incremented value (Eg: MKY43)
52 | * @description works with these types of formats
53 | * TEST01A06
54 | * TEST-100-A100
55 | * TEST0001B-101
56 | * TEST001A100
57 | * TEST001A91
58 | * TEST1101
59 | * TEST1010
60 | * 1010
61 | */
62 | const incrementor = (str) => {
63 | const numPart = str.match(/(0?[1-9])+$|0?([1-9]+?0+)$/)[0]
64 | const strPart = str.slice(0, str.indexOf(numPart))
65 | const isLastIndexNine = numPart.match(/9$/)
66 |
67 | // If we have a leading zero (e.g. - 'L100A099')
68 | // or there is no prefix - we should just increment the number
69 | if (isLastIndexNine || strPart != null) {
70 | return strPart + numPart.replace(/\d+$/, (n) => ++n)
71 | }
72 | // Increment the number and add the missing zero
73 | return `${strPart}0${numPart.replace(/\d+$/, (n) => ++n)}`
74 | }
75 |
76 | /**
77 | * Validates email with regex
78 | * @param {string} email
79 | */
80 | const validateEmail = (email) => {
81 | const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
82 | return re.test(String(email).toLowerCase())
83 | }
84 |
85 | /**
86 | * Returns download size and (optional) speed.
87 | * @param {number} size Size of file in bytes
88 | * @param {boolean} speed If true, shows speed.
89 | */
90 | const getReadableSize = (size, speed = false) => {
91 | let i = -1
92 | const units = ['kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb']
93 | do {
94 | size /= 1024
95 | i++
96 | } while (size > 1024)
97 | return `${Math.max(size, 0.1).toFixed(1)} ${units[i]}${(speed ? '/sec' : '')}`
98 | }
99 |
100 | /**
101 | * Returns formated number in Number DataType
102 | * @param {number} num Number to be formatted
103 | * @param {number} decimals Number of decimals
104 | */
105 | const round = (num, decimals = 2) => +(parseFloat(num).toFixed(decimals))
106 |
107 | /**
108 | * Returns Date Object from a string
109 | * @param {string} dateStr String to be parsed as date
110 | * @param {string} delimiter String to be used as delimiter
111 | */
112 | const parseDate = (dateStr, delimiter = '-') => {
113 | const [d, m, y] = (dateStr || '').trim().split(delimiter)
114 | const day = Math.max(1, Math.min(31, parseInt(d, 10)))
115 | const month = Math.max(1, Math.min(12, parseInt(m, 10))) - 1
116 | const yearInt = parseInt(y, 10)
117 | const year = yearInt < 100 ? yearInt + 2000 : yearInt
118 | return new Date(year, month, day)
119 | }
120 |
121 | export {
122 | getBoolFromString, makeHash, groupBy,
123 | titleCase, quantize, incrementor, validateEmail,
124 | getReadableSize, round, parseDate,
125 | }
126 |
--------------------------------------------------------------------------------