├── .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 | [![Top Lang](https://img.shields.io/github/languages/top/2AMDevs/invoicify-app?style=flat-square)](https://github.com/2AMDevs/invoicify-app) 15 | [![Downloads](https://img.shields.io/github/downloads/2AMDevs/invoicify-app/total?style=flat-square)](https://github.com/2AMDevs/invoicify-app/releases) 16 | [![Open Issues](https://img.shields.io/github/issues-raw/2AMDevs/invoicify-app?style=flat-square)](https://github.com/2AMDevs/invoicify-app/issues) 17 | [![Current Version](https://img.shields.io/github/package-json/v/2AMDevs/invoicify-app/master?style=flat-square)](https://github.com/2AMDevs/invoicify-app) 18 | 19 | [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-) 20 | 21 | 22 | [![Discord](https://img.shields.io/badge/Discord-7289DA?style=for-the-badge&logo=discord&logoColor=white)](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
Windows | Linux
Linux | MacOS
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 [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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 | 87 | 88 | 89 | 90 | 91 |

Aashutosh Rathi

🚇 📖 💻

Mohit Kumar Yadav

🐛 🤔 💻
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 | 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 |
105 | 110 |
111 |
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 |
30 |
31 |
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 | 5 | 7 | 9 | 12 | 14 | 16 | 35 | 42 | 44 | 46 | 48 | 51 | 53 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 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 | --------------------------------------------------------------------------------