Init commit
2
.browserslistrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
7
.editorconfig
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
[*.{js,jsx,ts,tsx,vue}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
max_line_length = 100
|
||||||
33
.eslintrc.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
'plugin:vue/essential',
|
||||||
|
'@vue/airbnb',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||||
|
'consistent-return': 'off',
|
||||||
|
'no-param-reassign': 'off',
|
||||||
|
'no-prototype-builtins': 'off',
|
||||||
|
camelcase: 'off',
|
||||||
|
'class-methods-use-this': 'off',
|
||||||
|
'no-plusplus': 'off',
|
||||||
|
'max-len': 'off',
|
||||||
|
'import/prefer-default-export': 'off',
|
||||||
|
radix: 'off',
|
||||||
|
'prefer-destructuring': 'off',
|
||||||
|
'no-mixed-operators': 'off',
|
||||||
|
'vue/require-v-for-key': 'off',
|
||||||
|
'import/extensions': 'off',
|
||||||
|
'linebreak-style': ['off'],
|
||||||
|
'object-shorthand': 'off',
|
||||||
|
'import/no-cycle': 'off'
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
parser: 'babel-eslint',
|
||||||
|
},
|
||||||
|
};
|
||||||
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
src/config/app.config.js
|
||||||
|
public/.htaccess
|
||||||
|
vue.config.js
|
||||||
29
README.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# moku serverless invoices
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
```
|
||||||
|
npm run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run your tests
|
||||||
|
```
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
|
```
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customize configuration
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
5
babel.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/app',
|
||||||
|
],
|
||||||
|
};
|
||||||
13361
package-lock.json
generated
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "serverless-invoices",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vuex-orm/core": "^0.36.3",
|
||||||
|
"@vuex-orm/plugin-change-flags": "https://github.com/mareksmakosz/plugin-change-flags.git",
|
||||||
|
"bootstrap": "^4.5.2",
|
||||||
|
"bootstrap-vue": "^2.17.3",
|
||||||
|
"core-js": "^2.6.5",
|
||||||
|
"dayjs": "^1.10.3",
|
||||||
|
"es6-promise": "^4.2.6",
|
||||||
|
"localforage": "^1.9.0",
|
||||||
|
"lodash": "^4.17.20",
|
||||||
|
"vue": "^2.6.10",
|
||||||
|
"vue-autosuggest": "^2.2.0",
|
||||||
|
"vue-multiselect": "^2.1.6",
|
||||||
|
"vue-notification": "^1.3.16",
|
||||||
|
"vue-router": "^3.0.3",
|
||||||
|
"vue2-datepicker": "^3.7.0",
|
||||||
|
"vuex": "^3.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/cli-plugin-babel": "^3.7.0",
|
||||||
|
"@vue/cli-plugin-eslint": "^3.7.0",
|
||||||
|
"@vue/cli-service": "^3.7.0",
|
||||||
|
"@vue/eslint-config-airbnb": "^4.0.0",
|
||||||
|
"babel-eslint": "^10.0.1",
|
||||||
|
"eslint": "^5.16.0",
|
||||||
|
"eslint-plugin-vue": "^5.0.0",
|
||||||
|
"node-sass": "^4.14.0",
|
||||||
|
"sass-loader": "^7.1.0",
|
||||||
|
"vue-template-compiler": "^2.5.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
9
public/.htaccess.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
RewriteRule ^index\.html$ - [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . index.html [L]
|
||||||
|
</IfModule>
|
||||||
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/android-chrome-384x384.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
9
public/browserconfig.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/mstile-150x150.png"/>
|
||||||
|
<TileColor>#da532c</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 998 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
26
public/index.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
|
<title>Serverless Invoices</title>
|
||||||
|
|
||||||
|
<!-- Google Material Icons -->
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Round" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Google Fonts Work Sans -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<!-- Google Fonts Roboto - only a subset of characters for time duration -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@500&display=swap&text=0123456789%3a" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="scrollbar">
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but Serverless Invoices doesn't work properly without JavaScript enabled. Please enable it to
|
||||||
|
continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
27
public/safari-pinned-tab.svg
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="420.000000pt" height="420.000000pt" viewBox="0 0 420.000000 420.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(0.000000,420.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M970 2940 c-154 -40 -274 -120 -348 -230 -23 -35 -42 -59 -43 -54 -5
|
||||||
|
42 -16 203 -18 257 -1 15 -16 17 -161 17 l-160 0 0 -845 0 -845 181 2 182 3 2
|
||||||
|
510 c1 281 5 522 9 536 22 93 68 181 125 238 49 49 106 91 125 91 8 0 17 4 20
|
||||||
|
8 11 19 117 35 193 29 167 -12 274 -106 299 -264 5 -26 9 -51 10 -55 1 -5 2
|
||||||
|
-253 3 -553 l1 -545 180 0 180 0 1 478 c1 519 2 553 18 595 6 16 9 33 5 38 -3
|
||||||
|
5 0 9 7 9 7 0 10 3 6 6 -8 8 49 112 86 154 16 19 50 49 75 67 180 130 472 78
|
||||||
|
549 -97 38 -86 40 -122 39 -692 l-1 -558 183 0 183 0 -4 613 c-2 336 -7 626
|
||||||
|
-11 642 -23 93 -55 170 -91 215 -50 63 -146 147 -195 171 -124 60 -188 74
|
||||||
|
-335 72 -248 -3 -458 -115 -556 -297 l-17 -30 -21 42 c-66 129 -192 221 -371
|
||||||
|
269 -83 22 -251 24 -330 3z"/>
|
||||||
|
<path d="M3360 2153 c-99 -16 -186 -60 -253 -126 -90 -89 -128 -197 -123 -346
|
||||||
|
4 -108 10 -132 46 -206 26 -54 75 -114 125 -154 95 -77 309 -112 455 -75 223
|
||||||
|
57 352 243 337 487 -6 102 -34 182 -88 254 -46 62 -137 125 -213 149 -49 15
|
||||||
|
-231 26 -286 17z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
19
public/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"short_name": "",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#eceff1",
|
||||||
|
"background_color": "#eceff1",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
36
src/App.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app"
|
||||||
|
class="min-vh-100"
|
||||||
|
:class="$route.name">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<router-view/>
|
||||||
|
</transition>
|
||||||
|
<notifications position="bottom center" classes="snackbar" width="332"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'app',
|
||||||
|
created() {
|
||||||
|
this.pauseAnimationsUntilLoaded();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
jsLoaded() {
|
||||||
|
document.body.classList.remove('js-loading');
|
||||||
|
},
|
||||||
|
pauseAnimationsUntilLoaded() {
|
||||||
|
document.body.classList.add('js-loading');
|
||||||
|
window.addEventListener('load', this.jsLoaded, false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import './assets/scss/variables';
|
||||||
|
@import '../node_modules/bootstrap/scss/bootstrap';
|
||||||
|
@import '../node_modules/bootstrap-vue/dist/bootstrap-vue.min.css';
|
||||||
|
@import './assets/scss/app';
|
||||||
|
</style>
|
||||||
7
src/assets/scss/_alerts.scss
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.alert {
|
||||||
|
&-secondary {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--shade);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/assets/scss/_animations.scss
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
@keyframes bump-in {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulsate {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bump {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up-fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, 56px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-left-fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(32px, 0);
|
||||||
|
}
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: .2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/assets/scss/_backdrops.scss
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1038;
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background-color: $modal-backdrop-bg;
|
||||||
|
opacity: $modal-backdrop-opacity;
|
||||||
|
transition: opacity .4s $base-ease;
|
||||||
|
visibility: visible;
|
||||||
|
&.in {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@include media-breakpoint-up(xl) {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/assets/scss/_buttons.scss
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
.btn {
|
||||||
|
border-width: 0;
|
||||||
|
|
||||||
|
&-sm {
|
||||||
|
&.btn--icon-left {
|
||||||
|
padding-left: $btn-padding-x-sm / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-outline-secondary {
|
||||||
|
border-width: $btn-border-width;
|
||||||
|
border-color: $input-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-primary {
|
||||||
|
color: var(--bg-primary);
|
||||||
|
background-color: var(--primary);
|
||||||
|
|
||||||
|
&:hover, &focus {
|
||||||
|
color: var(--bg-primary);
|
||||||
|
background-color: var(--primary-darken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-light {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:not(:disabled):not(.disabled):active,
|
||||||
|
&:not(:disabled):not(.disabled).active, {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &:active, &:focus {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--text-caption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-outline-dark {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
background-color: transparent;
|
||||||
|
border-width: $btn-border-width;
|
||||||
|
|
||||||
|
&:not(:disabled):not(.disabled):active,
|
||||||
|
&:not(:disabled):not(.disabled).active, {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &:active, &:focus {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--text-caption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-success {
|
||||||
|
&--light {
|
||||||
|
color: $success;
|
||||||
|
background-color: rgba($success, 0.07);
|
||||||
|
|
||||||
|
&:hover, &:active, &:focus {
|
||||||
|
color: $success;
|
||||||
|
background-color: rgba($success, 0.07);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-link {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
right: 32px;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 99px;
|
||||||
|
box-shadow: $box-shadow-light-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/assets/scss/_cards.scss
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.card {
|
||||||
|
box-shadow: $box-shadow-light-1;
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: $border-radius-large;
|
||||||
|
border-width: 0;
|
||||||
|
|
||||||
|
&-header {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-footer {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/assets/scss/_checkboxes.scss
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
.custom-checkbox {
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
min-height: 1.25em;
|
||||||
|
|
||||||
|
.custom-control-label {
|
||||||
|
&:before, &:after {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rounded {
|
||||||
|
.custom-control-label {
|
||||||
|
&:before {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--lg {
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
min-height: 1.5em;
|
||||||
|
|
||||||
|
.custom-control-label {
|
||||||
|
&:before, &:after {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--caption {
|
||||||
|
.custom-control-input {
|
||||||
|
&:checked {
|
||||||
|
~ .custom-control-label {
|
||||||
|
&:before {
|
||||||
|
background-color: $gray-400;
|
||||||
|
border-color: $gray-400;
|
||||||
|
color: $gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--narrow {
|
||||||
|
&.custom-control {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/assets/scss/_dropdowns.scss
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.dropdown {
|
||||||
|
&-menu {
|
||||||
|
@extend .bg-base;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: $box-shadow-light-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
transition: all 0.15s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/assets/scss/_forms.scss
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.invalid-feedback {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--error-darken);
|
||||||
|
}
|
||||||
15
src/assets/scss/_indicators.scss
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&--circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/assets/scss/_modals.scss
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
.modal {
|
||||||
|
&-dialog {
|
||||||
|
&.modal-lg {
|
||||||
|
@include media-breakpoint-up(sm) {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fade {
|
||||||
|
&-slide-horizontal {
|
||||||
|
.modal-dialog {
|
||||||
|
transform: translate(32px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
.modal-dialog {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-slide-vertical {
|
||||||
|
.modal-dialog {
|
||||||
|
transform: translate(0, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
.modal-dialog {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.high {
|
||||||
|
.modal-dialog {
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
.modal-dialog {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-backdrop {
|
||||||
|
opacity: $modal-backdrop-opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
@include media-breakpoint-up(sm) {
|
||||||
|
box-shadow: $modal-content-box-shadow-sm-up;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/assets/scss/_navs.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.nav-pills {
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/assets/scss/_scaffolding.scss
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
.app {
|
||||||
|
&__content {
|
||||||
|
padding-top: 32px;
|
||||||
|
padding-right: 66px;
|
||||||
|
padding-left: 66px;
|
||||||
|
will-change: padding-left;
|
||||||
|
transition: padding-left 0.2s $base-ease;
|
||||||
|
|
||||||
|
//@include media-breakpoint-up(xl) {
|
||||||
|
// padding-left: $sidebar-width + ($grid-gutter-width * 2);
|
||||||
|
// &.app__content--sidebar-collapsed {
|
||||||
|
// padding-left: 66px;
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//&--sheet-open {
|
||||||
|
// @include media-breakpoint-up(xl) {
|
||||||
|
// padding-right: $sheet-width + ($grid-gutter-width * 2);
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
.app {
|
||||||
|
&__content {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl) {
|
||||||
|
padding-left: $sidebar-width + ($grid-gutter-width * 2);
|
||||||
|
&.app__content--sidebar-collapsed {
|
||||||
|
padding-left: 66px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&--left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/assets/scss/_snackbars.scss
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.snackbar {
|
||||||
|
&.vue-notification-template {
|
||||||
|
display: flex;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
padding: ($spacer / 4) ($spacer);
|
||||||
|
margin-bottom: $spacer / 4;
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
box-shadow: $box-shadow-2;
|
||||||
|
background-color: $dark;
|
||||||
|
font-size: .875rem;
|
||||||
|
color: $light-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vue-notification-wrapper {
|
||||||
|
padding: 0 16px !important;
|
||||||
|
}
|
||||||
41
src/assets/scss/_surfaces.scss
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
.dp {
|
||||||
|
&--00 {
|
||||||
|
background-color: var(--dp00);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--01 {
|
||||||
|
background-color: var(--dp01);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--02 {
|
||||||
|
background-color: var(--dp02);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--03 {
|
||||||
|
background-color: var(--dp03);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--04 {
|
||||||
|
background-color: var(--dp04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--06 {
|
||||||
|
background-color: var(--dp06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--08 {
|
||||||
|
background-color: var(--dp08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--12 {
|
||||||
|
background-color: var(--dp12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--16 {
|
||||||
|
background-color: var(--dp16);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--24 {
|
||||||
|
background-color: var(--dp24);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/assets/scss/_tables.scss
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
.table {
|
||||||
|
&--card {
|
||||||
|
background-color: var(--dp02);
|
||||||
|
border-radius: $card-border-radius;
|
||||||
|
box-shadow: $box-shadow-light-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
th {
|
||||||
|
padding-top: 1.5em;
|
||||||
|
padding-bottom: .75em;
|
||||||
|
font-size: .875rem;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
&.align-middle {
|
||||||
|
td {
|
||||||
|
vertical-align: middle !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
tr {
|
||||||
|
transition: background-color $link-transition-duration $base-ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/assets/scss/_transitions.scss
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s $base-ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideFade-enter-active,
|
||||||
|
.slideFade-leave-active {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s $base-ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideFade-enter,
|
||||||
|
.slideFade-leave-to {
|
||||||
|
transform: translate(-4px, 0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideLeftFade-enter-active,
|
||||||
|
.slideLeftFade-leave-active {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s $base-ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideLeftFade-enter,
|
||||||
|
.slideLeftFade-leave-to {
|
||||||
|
transform: translate(32px, 0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.slideFromTopFade-enter-active,
|
||||||
|
.slideFromTopFade-leave-active {
|
||||||
|
transform: translate(0, 16px);
|
||||||
|
opacity: 1;
|
||||||
|
transition: all 0.2s $base-ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slideFromTopFade-enter,
|
||||||
|
.slideFromTopFade-leave-to {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bump-enter-active,
|
||||||
|
.bump-leave-active {
|
||||||
|
animation: bump-in .5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bump-enter,
|
||||||
|
.bump-leave-to {
|
||||||
|
animation: bump-in .5s reverse;
|
||||||
|
}
|
||||||
73
src/assets/scss/_type.scss
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
.material-icons.md-10 {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-14 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-18 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-24 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-36 {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-48 {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules for using icons as black on a light background. */
|
||||||
|
.material-icons.md-dark {
|
||||||
|
color: rgba(0, 0, 0, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-dark.md-inactive {
|
||||||
|
color: rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules for using icons as white on a dark background. */
|
||||||
|
.material-icons.md-light {
|
||||||
|
color: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons.md-light.md-inactive {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-through {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-caption {
|
||||||
|
color: var(--text-caption);
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
color: var(--text-caption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--orange) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-dark {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
141
src/assets/scss/_utilities.scss
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
.js-loading *,
|
||||||
|
.js-loading *:before,
|
||||||
|
.js-loading *:after {
|
||||||
|
animation-play-state: paused !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: $body-bg;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaling-svg-container {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
height: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
padding-bottom: 100%;
|
||||||
|
/* override this inline for aspect ratio other than square */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaling-svg {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-0 {
|
||||||
|
.form-control {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulsate {
|
||||||
|
animation-name: pulsate;
|
||||||
|
animation-duration: 2s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bump {
|
||||||
|
animation-name: bump;
|
||||||
|
animation-duration: 2s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-gray {
|
||||||
|
/* Permalink - use to edit and share this gradient: https://colorzilla.com/gradient-editor/#ffffff+0,eceff1+100 */
|
||||||
|
background: rgb(255, 255, 255); /* Old browsers */
|
||||||
|
background: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 1) 0%, rgba(236, 239, 241, 1) 100%); /* FF3.6-15 */
|
||||||
|
background: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 1) 0%, rgba(236, 239, 241, 1) 100%); /* Chrome10-25,Safari5.1-6 */
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(236, 239, 241, 1) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
|
||||||
|
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#eceff1', GradientType=1); /* IE6-9 fallback on horizontal gradient */
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-vh-50 {
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-line {
|
||||||
|
&:after {
|
||||||
|
display: block;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar {
|
||||||
|
// scrollbar styles
|
||||||
|
scrollbar-color: var(--text-caption);
|
||||||
|
scrollbar-width: 12px;
|
||||||
|
scrollbar-gutter: always;
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: var(--shade);
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--text-caption);
|
||||||
|
&:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-base {
|
||||||
|
> * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:before,
|
||||||
|
&:after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
content: '';
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
background-color: inherit;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-shade {
|
||||||
|
background-color: var(--shade) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-secondary {
|
||||||
|
background-color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-success {
|
||||||
|
background-color: var(--success) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-dark {
|
||||||
|
background-color: var(--text-primary) !important;
|
||||||
|
}
|
||||||
342
src/assets/scss/_variables.scss
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
:root {
|
||||||
|
--text-primary: rgba(0, 0, 0, .87);
|
||||||
|
--text-secondary: rgba(0, 0, 0, .6);
|
||||||
|
--text-disabled: rgba(0, 0, 0, .38);
|
||||||
|
--text-caption: rgba(0, 0, 0, .12);
|
||||||
|
--divider: rgba(0, 0, 0, .12);
|
||||||
|
--shade: rgba(0, 0, 0, .04);
|
||||||
|
|
||||||
|
--primary: #2962ff;
|
||||||
|
--primary-darken: #0D47A1;
|
||||||
|
--success: #64DD17;
|
||||||
|
--error: #C62828;
|
||||||
|
--error-darken: #B71C1C;
|
||||||
|
|
||||||
|
--bg-body: #f3f8f8;
|
||||||
|
--bg-primary: #fff;
|
||||||
|
--bg-secondary: #EEEEEE;
|
||||||
|
--bg-control: transparent;
|
||||||
|
|
||||||
|
--scrim: rgba(0, 0, 0, .32);
|
||||||
|
|
||||||
|
--dp00: #fff;
|
||||||
|
--dp01: #fff;
|
||||||
|
--dp02: #fff;
|
||||||
|
--dp03: #fff;
|
||||||
|
--dp04: #fff;
|
||||||
|
--dp06: #fff;
|
||||||
|
--dp08: #fff;
|
||||||
|
--dp12: #fff;
|
||||||
|
--dp16: #fff;
|
||||||
|
--dp24: #fff;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
--text-primary: rgba(255, 255, 255, .87);
|
||||||
|
--text-secondary: rgba(255, 255, 255, .6);
|
||||||
|
--text-disabled: rgba(255, 255, 255, .38);
|
||||||
|
--text-caption: rgba(255, 255, 255, .12);
|
||||||
|
--divider: rgba(255, 255, 255, .2);
|
||||||
|
--shade: rgba(255, 255, 255, .04);
|
||||||
|
|
||||||
|
--primary: #bbdefb;
|
||||||
|
--primary-darken: #90CAF9;
|
||||||
|
--success: #DCEDC8;
|
||||||
|
--error: #FFCDD2;
|
||||||
|
--error-darken: #EF9A9A;
|
||||||
|
|
||||||
|
|
||||||
|
--bg-body: #121212;
|
||||||
|
--bg-primary: #121212;
|
||||||
|
--bg-secondary: #616161;
|
||||||
|
--bg-control: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
--scrim: rgba(0, 0, 0, .32);
|
||||||
|
|
||||||
|
--dp00: transparent;
|
||||||
|
--dp01: rgba(255, 255, 255, 0.05);
|
||||||
|
--dp02: rgba(255, 255, 255, 0.07);
|
||||||
|
--dp03: rgba(255, 255, 255, 0.08);
|
||||||
|
--dp04: rgba(255, 255, 255, 0.09);
|
||||||
|
--dp06: rgba(255, 255, 255, 0.11);
|
||||||
|
--dp08: rgba(255, 255, 255, 0.12);
|
||||||
|
--dp12: rgba(255, 255, 255, 0.14);
|
||||||
|
--dp16: rgba(255, 255, 255, 0.15);
|
||||||
|
--dp24: rgba(255, 255, 255, 0.16);
|
||||||
|
|
||||||
|
&[data-theme="light"] {
|
||||||
|
--text-primary: rgba(0, 0, 0, .87);
|
||||||
|
--text-secondary: rgba(0, 0, 0, .6);
|
||||||
|
--text-disabled: rgba(0, 0, 0, .38);
|
||||||
|
--text-caption: rgba(0, 0, 0, .12);
|
||||||
|
--divider: rgba(0, 0, 0, .12);
|
||||||
|
--shade: rgba(0, 0, 0, .04);
|
||||||
|
|
||||||
|
--primary: #2962ff;
|
||||||
|
--primary-darken: #0D47A1;
|
||||||
|
--success: #64DD17;
|
||||||
|
--error: #C62828;
|
||||||
|
--error-darken: #B71C1C;
|
||||||
|
|
||||||
|
--bg-body: #f3f8f8;
|
||||||
|
--bg-primary: #fff;
|
||||||
|
--bg-secondary: #EEEEEE;
|
||||||
|
--bg-control: transparent;
|
||||||
|
|
||||||
|
--scrim: rgba(0, 0, 0, .32);
|
||||||
|
|
||||||
|
--dp00: #fff;
|
||||||
|
--dp01: #fff;
|
||||||
|
--dp02: #fff;
|
||||||
|
--dp03: #fff;
|
||||||
|
--dp04: #fff;
|
||||||
|
--dp06: #fff;
|
||||||
|
--dp08: #fff;
|
||||||
|
--dp12: #fff;
|
||||||
|
--dp16: #fff;
|
||||||
|
--dp24: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-theme="dark"] {
|
||||||
|
--text-primary: rgba(255, 255, 255, .87);
|
||||||
|
--text-secondary: rgba(255, 255, 255, .6);
|
||||||
|
--text-disabled: rgba(255, 255, 255, .38);
|
||||||
|
--text-caption: rgba(255, 255, 255, .12);
|
||||||
|
--divider: rgba(255, 255, 255, .2);
|
||||||
|
--shade: rgba(255, 255, 255, .04);
|
||||||
|
|
||||||
|
--primary: #bbdefb;
|
||||||
|
--primary-darken: #90CAF9;
|
||||||
|
--success: #DCEDC8;
|
||||||
|
--error: #FFCDD2;
|
||||||
|
--error-darken: #EF9A9A;
|
||||||
|
|
||||||
|
|
||||||
|
--bg-body: #121212;
|
||||||
|
--bg-primary: #121212;
|
||||||
|
--bg-secondary: #616161;
|
||||||
|
--bg-control: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
--scrim: rgba(0, 0, 0, .32);
|
||||||
|
|
||||||
|
--dp00: transparent;
|
||||||
|
--dp01: rgba(255, 255, 255, 0.05);
|
||||||
|
--dp02: rgba(255, 255, 255, 0.07);
|
||||||
|
--dp03: rgba(255, 255, 255, 0.08);
|
||||||
|
--dp04: rgba(255, 255, 255, 0.09);
|
||||||
|
--dp06: rgba(255, 255, 255, 0.11);
|
||||||
|
--dp08: rgba(255, 255, 255, 0.12);
|
||||||
|
--dp12: rgba(255, 255, 255, 0.14);
|
||||||
|
--dp16: rgba(255, 255, 255, 0.15);
|
||||||
|
--dp24: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$blue: #03A9F4;
|
||||||
|
$indigo: #6610f2;
|
||||||
|
$purple: #6f42c1;
|
||||||
|
$pink: #e83e8c;
|
||||||
|
$red: #F44336;
|
||||||
|
$orange: #E65100;
|
||||||
|
$yellow: #ffc107;
|
||||||
|
//$green: #64DD17;
|
||||||
|
//$green: #009688;
|
||||||
|
$green: #64DD17;
|
||||||
|
$teal: #20c997;
|
||||||
|
$cyan: #17a2b8;
|
||||||
|
$white: #fff;
|
||||||
|
$blue-dark: #0091EA;
|
||||||
|
|
||||||
|
|
||||||
|
@import "../../../node_modules/bootstrap/scss/functions";
|
||||||
|
@import "../../../node_modules/bootstrap/scss/variables";
|
||||||
|
@import "../../../node_modules/bootstrap/scss/mixins";
|
||||||
|
|
||||||
|
$warning: $orange;
|
||||||
|
|
||||||
|
//$box-shadow-1: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
$box-shadow-1: rgba(23, 43, 77, 0.2) 0px 1px 1px, rgba(23, 43, 77, 0.2) 0px 0px 1px;
|
||||||
|
$box-shadow-2: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
|
||||||
|
$box-shadow-3: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
|
||||||
|
$box-shadow-4: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
|
||||||
|
$box-shadow-5: 0 19px 38px rgba(0, 0, 0, 0.30), 0 15px 12px rgba(0, 0, 0, 0.22);
|
||||||
|
|
||||||
|
$box-shadow-light-1: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||||
|
$box-shadow-light-2: 0 3px 6px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
$box-shadow-light-3: 0 10px 20px rgba(0, 0, 0, 0.09), 0 6px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
$box-shadow-light-4: 0 14px 28px rgba(0, 0, 0, 0.12), 0 10px 10px rgba(0, 0, 0, 0.11);
|
||||||
|
$box-shadow-light-5: 0 19px 38px rgba(0, 0, 0, 0.15), 0 15px 12px rgba(0, 0, 0, 0.11);
|
||||||
|
$box-shadow-light-6: 0 0 82px 2px rgba(61, 69, 74, .2);
|
||||||
|
|
||||||
|
$font-family-base: 'Work Sans', sans-serif;
|
||||||
|
|
||||||
|
$gray-primary: rgba(0, 0, 0, 0.87);
|
||||||
|
$gray-medium: rgba(0, 0, 0, 0.72);
|
||||||
|
$gray-secondary: rgba(0, 0, 0, 0.54);
|
||||||
|
$gray-caption: rgba(0, 0, 0, 0.20);
|
||||||
|
$gray-divider: rgba(0, 0, 0, 0.12);
|
||||||
|
$gray-highlight: rgba(0, 0, 0, 0.07);
|
||||||
|
$gray-shade: rgba(0, 0, 0, 0.02);
|
||||||
|
|
||||||
|
$light-primary: rgba(255, 255, 255, 0.87);
|
||||||
|
$light-secondary: rgba(255, 255, 255, 0.54);
|
||||||
|
$light-caption: rgba(255, 255, 255, 0.20);
|
||||||
|
$light-divider: rgba(255, 255, 255, 0.12);
|
||||||
|
$light-highlight: rgba(255, 255, 255, 0.07);
|
||||||
|
$light-shade: rgba(255, 255, 255, 0.03);
|
||||||
|
|
||||||
|
$body-bg: var(--bg-body);
|
||||||
|
$body-color: var(--text-primary);
|
||||||
|
|
||||||
|
$line-height-base: 1.25;
|
||||||
|
|
||||||
|
$btn-font-weight: 500;
|
||||||
|
|
||||||
|
$card-cap-bg: transparent;
|
||||||
|
|
||||||
|
$grid-gutter-width: 32px;
|
||||||
|
|
||||||
|
$spacer: 1rem;
|
||||||
|
|
||||||
|
$alert-bg-level: -11;
|
||||||
|
$alert-border-level: -11;
|
||||||
|
$alert-padding-x: .75rem;
|
||||||
|
|
||||||
|
$badge-font-size: 68.75%;
|
||||||
|
|
||||||
|
$border-color: var(--text-caption);
|
||||||
|
|
||||||
|
$border-radius-large: 6px;
|
||||||
|
|
||||||
|
$base-ease: cubic-bezier(0, 0, 0.2, 1) 0ms;
|
||||||
|
|
||||||
|
$hr-border-color: var(--divider);
|
||||||
|
|
||||||
|
$dropdown-bg: var(--dp24);
|
||||||
|
$dropdown-border-color: transparent;
|
||||||
|
$dropdown-color: $body-color;
|
||||||
|
$dropdown-link-color: $body-color;
|
||||||
|
$dropdown-link-active-color: $body-color;
|
||||||
|
$dropdown-link-active-bg: var(--shade);
|
||||||
|
$dropdown-link-hover-bg: var(--shade);
|
||||||
|
$dropdown-link-hover-color: $dropdown-link-color;
|
||||||
|
$dropdown-divider-bg: $hr-border-color;
|
||||||
|
$dropdown-header-color: var(--text-secondary);
|
||||||
|
|
||||||
|
$close-color: var(--text-primary);
|
||||||
|
$close-text-shadow: none;
|
||||||
|
$close-font-weight: 500;
|
||||||
|
|
||||||
|
$modal-header-border-color: transparent;
|
||||||
|
$modal-footer-border-color: transparent;
|
||||||
|
$modal-transition: all 200ms $base-ease;
|
||||||
|
$modal-backdrop-bg: var(--scrim);
|
||||||
|
$modal-backdrop-opacity: 1;
|
||||||
|
$modal-content-border-color: transparent;
|
||||||
|
$modal-content-box-shadow-sm-up: $box-shadow-light-6;
|
||||||
|
|
||||||
|
$navbar-light-color: var(--text-secondary);
|
||||||
|
$navbar-light-hover-color: $body-color;
|
||||||
|
$navbar-light-active-color: $body-color;
|
||||||
|
|
||||||
|
$nav-tabs-link-active-bg: var(--bg-control);
|
||||||
|
$nav-tabs-link-active-border-color: transparent;
|
||||||
|
$nav-tabs-border-color: transparent;
|
||||||
|
$nav-tabs-link-hover-border-color: transparent;
|
||||||
|
$nav-tabs-link-active-color: var(--text-primary);
|
||||||
|
|
||||||
|
$navbar-padding-y: $spacer;
|
||||||
|
$navbar-nav-link-padding-x: 1rem;
|
||||||
|
|
||||||
|
$navbar-brand-font-size: 1.75rem;
|
||||||
|
|
||||||
|
$nav-pills-link-active-bg: var(--text-caption);
|
||||||
|
$nav-pills-link-active-color: $body-color;
|
||||||
|
|
||||||
|
$input-color: $body-color;
|
||||||
|
$input-focus-color: $input-color;
|
||||||
|
$input-border-color: var(--text-caption);
|
||||||
|
$input-bg: var(--bg-control);
|
||||||
|
$input-focus-bg: $input-bg;
|
||||||
|
$input-disabled-bg: var(--text-disabled);
|
||||||
|
$input-btn-padding-x: $grid-gutter-width / 2;
|
||||||
|
$input-placeholder-color: var(--text-disabled);
|
||||||
|
|
||||||
|
$input-focus-box-shadow: none;
|
||||||
|
$input-focus-border-color: $body-color;
|
||||||
|
|
||||||
|
$btn-font-size-sm: $font-size-sm;
|
||||||
|
$btn-focus-box-shadow: none;
|
||||||
|
$btn-padding-x: $input-btn-padding-x;
|
||||||
|
|
||||||
|
$headings-font-weight: $font-weight-normal;
|
||||||
|
|
||||||
|
$input-group-addon-bg: $input-bg;
|
||||||
|
$input-group-addon-border-color: $input-border-color;
|
||||||
|
|
||||||
|
$list-group-border-color: transparent;
|
||||||
|
$list-group-bg: var(--bg-control);
|
||||||
|
$list-group-color: $body-color;
|
||||||
|
$list-group-item-padding-y: $grid-gutter-width / 4;
|
||||||
|
$list-group-item-padding-x: $grid-gutter-width / 2;
|
||||||
|
|
||||||
|
$progress-bar-bg: var(--text-caption);
|
||||||
|
|
||||||
|
$spacers: (0: 0, 1: $spacer * .25, 2: $spacer * .5, 3: $spacer, 4: $spacer * 1.5, 5: $spacer * 3, 6: $spacer * 5);
|
||||||
|
|
||||||
|
// Custom
|
||||||
|
|
||||||
|
$link-transition-duration: 0.15s;
|
||||||
|
|
||||||
|
$text-muted: var(--text-disabled);
|
||||||
|
|
||||||
|
$sidebar-width: 220px;
|
||||||
|
$sidebar-width-collapsed: 72px;
|
||||||
|
|
||||||
|
$sheet-width: 360px;
|
||||||
|
|
||||||
|
$tracker-width: $sheet-width * 1.5;
|
||||||
|
|
||||||
|
$table-cell-padding: $grid-gutter-width / 2;
|
||||||
|
$table-border-color: $border-color;
|
||||||
|
$table-border-color: var(--text-caption);
|
||||||
|
$table-hover-bg: var(--divider);
|
||||||
|
$table-color: $body-color;
|
||||||
|
$table-hover-color: $body-color;
|
||||||
|
|
||||||
|
|
||||||
|
$grid-breakpoints: (
|
||||||
|
xs: 0,
|
||||||
|
sm: 600px,
|
||||||
|
md: 800px,
|
||||||
|
lg: 1000px,
|
||||||
|
xl: 1280px,
|
||||||
|
xxl: 1600px
|
||||||
|
);
|
||||||
|
|
||||||
|
// vue2-datepicker variables
|
||||||
|
|
||||||
|
$default-color: var(--text-primary);
|
||||||
|
$primary-color: var(--primary);
|
||||||
|
|
||||||
|
$today-color: $primary-color;
|
||||||
|
|
||||||
|
$disabled-color: var(--text-secondary);
|
||||||
|
$disabled-background-color: var(--text-disabled);
|
||||||
|
|
||||||
|
$calendar-active-color: var(--text-primary);
|
||||||
|
$calendar-active-background-color: $primary-color !default;
|
||||||
|
|
||||||
|
$calendar-hover-color: $default-color !default;
|
||||||
|
$calendar-hover-background-color: $calendar-active-background-color;
|
||||||
|
|
||||||
|
$calendar-in-range-color: $default-color !default;
|
||||||
|
$calendar-in-range-background-color: $calendar-active-background-color;
|
||||||
|
|
||||||
|
$time-active-color: $primary-color !default;
|
||||||
|
$time-active-background-color: transparent !default;
|
||||||
|
|
||||||
|
$time-hover-color: $default-color !default;
|
||||||
|
$time-hover-background-color: $calendar-active-background-color;
|
||||||
|
|
||||||
|
$input-border-radius: $input-border-radius;
|
||||||
28
src/assets/scss/app.scss
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
@import "alerts";
|
||||||
|
@import "animations";
|
||||||
|
@import "backdrops";
|
||||||
|
@import "buttons";
|
||||||
|
@import "cards";
|
||||||
|
@import "checkboxes";
|
||||||
|
@import "dropdowns";
|
||||||
|
@import "forms";
|
||||||
|
@import "indicators";
|
||||||
|
@import "modals";
|
||||||
|
@import "navs";
|
||||||
|
@import "scaffolding";
|
||||||
|
@import "snackbars";
|
||||||
|
@import "surfaces";
|
||||||
|
@import "tables";
|
||||||
|
@import "transitions";
|
||||||
|
@import "type";
|
||||||
|
@import "utilities";
|
||||||
|
|
||||||
|
@import "components/app-editable";
|
||||||
|
@import "components/color-picker";
|
||||||
|
@import "components/duration-popover";
|
||||||
|
@import "components/empty-state";
|
||||||
|
@import "components/invoice";
|
||||||
|
@import "components/multiselect";
|
||||||
|
@import "components/search-popover";
|
||||||
|
@import "components/vue2-datepicker";
|
||||||
|
@import "components/vue-autosuggest";
|
||||||
25
src/assets/scss/components/_app-editable.scss
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
.editable {
|
||||||
|
&__project-selector {
|
||||||
|
button {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
border-bottom-color: var(--divider);
|
||||||
|
background-color: var(--shade);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1px;
|
||||||
|
@media print {
|
||||||
|
border-bottom: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/assets/scss/components/_color-picker.scss
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
.color-picker {
|
||||||
|
.dropdown-menu {
|
||||||
|
min-width: initial;
|
||||||
|
width: 11.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/assets/scss/components/_duration-popover.scss
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
.duration-popover__container {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
.duration-popover__overlay {
|
||||||
|
z-index: 1;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-popover__select {
|
||||||
|
@extend .bg-base;
|
||||||
|
background-color: var(--dp24);
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
width: 340px;
|
||||||
|
box-shadow: $box-shadow-light-3;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/assets/scss/components/_empty-state.scss
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
.empty-state {
|
||||||
|
&__task {
|
||||||
|
height: 72px;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
/* Permalink - use to edit and share this gradient: https://colorzilla.com/gradient-editor/#ffffff+0,f8f9fa+100 */
|
||||||
|
background: rgb(255, 255, 255); /* Old browsers */
|
||||||
|
background: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 1) 0%, rgba(248, 249, 250, 1) 100%); /* FF3.6-15 */
|
||||||
|
background: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 1) 0%, rgba(248, 249, 250, 1) 100%); /* Chrome10-25,Safari5.1-6 */
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(248, 249, 250, 1) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
|
||||||
|
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#f8f9fa', GradientType=1); /* IE6-9 fallback on horizontal gradient */
|
||||||
|
transition: opacity $link-transition-duration $base-ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
height: 12px;
|
||||||
|
width: 60%;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
background-color: $gray-highlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/assets/scss/components/_invoice.scss
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
.invoice-box {
|
||||||
|
margin: auto;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: $box-shadow-light-1;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
html, body {
|
||||||
|
width: 210mm;
|
||||||
|
height: 297mm;
|
||||||
|
}
|
||||||
|
.invoice-box {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-invoice-row {
|
||||||
|
position: absolute;
|
||||||
|
right: -30px;
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
299
src/assets/scss/components/_multiselect.scss
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
.multiselect {
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
&.multiselect-custom {
|
||||||
|
.multiselect__tags {
|
||||||
|
border-color: $input-focus-border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--lg {
|
||||||
|
.multiselect__tags {
|
||||||
|
padding-top: $input-padding-y-lg !important;
|
||||||
|
padding-bottom: $input-padding-y-lg !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__single {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--form-control {
|
||||||
|
.multiselect {
|
||||||
|
.multiselect__tags {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__single,
|
||||||
|
.multiselect__input {
|
||||||
|
padding: $input-padding-y ($input-padding-x * 3) $input-padding-y $input-padding-x;
|
||||||
|
font-size: $input-font-size;
|
||||||
|
line-height: $input-line-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
&:before {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--capitalize {
|
||||||
|
.multiselect__option {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect {
|
||||||
|
&--primary {
|
||||||
|
.multiselect {
|
||||||
|
.multiselect__tags,
|
||||||
|
.multiselect__single,
|
||||||
|
.multiselect__input {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input {
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
&:before {
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--bg-primary) transparent transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect {
|
||||||
|
min-height: $input-height !important;
|
||||||
|
|
||||||
|
.multiselect__spinner {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__spinner:after,
|
||||||
|
.multiselect__spinner:before {
|
||||||
|
border-color: $green transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input,
|
||||||
|
.multiselect__single {
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: $input-font-weight;
|
||||||
|
font-size: $input-font-size;
|
||||||
|
color: $input-color;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input:-ms-input-placeholder {
|
||||||
|
color: $input-placeholder-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input::placeholder {
|
||||||
|
color: $input-placeholder-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input:hover,
|
||||||
|
.multiselect__single:hover {
|
||||||
|
border-color: $input-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__input:focus,
|
||||||
|
.multiselect__single:focus {
|
||||||
|
border-color: $input-focus-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tags {
|
||||||
|
min-height: $input-height;
|
||||||
|
border: 1px solid $input-border-color;
|
||||||
|
background: $input-bg;
|
||||||
|
//padding-bottom: 4px;
|
||||||
|
font-size: $input-font-size;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
padding: $input-padding-y ($input-padding-x * 2.5) $input-padding-y $input-padding-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tag {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tag-icon:after {
|
||||||
|
color: darken($green, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tag-icon:focus,
|
||||||
|
.multiselect__tag-icon:hover {
|
||||||
|
background: darken($green, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__tag-icon:focus:after,
|
||||||
|
.multiselect__tag-icon:hover:after {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__current {
|
||||||
|
border: 1px solid var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
transition: none;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -4px;
|
||||||
|
right: 12px;
|
||||||
|
color: $gray-600;
|
||||||
|
border-color: $gray-600 transparent transparent;
|
||||||
|
//display: none;
|
||||||
|
//color: $gray-light;
|
||||||
|
//border-color: $gray-light transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
//&:after {
|
||||||
|
// position: absolute;
|
||||||
|
// //font-family: 'Material Icons';
|
||||||
|
// display: inline-block;
|
||||||
|
// //content: '\e313';
|
||||||
|
// top: 50%;
|
||||||
|
// margin-top: -8px;
|
||||||
|
// right: 6px;
|
||||||
|
// font-size: 18px;
|
||||||
|
// pointer-events: none;
|
||||||
|
// color: inherit;
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.multiselect--active {
|
||||||
|
.multiselect__select {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__placeholder {
|
||||||
|
color: var(--bg-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
padding-top: 9px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__content-wrapper {
|
||||||
|
//@extend .bg-base;
|
||||||
|
//background: var(--dp24);
|
||||||
|
border: 1px solid var(--bg-primary);
|
||||||
|
box-shadow: $box-shadow-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__content {
|
||||||
|
position: relative;
|
||||||
|
@extend .bg-base;
|
||||||
|
background: var(--dp24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect--above .multiselect__content-wrapper {
|
||||||
|
border-top: 1px solid var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--highlight {
|
||||||
|
background: var(--shade);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--highlight:after {
|
||||||
|
background: var(--shade);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--selected {
|
||||||
|
background: $input-bg;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--selected.multiselect__option--highlight {
|
||||||
|
background: $input-bg;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--selected.multiselect__option--highlight:after {
|
||||||
|
background: $input-bg;
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: $input-disabled-bg;
|
||||||
|
|
||||||
|
.multiselect__tags {
|
||||||
|
background: $input-disabled-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__single {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__select {
|
||||||
|
opacity: .35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--disabled {
|
||||||
|
background: $input-disabled-bg !important;
|
||||||
|
color: var(--text-disabled) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--group {
|
||||||
|
background: $gray-100;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--group.multiselect__option--highlight {
|
||||||
|
background: inherit;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--group.multiselect__option--highlight:after {
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--disabled.multiselect__option--highlight {
|
||||||
|
background: $input-disabled-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--group-selected.multiselect__option--highlight {
|
||||||
|
background: $input-bg;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiselect__option--group-selected.multiselect__option--highlight:after {
|
||||||
|
background: $input-bg;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/assets/scss/components/_search-popover.scss
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
.search-popover__container {
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
.search-popover__overlay {
|
||||||
|
z-index: 1;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-popover__select {
|
||||||
|
@extend .bg-base;
|
||||||
|
background-color: var(--dp24);
|
||||||
|
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
box-shadow: $box-shadow-light-5;
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
padding: 0.75rem;
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
left: unset;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autosuggest__results-container {
|
||||||
|
ul {
|
||||||
|
@extend .scrollbar;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 320px;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0 0 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.autosuggest__results-before {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: initial;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.autosuggest__results-item {
|
||||||
|
border-radius: $border-radius;
|
||||||
|
transition: background-color $link-transition-duration $base-ease;
|
||||||
|
|
||||||
|
&--highlighted {
|
||||||
|
background-color: $gray-highlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/assets/scss/components/_vue-autosuggest.scss
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
.typeahead {
|
||||||
|
.autosuggest__results-container {
|
||||||
|
@extend .scrollbar;
|
||||||
|
position: absolute;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
box-shadow: $box-shadow-light-3;
|
||||||
|
z-index: 3;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
|
||||||
|
ul {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 300px;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0 .5rem 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&.autosuggest__results-before {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: initial;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.autosuggest__results {
|
||||||
|
@extend .bg-base;
|
||||||
|
background-color: var(--dp12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.autosuggest__results-item--highlighted {
|
||||||
|
background-color: var(--text-caption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.autosuggest__results-item--highlighted {
|
||||||
|
background-color: var(--text-caption);
|
||||||
|
}
|
||||||
33
src/assets/scss/components/_vue2-datepicker.scss
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
@import '~vue2-datepicker/scss/index';
|
||||||
|
|
||||||
|
.mx-input {
|
||||||
|
height: $input-height;
|
||||||
|
border-color: $input-border-color;
|
||||||
|
box-shadow: none;
|
||||||
|
color: $input-color;
|
||||||
|
font-size: $input-font-size;
|
||||||
|
padding-top: $input-padding-y;
|
||||||
|
padding-bottom: $input-padding-y;
|
||||||
|
background-color: $input-bg;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $input-focus-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $input-border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-icon-calendar,
|
||||||
|
.mx-icon-clear {
|
||||||
|
color: $input-color;
|
||||||
|
&:hover {
|
||||||
|
color: $input-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-datepicker-main {
|
||||||
|
@extend .bg-base;
|
||||||
|
background-color: var(--dp24);
|
||||||
|
}
|
||||||
20
src/components/EmptyState.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="`col-12 text-muted text-${align}`">
|
||||||
|
<small>{{ content }}</small>
|
||||||
|
<h4 class="mt-2">¯\_(ツ)_/¯</h4>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
content: {
|
||||||
|
default: 'Nothing here yet',
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
default: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
19
src/components/EmptyStateDrop.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div class="col-12 text-secondary text-center epmty-state">
|
||||||
|
<div class="empty-state__task mt-5 mb-3 p-3 d-flex flex-column justify-content-between">
|
||||||
|
<div class="empty-state__text"></div>
|
||||||
|
<div class="col-4 p-0 d-flex flex-row align-items-center">
|
||||||
|
<div class="empty-state__text"></div>
|
||||||
|
<i class="material-icons md-10 text-caption ml-2">lens</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1">Drop Tasks here</p>
|
||||||
|
<p>
|
||||||
|
<small>Overdue and unscheduled tasks<br> end up in backlog</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {};
|
||||||
|
</script>
|
||||||
99
src/components/bank-accounts/BankAccountForm.vue
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4>Bank account</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bankAccount" class="row">
|
||||||
|
<AppInput :value="bankAccount.bank_name"
|
||||||
|
@change="updateProp({ bank_name: $event })"
|
||||||
|
label="Bank Name"
|
||||||
|
field="bank_name"
|
||||||
|
:errors="errors"
|
||||||
|
class="col-sm-10"/>
|
||||||
|
<AppInput :value="bankAccount.account_no"
|
||||||
|
@change="updateProp({ account_no: $event })"
|
||||||
|
label="Account no"
|
||||||
|
field="account_no"
|
||||||
|
:errors="errors"
|
||||||
|
class="col-12"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="row">
|
||||||
|
<div class="col-12 pt-3">
|
||||||
|
<p>Loading..</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3 text-right">
|
||||||
|
<div class="col-12">
|
||||||
|
<button v-if="!isNew" class="btn btn-primary"
|
||||||
|
@click="$emit('done')">Done
|
||||||
|
</button>
|
||||||
|
<button v-if="isNew" class="btn btn-primary ml-2"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="createBankAccount">Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import NotificationService from '@/services/notification.service';
|
||||||
|
import AppInput from '@/components/form/AppInput';
|
||||||
|
import Errors from '@/utils/errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppInput,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
errors: new Errors(),
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
bankAccount: 'bankAccounts/bankAccount',
|
||||||
|
}),
|
||||||
|
isNew() {
|
||||||
|
return this.bankAccount && this.bankAccount.$isNew;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateProp(props) {
|
||||||
|
if (this.isNew) {
|
||||||
|
return this.$store.dispatch('bankAccounts/bankAccountProps', props);
|
||||||
|
}
|
||||||
|
this.errors.clear();
|
||||||
|
|
||||||
|
return this.$store.dispatch('bankAccounts/updateBankAccount', props)
|
||||||
|
.then((res) => {
|
||||||
|
NotificationService.success(res.message);
|
||||||
|
})
|
||||||
|
.catch(err => this.errors.set(err.response.data.errors));
|
||||||
|
},
|
||||||
|
createBankAccount() {
|
||||||
|
this.loading = true;
|
||||||
|
this.errors.clear();
|
||||||
|
|
||||||
|
return this.$store.dispatch('bankAccounts/createNewBankAccount', this.bankAccount)
|
||||||
|
.then((bankAccount) => {
|
||||||
|
this.$router.push({
|
||||||
|
query: {
|
||||||
|
bankAccountId: bankAccount.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.$emit('done');
|
||||||
|
})
|
||||||
|
.catch(err => this.errors.set(err.response.data.errors))
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
65
src/components/bank-accounts/BankAccountModal.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<BModal v-model="isOpen"
|
||||||
|
centered
|
||||||
|
hide-footer
|
||||||
|
hide-header
|
||||||
|
size="md"
|
||||||
|
content-class="bg-base dp--24">
|
||||||
|
<BankAccountForm @done="close()"/>
|
||||||
|
</BModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { BModal } from 'bootstrap-vue';
|
||||||
|
import BankAccountForm from '@/components/bank-accounts/BankAccountForm';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
BModal,
|
||||||
|
BankAccountForm,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isOpen: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.bankAccounts.isModalOpen;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (!val) {
|
||||||
|
this.$router.push({ query: {} });
|
||||||
|
this.$store.dispatch('bankAccounts/getBankAccounts');
|
||||||
|
}
|
||||||
|
this.$store.commit('bankAccounts/isModalOpen', val);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...mapGetters({
|
||||||
|
bankAccount: 'bankAccounts/bankAccount',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.query.bankAccountId'() {
|
||||||
|
this.getBankAccount();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getBankAccount();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getBankAccount() {
|
||||||
|
const query = this.$route.query;
|
||||||
|
if (query.hasOwnProperty('bankAccountId')) {
|
||||||
|
if ((this.bankAccount && this.bankAccount.id !== query.bankAccountId) || !this.bankAccount) {
|
||||||
|
this.$store.dispatch('bankAccounts/getBankAccount', query.bankAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.commit('bankAccounts/isModalOpen', true);
|
||||||
|
} else {
|
||||||
|
this.$store.commit('bankAccounts/isModalOpen', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
73
src/components/bank-accounts/BankAccountsList.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="!bankAccounts">Loading</div>
|
||||||
|
<div v-else-if="bankAccounts && bankAccounts.length > 0">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account no.</th>
|
||||||
|
<th>Bank</th>
|
||||||
|
<th class="text-right"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="account in bankAccounts" :key="account.id"
|
||||||
|
@click="onSelect(account)" :class="{pointer: $listeners.select }">
|
||||||
|
<td>{{ account.account_no }}</td>
|
||||||
|
<td>{{ account.bank_name }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<i class="material-icons md-18 p-1 pointer"
|
||||||
|
@click.stop="openBankAccountModal(account)">
|
||||||
|
edit
|
||||||
|
</i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-sm btn-link" @click="createNewAccount">Add bank account</button>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else>
|
||||||
|
<template v-slot>
|
||||||
|
<button class="btn btn-sm btn-link" @click="createNewAccount">Add bank account</button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { formatDate } from '@/filters/date.filter';
|
||||||
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EmptyState,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
date: formatDate,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
bankAccounts: 'bankAccounts/all',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch('bankAccounts/getBankAccounts');
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
createNewAccount() {
|
||||||
|
this.$store.dispatch('bankAccounts/openNewBankAccountModal');
|
||||||
|
},
|
||||||
|
openBankAccountModal(bankAccount) {
|
||||||
|
this.$store.commit('bankAccounts/bankAccountId', bankAccount.id);
|
||||||
|
this.$router.push({
|
||||||
|
query: {
|
||||||
|
bankAccountId: bankAccount.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSelect(account) {
|
||||||
|
this.$emit('select', account);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
171
src/components/clients/ClientForm.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4>Client</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="client" class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h5>General</h5>
|
||||||
|
</div>
|
||||||
|
<AppInput :value="client.company_name" @change="updateProp({ company_name: $event })"
|
||||||
|
label="Company Name" field="company_name" :errors="errors" class="col-12"/>
|
||||||
|
<AppInput :value="client.invoice_email" @change="updateProp({ invoice_email: $event })"
|
||||||
|
label="Email" field="invoice_email" :errors="errors" class="col-sm-7"/>
|
||||||
|
<AppInput :value="client.company_reg_no" @change="updateProp({ company_reg_no: $event })"
|
||||||
|
label="Company reg no" field="company_reg_no" :errors="errors" class="col-sm-5"/>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<h5>Invoice Settings</h5>
|
||||||
|
<h6>Address</h6>
|
||||||
|
</div>
|
||||||
|
<AppInput :value="client.company_address" @change="updateProp({ company_address: $event })"
|
||||||
|
label="Company Address" field="company_address" :errors="errors"
|
||||||
|
class="col-12"/>
|
||||||
|
<AppInput :value="client.company_postal_code"
|
||||||
|
@change="updateProp({ company_postal_code: $event })"
|
||||||
|
label="Postal code" field="company_postal_code" :errors="errors"
|
||||||
|
class="col-sm-5"/>
|
||||||
|
<AppInput :value="client.company_city" @change="updateProp({ company_city: $event })"
|
||||||
|
label="City" field="company_city" :errors="errors" class="col-sm-7"/>
|
||||||
|
<AppInput :value="client.company_county" @change="updateProp({ company_county: $event })"
|
||||||
|
label="County/State" field="company_county" :errors="errors" class="col-sm-6"/>
|
||||||
|
<AppInput :value="client.company_country" @change="updateProp({ company_country: $event })"
|
||||||
|
label="Country" field="company_country" :errors="errors" class="col-sm-6"/>
|
||||||
|
<AppInput :value="client.currency" @change="updateProp({ currency: $event })"
|
||||||
|
label="Currency" field="currency" :errors="errors" class="col-sm-4"/>
|
||||||
|
<AppInput :value="client.rate" @change="updateProp({ rate: $event })"
|
||||||
|
label="Hourly rate" field="rate" :errors="errors" class="col-sm-4"/>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<h6>VAT</h6>
|
||||||
|
</div>
|
||||||
|
<AppInput :value="client.company_vat_no" @change="updateProp({ company_vat_no: $event })"
|
||||||
|
label="Company VAT no" field="company_vat_no" :errors="errors" class="col-sm-8"/>
|
||||||
|
<AppCheckbox :value="client.has_vat" @input="updateProp({ has_vat: $event })"
|
||||||
|
label="Apply VAT" field="has_vat" :errors="errors" class="col-sm-4"/>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<h6>Banking details</h6>
|
||||||
|
</div>
|
||||||
|
<AppSelect :value="client.bank_account"
|
||||||
|
track-by="id"
|
||||||
|
label="Bank account"
|
||||||
|
label-field="bank_name"
|
||||||
|
:options="bankAccounts || []"
|
||||||
|
@input="bankAccountChanged"
|
||||||
|
class="col-12"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!client">Loading</div>
|
||||||
|
|
||||||
|
<div class="row mt-3 text-right" v-if="client">
|
||||||
|
<div class="col-12">
|
||||||
|
<div v-if="!isNew">
|
||||||
|
<button class="btn btn-outline-danger mr-2" @click="deleteClient(client.id)">Delete</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
@click="$emit('done')">Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button v-else class="btn btn-primary ml-2"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="createClient">Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import NotificationService from '@/services/notification.service';
|
||||||
|
import AppInput from '@/components/form/AppInput';
|
||||||
|
import AppSelect from '@/components/form/AppSelect';
|
||||||
|
import Errors from '@/utils/errors';
|
||||||
|
import AppCheckbox from '@/components/form/AppCheckbox';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppCheckbox,
|
||||||
|
AppInput,
|
||||||
|
AppSelect,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
errors: new Errors(),
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
client: 'clients/client',
|
||||||
|
bankAccounts: 'bankAccounts/all',
|
||||||
|
}),
|
||||||
|
isNew() {
|
||||||
|
return this.client && this.client.$isNew;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getBankAccounts();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getBankAccounts() {
|
||||||
|
this.$store.dispatch('bankAccounts/getBankAccounts');
|
||||||
|
},
|
||||||
|
updateProp(props) {
|
||||||
|
if (this.isNew) {
|
||||||
|
return this.$store.dispatch('clients/clientProps', props);
|
||||||
|
}
|
||||||
|
this.errors.clear();
|
||||||
|
|
||||||
|
this.$store.dispatch('clients/updateClient', props)
|
||||||
|
.then((res) => {
|
||||||
|
NotificationService.success(res.message);
|
||||||
|
})
|
||||||
|
.catch(err => this.errors.set(err.response.data.errors));
|
||||||
|
},
|
||||||
|
bankAccountChanged(val) {
|
||||||
|
this.updateProp({
|
||||||
|
bank_account_id: val ? val.id : null,
|
||||||
|
bank_account: val,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createClient() {
|
||||||
|
this.loading = true;
|
||||||
|
this.errors.clear();
|
||||||
|
|
||||||
|
return this.$store.dispatch('clients/createNewClient', this.client)
|
||||||
|
.then((client) => {
|
||||||
|
this.$router.push({
|
||||||
|
query: {
|
||||||
|
clientId: client.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.$emit('done');
|
||||||
|
})
|
||||||
|
.catch(err => this.errors.set(err.response.data.errors))
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async deleteClient(clientId) {
|
||||||
|
const confirmed = await this.$bvModal.msgBoxConfirm(`Delete client ${this.client.company_name}?`, {
|
||||||
|
okTitle: 'Delete',
|
||||||
|
okVariant: 'danger',
|
||||||
|
cancelTitle: 'Dismiss',
|
||||||
|
cancelVariant: 'btn-link',
|
||||||
|
contentClass: 'bg-base dp--24',
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
this.$emit('done');
|
||||||
|
const res = await this.$store.dispatch('clients/deleteClient', clientId);
|
||||||
|
try {
|
||||||
|
NotificationService.success(res.message);
|
||||||
|
} catch (err) {
|
||||||
|
NotificationService.error(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
64
src/components/clients/ClientModal.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<BModal v-model="isOpen"
|
||||||
|
centered
|
||||||
|
hide-footer
|
||||||
|
hide-header
|
||||||
|
content-class="bg-base dp--24">
|
||||||
|
<ClientForm @done="close()"/>
|
||||||
|
</BModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { BModal } from 'bootstrap-vue';
|
||||||
|
import ClientForm from '@/components/clients/ClientForm';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ClientForm,
|
||||||
|
BModal,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isOpen: {
|
||||||
|
get() {
|
||||||
|
return this.$store.state.clients.isModalOpen;
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
if (!val) {
|
||||||
|
this.$router.push({ query: {} });
|
||||||
|
this.$store.dispatch('clients/getClients');
|
||||||
|
}
|
||||||
|
this.$store.commit('clients/isModalOpen', val);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...mapGetters({
|
||||||
|
client: 'clients/client',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.query.clientId'() {
|
||||||
|
this.getClient();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getClient();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getClient() {
|
||||||
|
const query = this.$route.query;
|
||||||
|
if (query.hasOwnProperty('clientId')) {
|
||||||
|
if ((this.client && this.client.id !== query.clientId) || !this.client) {
|
||||||
|
this.$store.dispatch('clients/getClient', query.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.commit('clients/isModalOpen', true);
|
||||||
|
} else {
|
||||||
|
this.$store.commit('clients/isModalOpen', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
149
src/components/clients/ClientSelector.vue
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search-popover__container">
|
||||||
|
<div class="editable__item"
|
||||||
|
:class="btnClasses"
|
||||||
|
ref="button"
|
||||||
|
:tabindex="tabindex"
|
||||||
|
@click="toggleOpen">
|
||||||
|
<span v-if="!value">Client</span>
|
||||||
|
<span v-else>{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="search-popover__overlay" v-if="isOpen" @click="toggleOpen"></div>
|
||||||
|
<VueAutosuggest
|
||||||
|
class="search-popover__select"
|
||||||
|
v-show="isOpen"
|
||||||
|
ref="suggest"
|
||||||
|
:input-props="{placeholder: 'Search client', class: 'form-control'}"
|
||||||
|
:suggestions="suggestions"
|
||||||
|
:value="query"
|
||||||
|
:get-suggestion-value="getSuggestionValue"
|
||||||
|
:should-render-suggestions="shouldRenderSuggestions"
|
||||||
|
@input="onInput"
|
||||||
|
@change="onChange"
|
||||||
|
@selected="onSelected"
|
||||||
|
@keydown.esc="toggleOpen"
|
||||||
|
@keydown.tab="toggleOpen"
|
||||||
|
@keydown.down="onKeyDown"
|
||||||
|
@keydown.ctrl.enter="createNewClient"
|
||||||
|
>
|
||||||
|
<template slot-scope="{ suggestion }">
|
||||||
|
<span>{{ suggestion.item.company_name }}</span>
|
||||||
|
</template>
|
||||||
|
<template slot="after-suggestions">
|
||||||
|
<button class="btn btn-link mt-2"
|
||||||
|
ref="createNewButton"
|
||||||
|
@click="createNewClient"
|
||||||
|
@keydown.up="returnToSuggestions">
|
||||||
|
<i class="material-icons material-icons-round md-18">add</i>
|
||||||
|
Create {{this.query ? `"${this.query}"` : 'new'}}
|
||||||
|
<code class="ml-2 badge badge-secondary">ctrl + enter</code>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</VueAutosuggest>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { VueAutosuggest } from 'vue-autosuggest';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
VueAutosuggest,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {},
|
||||||
|
btnClass: {},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
query: '',
|
||||||
|
tabindex: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
suggestions() {
|
||||||
|
return [{
|
||||||
|
data: [
|
||||||
|
/* { company_name: 'No client', id: null }, */
|
||||||
|
...this.$store.getters['clients/all'] || [],
|
||||||
|
]
|
||||||
|
.filter(client => !this.query || client.company_name.toLowerCase()
|
||||||
|
.indexOf(String(this.query)
|
||||||
|
.toLowerCase()) !== -1),
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
input() {
|
||||||
|
return this.$refs.suggest.$el.querySelector('input');
|
||||||
|
},
|
||||||
|
button() {
|
||||||
|
return this.$refs.button;
|
||||||
|
},
|
||||||
|
btnClasses() {
|
||||||
|
return !this.value ? `text-muted ${this.btnClass}` : this.btnClass;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleOpen() {
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
this.isOpen = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.tabindex = -1;
|
||||||
|
this.input.click();
|
||||||
|
this.input.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.tabindex = 0;
|
||||||
|
// this.button.focus();
|
||||||
|
});
|
||||||
|
this.query = '';
|
||||||
|
},
|
||||||
|
getSuggestionValue(/* suggestion */) {
|
||||||
|
// return suggestion.item.name;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onInput(query) {
|
||||||
|
this.query = query;
|
||||||
|
this.$emit('input', query);
|
||||||
|
},
|
||||||
|
onChange(event) {
|
||||||
|
this.$emit('change', event.target.value);
|
||||||
|
},
|
||||||
|
onSelected(suggestion) {
|
||||||
|
if (suggestion) {
|
||||||
|
this.$emit('selected', suggestion.item);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createNewClient() {
|
||||||
|
if (this.query.length) {
|
||||||
|
const client = await this.$store.dispatch('clients/createNewClient', { company_name: this.query });
|
||||||
|
this.$emit('selected', client);
|
||||||
|
} else {
|
||||||
|
this.$store.dispatch('clients/openNewClientModal');
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
},
|
||||||
|
shouldRenderSuggestions() {
|
||||||
|
return this.isOpen;
|
||||||
|
},
|
||||||
|
onKeyDown() {
|
||||||
|
if (this.$refs.suggest.totalResults === 0) {
|
||||||
|
this.$refs.createNewButton.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
returnToSuggestions() {
|
||||||
|
this.input.focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
29
src/components/form/AppCheckbox.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" :id="field"
|
||||||
|
class="form-check-input"
|
||||||
|
:class="{
|
||||||
|
'is-invalid': errors && errors.has(field)
|
||||||
|
}"
|
||||||
|
:checked="value"
|
||||||
|
@input="$emit('input', $event.target.checked)">
|
||||||
|
<label class="form-check-label" :for="field">
|
||||||
|
{{ label }}
|
||||||
|
<slot/>
|
||||||
|
</label>
|
||||||
|
<AppError v-if="errors" :errors="errors" :field="field"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
props: ['errors', 'label', 'value', 'field'],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
69
src/components/form/AppColorPicker.vue
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group color-picker">
|
||||||
|
<label :for="field" v-if="label">{{ label }}</label>
|
||||||
|
<b-dropdown variant="outline-secondary"
|
||||||
|
right
|
||||||
|
:disabled="disabled"
|
||||||
|
class="w-100">
|
||||||
|
<template slot="button-content">
|
||||||
|
<i class="material-icons" v-if="value" :style="{ color: value }">lens</i>
|
||||||
|
<!-- <span v-else>Choose color</span>-->
|
||||||
|
</template>
|
||||||
|
<b-dropdown-item-button v-for="color in colors"
|
||||||
|
:key="color"
|
||||||
|
@click="$emit('input', color)">
|
||||||
|
<i class="material-icons" :style="{ color }">lens</i>
|
||||||
|
</b-dropdown-item-button>
|
||||||
|
</b-dropdown>
|
||||||
|
<slot/>
|
||||||
|
<AppError v-if="errors" :errors="errors" :field="field"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { BDropdown, BDropdownItemButton } from 'bootstrap-vue';
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
BDropdown,
|
||||||
|
BDropdownItemButton,
|
||||||
|
},
|
||||||
|
props: ['errors', 'label', 'value', 'field', 'disabled'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
colors: [
|
||||||
|
'rgb(6, 170, 245)',
|
||||||
|
'rgb(0, 0, 0)',
|
||||||
|
'rgb(234, 70, 141)',
|
||||||
|
'rgb(251, 139, 20)',
|
||||||
|
'rgb(199, 116, 28)',
|
||||||
|
|
||||||
|
'rgb(75, 200, 0)',
|
||||||
|
'rgb(137, 0, 0)',
|
||||||
|
'rgb(225, 154, 134)',
|
||||||
|
'rgb(197, 107, 255)',
|
||||||
|
'rgb(32, 85, 0)',
|
||||||
|
|
||||||
|
'rgb(55, 80, 181)',
|
||||||
|
'rgb(160, 26, 165)',
|
||||||
|
'rgb(241, 195, 63)',
|
||||||
|
'rgb(226, 5, 5)',
|
||||||
|
'rgb(4, 187, 155)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.selectRandom();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectRandom() {
|
||||||
|
if (!this.value) {
|
||||||
|
this.$emit('input', _.sample(this.colors));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
97
src/components/form/AppDatePicker.vue
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label :for="field" :class="labelClasses" v-if="label">{{ label }}</label>
|
||||||
|
<div :class="containerClasses">
|
||||||
|
<!-- <div class="input-group-prepend">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="material-icons md-18 text-muted" v-if="value">today</i>
|
||||||
|
<i class="material-icons md-18 text-muted" v-else>calendar_today</i>
|
||||||
|
</span>
|
||||||
|
</div>-->
|
||||||
|
<DatePicker :disabled="disabled"
|
||||||
|
:inline="inline"
|
||||||
|
:id="field"
|
||||||
|
:class="[
|
||||||
|
errors && errors.has(field) ? 'is-invalid' : '',
|
||||||
|
...inputClasses,
|
||||||
|
]"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
autocomplete="off"
|
||||||
|
@change="outputValue"
|
||||||
|
:lang="{
|
||||||
|
formatLocale: {
|
||||||
|
firstDayOfWeek: 1,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
:range="mode === 'range'"
|
||||||
|
:format="format"
|
||||||
|
:type="type"
|
||||||
|
:value="inputValue"/>
|
||||||
|
<slot></slot>
|
||||||
|
<AppError v-if="errors" :errors="errors" :field="field"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import DatePicker from 'vue2-datepicker';
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
DatePicker,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
errors: {},
|
||||||
|
label: {},
|
||||||
|
mode: {},
|
||||||
|
value: {},
|
||||||
|
field: {},
|
||||||
|
disabled: {},
|
||||||
|
inline: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
default: 'YYYY-MM-DD',
|
||||||
|
},
|
||||||
|
modelFormat: {
|
||||||
|
default: 'YYYY-MM-DD',
|
||||||
|
},
|
||||||
|
type: {},
|
||||||
|
placeholder: {},
|
||||||
|
labelClasses: {},
|
||||||
|
inputClasses: {},
|
||||||
|
containerClasses: {},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputValue() {
|
||||||
|
return Array.isArray(this.value)
|
||||||
|
? this.value.map(val => dayjs(val, this.modelFormat).toDate())
|
||||||
|
: dayjs(this.value, this.modelFormat).toDate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
outputValue(event) {
|
||||||
|
const value = Array.isArray(event)
|
||||||
|
? event.map(val => this.toModelFormat(val))
|
||||||
|
: this.toModelFormat(event);
|
||||||
|
this.$emit('input', value);
|
||||||
|
this.$emit('change', value);
|
||||||
|
},
|
||||||
|
toModelFormat(val) {
|
||||||
|
return val ? dayjs(val).format(this.modelFormat) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
.mx-datepicker-popup {
|
||||||
|
z-index: 1040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-datepicker {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/components/form/AppEditable.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="{
|
||||||
|
'text-muted': !tmpVal,
|
||||||
|
'd-print-none': !tmpVal,
|
||||||
|
'is-invalid': errors && errors.has(field)
|
||||||
|
}"
|
||||||
|
class="editable">
|
||||||
|
<span ref="editable"
|
||||||
|
class="editable__item"
|
||||||
|
contenteditable
|
||||||
|
v-on="listeners"
|
||||||
|
:class="{'position-absolute': !tmpVal || (!tmpVal && !isFocused)}"
|
||||||
|
></span>
|
||||||
|
<span v-if="!tmpVal" @click="focus"
|
||||||
|
class="editable__item">{{ placeholder }}</span>
|
||||||
|
<span v-if="suffix">{{ suffix }}</span>
|
||||||
|
<AppError v-if="errors" :errors="errors" :field="field"/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: 'Enter item',
|
||||||
|
},
|
||||||
|
suffix: {},
|
||||||
|
errors: {},
|
||||||
|
field: {},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
focusInVal: null,
|
||||||
|
tmpVal: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
listeners() {
|
||||||
|
return {
|
||||||
|
...this.$listeners,
|
||||||
|
input: this.onInput,
|
||||||
|
focusin: this.onFocusIn,
|
||||||
|
focusout: this.onFocusOut,
|
||||||
|
isFocused: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value() {
|
||||||
|
this.$refs.editable.innerText = this.value;
|
||||||
|
this.tmpVal = this.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$refs.editable.innerText = this.value;
|
||||||
|
this.tmpVal = this.value;
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onInput(e) {
|
||||||
|
this.tmpVal = e.target.innerText;
|
||||||
|
this.$emit('input', this.tmpVal);
|
||||||
|
},
|
||||||
|
onFocusIn() {
|
||||||
|
this.isFocused = true;
|
||||||
|
this.focusInVal = this.$refs.editable.innerText;
|
||||||
|
},
|
||||||
|
onFocusOut() {
|
||||||
|
this.isFocused = false;
|
||||||
|
if (this.focusInVal !== this.$refs.editable.innerText) {
|
||||||
|
this.$emit('change', this.$refs.editable.innerText);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
this.$refs.editable.focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
13
src/components/form/AppError.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="invalid-feedback" v-if="errors.has(field)">
|
||||||
|
<template v-for="error in errors.get(field)">
|
||||||
|
{{ error }} <br>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['errors', 'field'],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
64
src/components/form/AppInput.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group">
|
||||||
|
<label :for="field" :class="labelClasses" v-if="label">{{ label }}</label>
|
||||||
|
<div :class="containerClasses">
|
||||||
|
<input :disabled="disabled"
|
||||||
|
:type="inputType"
|
||||||
|
:id="field"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
class="form-control"
|
||||||
|
:class="[
|
||||||
|
errors && errors.has(field) ? 'is-invalid' : '',
|
||||||
|
size ? 'form-control-' + size : '',
|
||||||
|
...inputClasses,
|
||||||
|
]"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:maxlength="max"
|
||||||
|
:value="value"
|
||||||
|
@input="$emit('input', $event.target.value)"
|
||||||
|
@change="$emit('change', $event.target.value)"
|
||||||
|
@keydown.self.enter.exact="$emit('submit', $event.target.value)"
|
||||||
|
:ref="field"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<AppError v-if="errors" :errors="errors" :field="field"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
errors: {},
|
||||||
|
label: {},
|
||||||
|
value: {},
|
||||||
|
field: {},
|
||||||
|
type: {},
|
||||||
|
max: {},
|
||||||
|
disabled: {},
|
||||||
|
placeholder: {},
|
||||||
|
size: {},
|
||||||
|
labelClasses: {},
|
||||||
|
inputClasses: {},
|
||||||
|
containerClasses: {},
|
||||||
|
autocomplete: {
|
||||||
|
default: 'on',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputType() {
|
||||||
|
return this.type || 'text';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
focus() {
|
||||||
|
this.$refs[this.field].focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
62
src/components/form/AppSelect.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group multiselect--form-control">
|
||||||
|
<label :for="field" v-if="label">{{ label }}</label>
|
||||||
|
<Multiselect :id="field"
|
||||||
|
:options="options"
|
||||||
|
:track-by="trackBy"
|
||||||
|
:disabled="disabled"
|
||||||
|
:label="labelField"
|
||||||
|
:allow-empty="allowEmpty"
|
||||||
|
:custom-label="customLabel"
|
||||||
|
:deselect-label="deselectLabel"
|
||||||
|
:select-label="selectLabel"
|
||||||
|
:selected-label="selectedLabel"
|
||||||
|
:preserve-search="true"
|
||||||
|
@input="$emit('input', $event)"
|
||||||
|
@search-change="$emit('search-change', $event)"
|
||||||
|
:value="value"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:loading="loading"
|
||||||
|
:class="{
|
||||||
|
'is-invalid': errors && errors.has(field)
|
||||||
|
}"
|
||||||
|
:multiple="multiple"
|
||||||
|
>
|
||||||
|
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
|
||||||
|
<slot :name="name" v-bind="slotData"/>
|
||||||
|
</template>
|
||||||
|
</Multiselect>
|
||||||
|
<AppError v-if="errors" :errors="errors" :field="field"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Multiselect from 'vue-multiselect';
|
||||||
|
import 'vue-multiselect/dist/vue-multiselect.min.css';
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
Multiselect,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
errors: {},
|
||||||
|
label: {},
|
||||||
|
value: {},
|
||||||
|
field: {},
|
||||||
|
options: {},
|
||||||
|
multiple: {},
|
||||||
|
trackBy: {},
|
||||||
|
labelField: {},
|
||||||
|
customLabel: {},
|
||||||
|
placeholder: {},
|
||||||
|
loading: {},
|
||||||
|
allowEmpty: { default: false },
|
||||||
|
deselectLabel: { default: '' },
|
||||||
|
selectLabel: { default: '' },
|
||||||
|
selectedLabel: { default: '' },
|
||||||
|
disabled: { default: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
61
src/components/form/AppTextarea.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group">
|
||||||
|
<label :for="field" v-if="label" :class="labelClasses">{{ label }}</label>
|
||||||
|
<div :class="containerClasses">
|
||||||
|
<textarea :disabled="disabled"
|
||||||
|
:id="field"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
class="form-control"
|
||||||
|
:rows="rows"
|
||||||
|
:class="[
|
||||||
|
errors && errors.has(field) ? 'is-invalid' : '',
|
||||||
|
size ? 'form-control-' + size : '',
|
||||||
|
...inputClasses,
|
||||||
|
]"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:maxlength="max"
|
||||||
|
:value="value"
|
||||||
|
@input="$emit('input', $event.target.value)"
|
||||||
|
@change="$emit('change', $event.target.value)"
|
||||||
|
@keydown.self.enter.exact="$emit('submit', $event.target.value)"
|
||||||
|
:ref="field"
|
||||||
|
>
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
<AppError v-if="errors" :errors="errors" :field="field"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
errors: {},
|
||||||
|
label: {},
|
||||||
|
value: {},
|
||||||
|
field: {},
|
||||||
|
type: {},
|
||||||
|
max: {},
|
||||||
|
rows: {},
|
||||||
|
disabled: {},
|
||||||
|
placeholder: {},
|
||||||
|
size: {},
|
||||||
|
labelClasses: {},
|
||||||
|
inputClasses: {},
|
||||||
|
containerClasses: {},
|
||||||
|
autocomplete: {
|
||||||
|
default: 'on',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
focus() {
|
||||||
|
this.$refs[this.field].focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
85
src/components/form/AppTypeahead.vue
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-group typeahead">
|
||||||
|
<VueAutosuggest
|
||||||
|
ref="suggest"
|
||||||
|
:input-props="{placeholder: placeholder, class: 'form-control form-control-sm tracker__input'}"
|
||||||
|
:section-configs="config"
|
||||||
|
:suggestions="suggestions"
|
||||||
|
:value="value"
|
||||||
|
:get-suggestion-value="getSuggestionValue"
|
||||||
|
:should-render-suggestions="(size, loading) => size >= 0 && !loading && !closed"
|
||||||
|
@input="onInput"
|
||||||
|
@change="onChange"
|
||||||
|
@blur="onBlur"
|
||||||
|
@focus="onFocus"
|
||||||
|
@selected="onSelected"
|
||||||
|
>
|
||||||
|
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
|
||||||
|
<slot :name="name" v-bind="slotData"/>
|
||||||
|
</template>
|
||||||
|
</VueAutosuggest>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { VueAutosuggest } from 'vue-autosuggest';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
VueAutosuggest,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {},
|
||||||
|
placeholder: {},
|
||||||
|
options: {},
|
||||||
|
labelField: {},
|
||||||
|
sectionConfigs: {},
|
||||||
|
filter: { default: (option, query) => (option[this.labelField].toLowerCase().indexOf(query.toLowerCase()) > -1) },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
closed: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
suggestions() {
|
||||||
|
return this.options.map(group => ({
|
||||||
|
...group,
|
||||||
|
data: group.data.filter(option => this.filter(option, this.value)),
|
||||||
|
}))
|
||||||
|
.filter(group => group.data.length > 0);
|
||||||
|
},
|
||||||
|
config() {
|
||||||
|
return {
|
||||||
|
default: { onSelected: this.onSelected },
|
||||||
|
...this.sectionConfigs,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getSuggestionValue(suggestion) {
|
||||||
|
return suggestion.item[this.labelField];
|
||||||
|
},
|
||||||
|
onInput(query) {
|
||||||
|
this.$emit('input', query);
|
||||||
|
},
|
||||||
|
onChange(event) {
|
||||||
|
this.$emit('change', event.target.value);
|
||||||
|
},
|
||||||
|
onFocus() {
|
||||||
|
this.closed = false;
|
||||||
|
},
|
||||||
|
onBlur() {
|
||||||
|
this.closed = true;
|
||||||
|
},
|
||||||
|
onSelected(suggestion) {
|
||||||
|
if (!suggestion) {
|
||||||
|
this.$refs.suggest.$el.querySelector('input').blur();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Enter on no suggestion returns null
|
||||||
|
this.$emit('selected', suggestion);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
53
src/components/invoices/InvoiceBankDetails.vue
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<strong v-b-modal.bank_details class="editable__item"
|
||||||
|
:class="{'is-invalid': errors && errors.has('bank_account_no')}">
|
||||||
|
{{ invoice.bank_account_no }}
|
||||||
|
<span v-if="!invoice.bank_account_no">Add bank account no</span>
|
||||||
|
</strong>
|
||||||
|
<AppError :errors="errors" field="bank_account_no"/>
|
||||||
|
<br>
|
||||||
|
<span class="editable__item" v-b-modal.bank_details
|
||||||
|
:class="{'is-invalid': errors && errors.has('bank_name')}">
|
||||||
|
{{ invoice.bank_name }}
|
||||||
|
<span v-if="!invoice.bank_name">Add bank name</span>
|
||||||
|
</span>
|
||||||
|
<AppError :errors="errors" field="bank_name"/>
|
||||||
|
|
||||||
|
<BModal id="bank_details"
|
||||||
|
centered
|
||||||
|
title="Choose bank account"
|
||||||
|
hide-footer
|
||||||
|
size="lg"
|
||||||
|
content-class="bg-base dp--24">
|
||||||
|
<BankAccountsList @select="accountSelected"/>
|
||||||
|
</BModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { BModal, VBModal } from 'bootstrap-vue';
|
||||||
|
import BankAccountsList from '@/components/bank-accounts/BankAccountsList';
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['invoice', 'errors'],
|
||||||
|
components: {
|
||||||
|
BModal,
|
||||||
|
BankAccountsList,
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
'b-modal': VBModal,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
accountSelected(account) {
|
||||||
|
this.$emit('update', {
|
||||||
|
bank_account_no: account.account_no,
|
||||||
|
bank_name: account.bank_name,
|
||||||
|
bank_account_id: account.id,
|
||||||
|
});
|
||||||
|
this.$bvModal.hide('bank_details');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
100
src/components/invoices/InvoiceClientDetails.vue
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<ClientSelector :value="invoice.client_name" btn-class="font-weight-bold" @selected="clientSelected"/>
|
||||||
|
</div>
|
||||||
|
<AppEditable :value="invoice.client_address"
|
||||||
|
suffix=", "
|
||||||
|
placeholder="Address"
|
||||||
|
@change="updateProp({ client_address: $event })"/>
|
||||||
|
<AppEditable :value="invoice.client_postal_code"
|
||||||
|
placeholder="Postal code"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ client_postal_code: $event })"/>
|
||||||
|
<AppError :errors="errors" field="client_address"/>
|
||||||
|
<AppError :errors="errors" field="client_postal_code"/>
|
||||||
|
|
||||||
|
<AppEditable :value="invoice.client_city"
|
||||||
|
suffix=", "
|
||||||
|
placeholder="City"
|
||||||
|
@change="updateProp({ client_city: $event })"/>
|
||||||
|
<AppEditable :value="invoice.client_county"
|
||||||
|
suffix=", "
|
||||||
|
placeholder="County/State"
|
||||||
|
@change="updateProp({ client_county: $event })"/>
|
||||||
|
<AppEditable :value="invoice.client_country"
|
||||||
|
placeholder="Country"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ client_country: $event })"/>
|
||||||
|
<AppError :errors="errors" field="client_city"/>
|
||||||
|
<AppError :errors="errors" field="client_county"/>
|
||||||
|
<AppError :errors="errors" field="client_country"/>
|
||||||
|
|
||||||
|
<span :class="{'d-print-none': !invoice.client_reg_no }">Reg no: </span>
|
||||||
|
<AppEditable :value="invoice.client_reg_no"
|
||||||
|
:errors="errors"
|
||||||
|
field="client_reg_no"
|
||||||
|
placeholder="Enter reg no"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ client_reg_no: $event })"/>
|
||||||
|
<span :class="{'d-print-none': !invoice.client_vat_no }">VAT no: </span>
|
||||||
|
<AppEditable :value="invoice.client_vat_no"
|
||||||
|
:errors="errors"
|
||||||
|
field="client_vat_no"
|
||||||
|
placeholder="Enter vat no"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ client_vat_no: $event })"/>
|
||||||
|
<AppEditable :value="invoice.client_email"
|
||||||
|
:errors="errors"
|
||||||
|
field="client_email"
|
||||||
|
class="break-line"
|
||||||
|
placeholder="Client's email"
|
||||||
|
@change="updateProp({ client_email: $event })"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
import AppEditable from '@/components/form/AppEditable';
|
||||||
|
import ClientSelector from '@/components/clients/ClientSelector';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['invoice', 'errors'],
|
||||||
|
components: {
|
||||||
|
AppError,
|
||||||
|
ClientSelector,
|
||||||
|
AppEditable,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
team: 'teams/team',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateProp(props) {
|
||||||
|
this.$emit('update', props);
|
||||||
|
},
|
||||||
|
clientSelected(client) {
|
||||||
|
this.prefillClient(client);
|
||||||
|
},
|
||||||
|
prefillClient(client) {
|
||||||
|
return this.updateProp({
|
||||||
|
client_id: client.id,
|
||||||
|
client_name: client.company_name,
|
||||||
|
client_address: client.company_address,
|
||||||
|
client_postal_code: client.company_postal_code,
|
||||||
|
client_city: client.company_city,
|
||||||
|
client_county: client.company_county,
|
||||||
|
client_country: client.company_country,
|
||||||
|
client_reg_no: client.company_reg_no,
|
||||||
|
client_vat_no: client.company_vat_no,
|
||||||
|
client_email: client.invoice_email,
|
||||||
|
currency: client.currency || 'USD',
|
||||||
|
vat_rate: client.has_vat ? this.team.vat_rate : 0,
|
||||||
|
bank_name: client.bank_account ? client.bank_account.bank_name : null,
|
||||||
|
bank_account_no: client.bank_account ? client.bank_account.account_no : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
75
src/components/invoices/InvoiceCompanyDetails.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
<AppEditable :value="invoice.from_name"
|
||||||
|
:errors="errors"
|
||||||
|
field="from_name"
|
||||||
|
placeholder="Your company name"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ from_name: $event })"/>
|
||||||
|
</strong>
|
||||||
|
<AppEditable :value="invoice.from_address"
|
||||||
|
suffix=", "
|
||||||
|
placeholder="Address"
|
||||||
|
@change="updateProp({ from_address: $event })"/>
|
||||||
|
<AppEditable :value="invoice.from_postal_code"
|
||||||
|
placeholder="Postal code"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ from_postal_code: $event })"/>
|
||||||
|
<AppError :errors="errors" field="from_address"/>
|
||||||
|
<AppError :errors="errors" field="from_postal_code"/>
|
||||||
|
|
||||||
|
<AppEditable :value="invoice.from_city"
|
||||||
|
suffix=", "
|
||||||
|
placeholder="City"
|
||||||
|
@change="updateProp({ from_city: $event })"/>
|
||||||
|
<AppEditable :value="invoice.from_county"
|
||||||
|
suffix=", "
|
||||||
|
placeholder="County/State"
|
||||||
|
@change="updateProp({ from_county: $event })"/>
|
||||||
|
<AppEditable :value="invoice.from_country"
|
||||||
|
placeholder="Country"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ from_country: $event })"/>
|
||||||
|
<AppError :errors="errors" field="from_city"/>
|
||||||
|
<AppError :errors="errors" field="from_county"/>
|
||||||
|
<AppError :errors="errors" field="from_country"/>
|
||||||
|
|
||||||
|
<span :class="{'d-print-none': !invoice.from_reg_no }">Reg no: </span>
|
||||||
|
<AppEditable :value="invoice.from_reg_no"
|
||||||
|
:errors="errors"
|
||||||
|
field="from_reg_no"
|
||||||
|
placeholder="Enter reg no"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ from_reg_no: $event })"/>
|
||||||
|
<span :class="{'d-print-none': !invoice.from_vat_no }">VAT no: </span>
|
||||||
|
<AppEditable :value="invoice.from_vat_no"
|
||||||
|
:errors="errors"
|
||||||
|
field="from_vat_no"
|
||||||
|
placeholder="Enter vat no"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ from_vat_no: $event })"/>
|
||||||
|
<AppEditable :value="invoice.from_email"
|
||||||
|
:errors="errors"
|
||||||
|
field="from_email"
|
||||||
|
placeholder="Your email"
|
||||||
|
@change="updateProp({ from_email: $event })"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
import AppEditable from '../form/AppEditable';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['invoice', 'errors'],
|
||||||
|
components: {
|
||||||
|
AppEditable,
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateProp(props) {
|
||||||
|
this.$emit('update', props);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
36
src/components/invoices/InvoiceContactDetails.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<AppEditable :value="invoice.from_website"
|
||||||
|
:errors="errors"
|
||||||
|
field="from_website"
|
||||||
|
placeholder="Add website"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ from_website: $event })"/>
|
||||||
|
<AppEditable :value="invoice.from_email"
|
||||||
|
:errors="errors"
|
||||||
|
field="from_email"
|
||||||
|
placeholder="Add email"
|
||||||
|
class="break-line"
|
||||||
|
@change="updateProp({ from_email: $event })"/>
|
||||||
|
<AppEditable :value="invoice.from_phone"
|
||||||
|
:errors="errors"
|
||||||
|
field="from_phone"
|
||||||
|
placeholder="Add phone"
|
||||||
|
@change="updateProp({ from_phone: $event })"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import AppEditable from '../form/AppEditable';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['invoice', 'errors'],
|
||||||
|
components: {
|
||||||
|
AppEditable,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateProp(props) {
|
||||||
|
this.$emit('update', props);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
77
src/components/invoices/InvoiceControls.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="row" v-if="invoice">
|
||||||
|
<div class="col-12 mb-4 d-flex justify-content-between align-items-start">
|
||||||
|
<router-link class="btn btn-sm btn-light btn--icon-left"
|
||||||
|
:to="{name: 'invoices'}">
|
||||||
|
<i class="material-icons">arrow_back</i>
|
||||||
|
<span class="d-inline-block">Back</span>
|
||||||
|
<!-- Back-->
|
||||||
|
</router-link>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<!-- <button class="btn btn-sm btn-outline-danger mr-2" @click="deleteInvoice">Delete</button>-->
|
||||||
|
<!-- <a :href="invoice.pdf_url" target="_blank" class="btn btn-sm btn-outline-primary mr-2">PDF</a>-->
|
||||||
|
|
||||||
|
<AppSelect :value="invoice.status"
|
||||||
|
class="mb-0 mr-2 text-capitalize multiselect--capitalize"
|
||||||
|
:options="['draft', 'booked', 'sent', 'paid', 'cancelled']"
|
||||||
|
@input="updateProp({status: $event})"/>
|
||||||
|
<button class="btn btn-sm btn-outline-dark"
|
||||||
|
v-if="invoice.status === 'draft'"
|
||||||
|
@click="bookInvoice">Book
|
||||||
|
</button>
|
||||||
|
<b-dropdown variant="link" size="sm" no-caret right>
|
||||||
|
<template slot="button-content">
|
||||||
|
<i class="material-icons">more_vert</i>
|
||||||
|
</template>
|
||||||
|
<b-dropdown-item :href="invoice.pdf_url" target="_blank">Download PDF</b-dropdown-item>
|
||||||
|
<b-dropdown-item-button @click="deleteInvoice">Delete</b-dropdown-item-button>
|
||||||
|
</b-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import NotificationService from '@/services/notification.service';
|
||||||
|
import { BDropdown, BDropdownItem, BDropdownItemButton } from 'bootstrap-vue';
|
||||||
|
import AppSelect from '@/components/form/AppSelect';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
BDropdown,
|
||||||
|
BDropdownItem,
|
||||||
|
BDropdownItemButton,
|
||||||
|
AppSelect,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
invoice: 'invoices/invoice',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async deleteInvoice() {
|
||||||
|
const confirmed = await this.$bvModal.msgBoxConfirm(`Delete invoice ${this.invoice.number}?`, {
|
||||||
|
okTitle: 'Delete',
|
||||||
|
okVariant: 'danger',
|
||||||
|
cancelTitle: 'Dismiss',
|
||||||
|
cancelVariant: 'btn-link',
|
||||||
|
contentClass: 'bg-base dp--24',
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
const res = await this.$store.dispatch('invoices/deleteInvoice', this.invoice);
|
||||||
|
NotificationService.success(res.message);
|
||||||
|
this.$router.push({
|
||||||
|
name: 'invoices',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bookInvoice() {
|
||||||
|
this.$store.dispatch('invoices/bookInvoice');
|
||||||
|
},
|
||||||
|
updateProp(props) {
|
||||||
|
this.$store.dispatch('invoices/updateInvoice', props);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
115
src/components/invoices/InvoiceForm.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base dp--02 invoice-box" v-if="invoice">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-5">
|
||||||
|
<div class="col-4">
|
||||||
|
<img v-if="team.logo_url"
|
||||||
|
:src="team.logo_url" style="width:100%; max-width:200px;">
|
||||||
|
<!-- TODO: logo url input -->
|
||||||
|
<AppError :errors="errors" field="team.logos"/>
|
||||||
|
</div>
|
||||||
|
<InvoiceHeader :invoice="invoice" :errors="errors" @update="updateProp"
|
||||||
|
class="col-8 text-right mb-2"/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<InvoiceClientDetails :invoice="invoice" :errors="errors" @update="updateProp"
|
||||||
|
class="col-6"/>
|
||||||
|
<InvoiceCompanyDetails :invoice="invoice" :errors="errors" @update="updateProp"
|
||||||
|
class="col-6 text-right"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<AppEditable :value="invoice.notes"
|
||||||
|
class="col-12"
|
||||||
|
placeholder="Insert note"
|
||||||
|
@change="updateProp({ notes: $event })"/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Unit</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th class="text-right">Sum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<InvoiceRow v-for="(row, index) in invoice.rows" :errors="errors"
|
||||||
|
:row="row" :index="index" :key="row.id"/>
|
||||||
|
<tr class="d-print-none">
|
||||||
|
<td colspan="5">
|
||||||
|
<button class="btn btn-sm" @click="addRow">
|
||||||
|
<i class="material-icons md-18 pointer">add</i>
|
||||||
|
</button>
|
||||||
|
<AppError :errors="errors" field="rows"/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<InvoiceTotals :invoice="invoice" :errors="errors" @update="updateProp"/>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="row">
|
||||||
|
<InvoiceBankDetails :invoice="invoice" :errors="errors" @update="updateProp"
|
||||||
|
class="col-8"/>
|
||||||
|
<InvoiceContactDetails :invoice="invoice" :errors="errors" @update="updateProp"
|
||||||
|
class="col-4 text-right"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters, mapState } from 'vuex';
|
||||||
|
import InvoiceRow from '@/components/invoices/InvoiceRow';
|
||||||
|
import InvoiceClientDetails from '@/components/invoices/InvoiceClientDetails';
|
||||||
|
import InvoiceCompanyDetails from '@/components/invoices/InvoiceCompanyDetails';
|
||||||
|
import InvoiceBankDetails from '@/components/invoices/InvoiceBankDetails';
|
||||||
|
import InvoiceContactDetails from '@/components/invoices/InvoiceContactDetails';
|
||||||
|
import InvoiceHeader from '@/components/invoices/InvoiceHeader';
|
||||||
|
import InvoiceTotals from '@/components/invoices/InvoiceTotals';
|
||||||
|
import AppEditable from '@/components/form/AppEditable';
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
InvoiceTotals,
|
||||||
|
InvoiceHeader,
|
||||||
|
InvoiceContactDetails,
|
||||||
|
InvoiceBankDetails,
|
||||||
|
InvoiceCompanyDetails,
|
||||||
|
InvoiceRow,
|
||||||
|
InvoiceClientDetails,
|
||||||
|
AppEditable,
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
errors: state => state.invoices.errors,
|
||||||
|
}),
|
||||||
|
...mapGetters({
|
||||||
|
team: 'teams/team',
|
||||||
|
invoice: 'invoices/invoice',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.params.id'() {
|
||||||
|
this.getInvoice();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.getInvoice();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getInvoice() {
|
||||||
|
this.$store.dispatch('invoices/getInvoice', this.$route.params.id);
|
||||||
|
},
|
||||||
|
updateProp(props) {
|
||||||
|
this.$store.dispatch('invoices/updateInvoice', props);
|
||||||
|
},
|
||||||
|
addRow() {
|
||||||
|
this.$store.dispatch('invoices/addRow');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
73
src/components/invoices/InvoiceHeader.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
Invoice
|
||||||
|
<AppEditable :value="invoice.number"
|
||||||
|
:errors="errors"
|
||||||
|
field="number"
|
||||||
|
placeholder="NO."
|
||||||
|
@change="updateProp({ number: $event })"/>
|
||||||
|
</h3>
|
||||||
|
Issued at: <span class="editable__item" v-b-modal.modal_issued_at>{{ invoice.issued_at | date('D. MMM YYYY', 'YYYY-MM-DD') }}</span>
|
||||||
|
<BModal id="modal_issued_at"
|
||||||
|
centered
|
||||||
|
title="Issued at"
|
||||||
|
hide-footer
|
||||||
|
size="sm"
|
||||||
|
content-class="bg-base dp--24">
|
||||||
|
<AppDatePicker :value="invoice.issued_at"
|
||||||
|
@change="updateProp({ issued_at: $event })"
|
||||||
|
:errors="errors"
|
||||||
|
:inline="true"
|
||||||
|
field="issued_at"/>
|
||||||
|
</BModal>
|
||||||
|
<br>Due at: <span class="editable__item" v-b-modal.modal_due_at>{{ invoice.due_at | date('D. MMM YYYY', 'YYYY-MM-DD') }}</span>
|
||||||
|
<BModal id="modal_due_at"
|
||||||
|
centered
|
||||||
|
title="Due at"
|
||||||
|
hide-footer
|
||||||
|
size="sm"
|
||||||
|
content-class="bg-base dp--24">
|
||||||
|
<AppDatePicker :value="invoice.due_at"
|
||||||
|
@change="updateProp({ due_at: $event })"
|
||||||
|
:errors="errors"
|
||||||
|
:inline="true"
|
||||||
|
field="due_at"/>
|
||||||
|
</BModal>
|
||||||
|
<br>Late fee:
|
||||||
|
<AppEditable :value="invoice.late_fee | currency"
|
||||||
|
:errors="errors"
|
||||||
|
suffix="%"
|
||||||
|
field="late_fee"
|
||||||
|
placeholder="Add late fee"
|
||||||
|
@change="updateProp({ late_fee: $event })"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { BModal, VBModal } from 'bootstrap-vue';
|
||||||
|
import AppEditable from '@/components/form/AppEditable';
|
||||||
|
import AppDatePicker from '@/components/form/AppDatePicker';
|
||||||
|
import { formatDate } from '@/filters/date.filter';
|
||||||
|
import { formatCurrency } from '@/filters/currency.filter';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['invoice', 'errors'],
|
||||||
|
components: {
|
||||||
|
AppEditable,
|
||||||
|
AppDatePicker,
|
||||||
|
BModal,
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
'b-modal': VBModal,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
date: formatDate,
|
||||||
|
currency: formatCurrency,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateProp(props) {
|
||||||
|
this.$emit('update', props);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
66
src/components/invoices/InvoiceRow.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<AppEditable :value="row.item"
|
||||||
|
:errors="errors"
|
||||||
|
:field="`rows.${index}.item`"
|
||||||
|
placeholder="Enter item"
|
||||||
|
@change="updateProp({ item: $event })"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<AppEditable :value="row.quantity"
|
||||||
|
:errors="errors"
|
||||||
|
:field="`rows.${index}.quantity`"
|
||||||
|
placeholder="Enter quantity"
|
||||||
|
@change="updateProp({ quantity: $event })"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<AppEditable :value="row.unit"
|
||||||
|
:errors="errors"
|
||||||
|
:field="`rows.${index}.unit`"
|
||||||
|
placeholder="Enter unit"
|
||||||
|
@change="updateProp({ unit: $event })"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<AppEditable :value="row.price | currency"
|
||||||
|
:errors="errors"
|
||||||
|
:field="`rows.${index}.price`"
|
||||||
|
placeholder="Enter price"
|
||||||
|
@change="updateProp({ price: $event })"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-right position-relative">
|
||||||
|
{{ (row.quantity * row.price) | currency }}
|
||||||
|
<button class="btn btn-sm remove-invoice-row d-print-none" @click="removeRow(row)">
|
||||||
|
<i class="material-icons md-18 pointer">remove</i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { formatCurrency } from '../../filters/currency.filter';
|
||||||
|
import AppEditable from '../form/AppEditable';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['row', 'errors', 'index'],
|
||||||
|
name: 'InvoiceRow',
|
||||||
|
components: {
|
||||||
|
AppEditable,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
currency: formatCurrency,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateProp(props) {
|
||||||
|
this.$store.dispatch('invoices/updateInvoiceRow', {
|
||||||
|
props,
|
||||||
|
id: this.row.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async removeRow(row) {
|
||||||
|
await this.$store.dispatch('invoices/removeRow', row);
|
||||||
|
this.updateProp();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
62
src/components/invoices/InvoiceTotals.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="text-right">
|
||||||
|
<td colspan="4">Subtotal</td>
|
||||||
|
<td>{{ subTotal | currency }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-right">
|
||||||
|
<td colspan="4">
|
||||||
|
VAT
|
||||||
|
(<AppEditable :value="invoice.vat_rate | currency"
|
||||||
|
suffix="%"
|
||||||
|
placeholder="Add VAT"
|
||||||
|
@change="updateProp({ vat_rate: $event })"/>)
|
||||||
|
<AppError :errors="errors" field="vat_rate"/>
|
||||||
|
</td>
|
||||||
|
<td>{{ totalVat | currency }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="text-right">
|
||||||
|
<th colspan="4">
|
||||||
|
Total
|
||||||
|
<AppEditable :value="invoice.currency"
|
||||||
|
:errors="errors"
|
||||||
|
field="currency"
|
||||||
|
placeholder="Add currency"
|
||||||
|
@change="updateProp({ currency: $event })"/>
|
||||||
|
</th>
|
||||||
|
<th class="text-nowrap">{{ total | currency }}</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import AppError from '@/components/form/AppError';
|
||||||
|
import AppEditable from '../form/AppEditable';
|
||||||
|
import { formatDate } from '../../filters/date.filter';
|
||||||
|
import { formatCurrency } from '../../filters/currency.filter';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['invoice', 'errors'],
|
||||||
|
components: {
|
||||||
|
AppEditable,
|
||||||
|
AppError,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
date: formatDate,
|
||||||
|
currency: formatCurrency,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
rows: 'invoices/rows',
|
||||||
|
subTotal: 'invoices/subTotal',
|
||||||
|
total: 'invoices/total',
|
||||||
|
totalVat: 'invoices/totalVat',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateProp(props) {
|
||||||
|
this.$emit('update', props);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
84
src/components/invoices/InvoicesList.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="!invoices" class="col-12">Loading</div>
|
||||||
|
<table class="table table--card table-hover" v-else-if="invoices && invoices.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>No.</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Issued at</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th class="text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody v-if="invoices">
|
||||||
|
<tr v-for="invoice in invoices"
|
||||||
|
class="pointer"
|
||||||
|
:key="invoice.id"
|
||||||
|
@click="openInvoice(invoice)">
|
||||||
|
<td>{{ invoice.number }}</td>
|
||||||
|
<td>{{ invoice.client ? invoice.client.company_name : '' }}</td>
|
||||||
|
<td>{{ invoice.issued_at | date('D MMM YYYY', 'YYYY-MM-DD') }}</td>
|
||||||
|
<td>
|
||||||
|
{{ invoice.total | currency }}
|
||||||
|
<small v-if="invoice.vat_rate"><br>({{ totalWithVat(invoice) | currency }})</small>
|
||||||
|
</td>
|
||||||
|
<td class="text-right text-capitalize">
|
||||||
|
<i class="material-icons material-icons-round md-18 mr-2 text-warning"
|
||||||
|
v-if="isOverDue(invoice)"
|
||||||
|
v-b-tooltip.hover title="Overdue">warning</i>
|
||||||
|
<i class="material-icons material-icons-round md-18 mr-2 text-success"
|
||||||
|
v-else-if="invoice.status === 'paid'">done</i>
|
||||||
|
{{ invoice.status }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<EmptyState v-else/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { formatDate } from '@/filters/date.filter';
|
||||||
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
import { formatCurrency } from '@/filters/currency.filter';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { VBTooltip } from 'bootstrap-vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
EmptyState,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
date: formatDate,
|
||||||
|
currency: formatCurrency,
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
'b-tooltip': VBTooltip,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
invoices: 'invoices/all',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch('invoices/getInvoices');
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
openInvoice(invoice) {
|
||||||
|
this.$store.commit('invoices/invoiceId', invoice.id);
|
||||||
|
this.$router.push({
|
||||||
|
name: 'invoice',
|
||||||
|
params: { id: invoice.id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
totalWithVat(invoice) {
|
||||||
|
return (invoice.vat_rate / 100 * invoice.total) + invoice.total;
|
||||||
|
},
|
||||||
|
isOverDue(invoice) {
|
||||||
|
return invoice.status === 'sent' && invoice.due_at < dayjs()
|
||||||
|
.format();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
7
src/config/storage.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import storage from 'localforage';
|
||||||
|
|
||||||
|
storage.config({
|
||||||
|
name: 'serverlessInvoices',
|
||||||
|
version: 1.0,
|
||||||
|
storeName: 'default',
|
||||||
|
});
|
||||||
11
src/filters/currency.filter.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export function formatCurrency(val, digits = 2) {
|
||||||
|
if (val !== null) {
|
||||||
|
const x = parseFloat(val);
|
||||||
|
if (Number.isNaN(x)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts = x.toFixed(digits).split('.');
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||||
|
return parts.join('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/filters/date.filter.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||||
|
|
||||||
|
dayjs.extend(customParseFormat);
|
||||||
|
|
||||||
|
export function formatDate(val, toFormat = 'D MMM YYYY', fromFormat = 'YYYY-MM-DDTHH:mm:ssZ') {
|
||||||
|
if (val) {
|
||||||
|
return dayjs(String(val), fromFormat)
|
||||||
|
.format(toFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/main.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import 'es6-promise';
|
||||||
|
import '@/config/storage.config';
|
||||||
|
import { BVModalPlugin } from 'bootstrap-vue';
|
||||||
|
import Vue from 'vue';
|
||||||
|
import App from '@/App.vue';
|
||||||
|
import router from '@/router';
|
||||||
|
import store from '@/store/store';
|
||||||
|
import VueNotifications from 'vue-notification';
|
||||||
|
|
||||||
|
Vue.use(BVModalPlugin);
|
||||||
|
Vue.use(VueNotifications);
|
||||||
|
|
||||||
|
Vue.config.productionTip = false;
|
||||||
|
|
||||||
|
const app = new Vue({
|
||||||
|
router,
|
||||||
|
store,
|
||||||
|
render: h => h(App),
|
||||||
|
}).$mount('#app');
|
||||||
|
|
||||||
|
export default app;
|
||||||
43
src/router.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
import Router from 'vue-router';
|
||||||
|
import store from '@/store/store';
|
||||||
|
|
||||||
|
Vue.use(Router);
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'dashboard',
|
||||||
|
redirect: 'invoices',
|
||||||
|
component: () => import(/* webpackChunkName: "dashboard" */ '@/views/dashboard/Dashboard.vue'),
|
||||||
|
beforeEnter: async (to, from, next) => {
|
||||||
|
await store.dispatch('teams/init');
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/invoices',
|
||||||
|
name: 'invoices',
|
||||||
|
component: () => import(/* webpackChunkName: "invoices" */ '@/views/dashboard/Invoices.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/invoice/:id',
|
||||||
|
name: 'invoice',
|
||||||
|
component: () => import(/* webpackChunkName: "invoice" */ '@/views/dashboard/Invoice.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/invoices/:id/print',
|
||||||
|
name: 'invoice-print',
|
||||||
|
component: () => import(/* webpackChunkName: "invoice" */ '@/views/InvoicePrint.vue'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = new Router({
|
||||||
|
mode: 'history',
|
||||||
|
base: process.env.BASE_URL,
|
||||||
|
routes,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
35
src/services/bank-account.service.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import storage from 'localforage';
|
||||||
|
|
||||||
|
class BankAccountService {
|
||||||
|
async getBankAccounts() {
|
||||||
|
const bankAccounts = await storage.getItem('bank_accounts');
|
||||||
|
|
||||||
|
return bankAccounts || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBankAccount(bankAccountId) {
|
||||||
|
const bankAccounts = await this.getBankAccounts();
|
||||||
|
return bankAccounts.find(bank_account => bank_account.id === bankAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBankAccount(bankAccount) {
|
||||||
|
const bankAccounts = await this.getBankAccounts();
|
||||||
|
|
||||||
|
delete bankAccount.$id;
|
||||||
|
delete bankAccount.$isNew;
|
||||||
|
delete bankAccount.$isDirty;
|
||||||
|
|
||||||
|
bankAccounts.push(bankAccount);
|
||||||
|
await storage.setItem('bank_accounts', bankAccounts);
|
||||||
|
return bankAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBankAccount(bankAccount) {
|
||||||
|
const bankAccounts = await this.getBankAccounts();
|
||||||
|
const index = bankAccounts.findIndex(item => item.id === bankAccount.id);
|
||||||
|
bankAccounts[index] = bankAccount;
|
||||||
|
return storage.setItem('bank_accounts', bankAccounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BankAccountService();
|
||||||
47
src/services/client.service.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import storage from 'localforage';
|
||||||
|
|
||||||
|
class ClientService {
|
||||||
|
async getClients() {
|
||||||
|
const clients = await storage.getItem('clients');
|
||||||
|
|
||||||
|
return clients || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient(clientId) {
|
||||||
|
const clients = await this.getClients();
|
||||||
|
return clients.find(client => client.id === clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createClient(client) {
|
||||||
|
const clients = await this.getClients();
|
||||||
|
|
||||||
|
delete client.$id;
|
||||||
|
delete client.$isNew;
|
||||||
|
delete client.$isDirty;
|
||||||
|
|
||||||
|
clients.push(client);
|
||||||
|
await storage.setItem('clients', clients);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateClient(client) {
|
||||||
|
const clients = await this.getClients();
|
||||||
|
const index = clients.findIndex(item => item.id === client.id);
|
||||||
|
|
||||||
|
// TODO: Fix this mess
|
||||||
|
if (index === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clients[index] = client;
|
||||||
|
return storage.setItem('clients', clients);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteClient(clientId) {
|
||||||
|
const clients = await this.getClients();
|
||||||
|
const index = clients.findIndex(item => item.id === clientId);
|
||||||
|
clients.splice(index, 1);
|
||||||
|
return storage.setItem('clients', clients);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ClientService();
|
||||||
48
src/services/invoice.service.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import storage from 'localforage';
|
||||||
|
|
||||||
|
class InvoiceService {
|
||||||
|
async getInvoices() {
|
||||||
|
const invoices = await storage.getItem('invoices');
|
||||||
|
return invoices || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoice(invoiceId) {
|
||||||
|
const invoices = await this.getInvoices();
|
||||||
|
return invoices.find(invoice => invoice.id === invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(invoice) {
|
||||||
|
// TODO: add invoice no, issued_at, due_at, late_fee, prefill company info, bank_info, currency, vat_rate
|
||||||
|
const invoices = await this.getInvoices();
|
||||||
|
|
||||||
|
delete invoice.$id;
|
||||||
|
delete invoice.$isNew;
|
||||||
|
delete invoice.$isDirty;
|
||||||
|
|
||||||
|
invoices.push(invoice);
|
||||||
|
await storage.setItem('invoices', invoices);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvoice(invoice) {
|
||||||
|
// TODO: validation
|
||||||
|
const invoices = await this.getInvoices();
|
||||||
|
const index = invoices.findIndex(item => item.id === invoice.id);
|
||||||
|
invoices[index] = invoice;
|
||||||
|
return storage.setItem('invoices', invoices);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInvoice(invoiceId) {
|
||||||
|
const invoices = await this.getInvoices();
|
||||||
|
const index = invoices.findIndex(item => item.id === invoiceId);
|
||||||
|
invoices.splice(index, 1);
|
||||||
|
return storage.setItem('invoices', invoices);
|
||||||
|
}
|
||||||
|
|
||||||
|
bookInvoice(invoice) {
|
||||||
|
// TODO: validation
|
||||||
|
invoice.status = 'booked';
|
||||||
|
return this.updateInvoice(invoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new InvoiceService();
|
||||||
24
src/services/notification.service.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
success(text) {
|
||||||
|
return Vue.notify({
|
||||||
|
type: 'success',
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
error(text, duration = 5000) {
|
||||||
|
return Vue.notify({
|
||||||
|
type: 'error',
|
||||||
|
text,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stickyError(text, title) {
|
||||||
|
return this.error(text, title, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NotificationService();
|
||||||
36
src/services/team.service.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import storage from 'localforage';
|
||||||
|
|
||||||
|
class TeamService {
|
||||||
|
async getTeam() {
|
||||||
|
let team = await storage.getItem('team');
|
||||||
|
if (!team) {
|
||||||
|
team = {
|
||||||
|
company_name: null,
|
||||||
|
company_address: null,
|
||||||
|
company_postal_code: null,
|
||||||
|
company_country: null,
|
||||||
|
company_county: null,
|
||||||
|
company_city: null,
|
||||||
|
company_reg_no: null,
|
||||||
|
company_vat_no: null,
|
||||||
|
website: null,
|
||||||
|
contact_email: null,
|
||||||
|
contact_phone: null,
|
||||||
|
vat_rate: null,
|
||||||
|
invoice_late_fee: null,
|
||||||
|
invoice_due_days: null,
|
||||||
|
updated_at: null,
|
||||||
|
created_at: null,
|
||||||
|
logo_url: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTeam(team) {
|
||||||
|
return storage.setItem('team', team);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TeamService();
|
||||||
67
src/store/bank-accounts.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import BankAccountService from '@/services/bank-account.service';
|
||||||
|
import BankAccount from '@/store/models/bank-account';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
bankAccountId: null,
|
||||||
|
isModalOpen: null,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
bankAccountId(state, bankAccountId) {
|
||||||
|
state.bankAccountId = bankAccountId;
|
||||||
|
},
|
||||||
|
isModalOpen(state, isOpen) {
|
||||||
|
state.isModalOpen = isOpen;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
init({ dispatch }) {
|
||||||
|
return dispatch('getBankAccounts');
|
||||||
|
},
|
||||||
|
terminate() {
|
||||||
|
return BankAccount.deleteAll();
|
||||||
|
},
|
||||||
|
async getBankAccounts() {
|
||||||
|
const accounts = await BankAccountService.getBankAccounts();
|
||||||
|
await BankAccount.create({ data: accounts });
|
||||||
|
return accounts;
|
||||||
|
},
|
||||||
|
async getBankAccount({ commit }, bankAccountId) {
|
||||||
|
const bankAccount = await BankAccountService.getBankAccount(bankAccountId);
|
||||||
|
commit('bankAccountId', bankAccount.id);
|
||||||
|
return BankAccount.insert({ data: bankAccount });
|
||||||
|
},
|
||||||
|
async createNewBankAccount(store, bankAccount) {
|
||||||
|
const res = await BankAccountService.createBankAccount(bankAccount);
|
||||||
|
await BankAccount.insert({ data: res });
|
||||||
|
return BankAccount.find(res.id);
|
||||||
|
},
|
||||||
|
bankAccountProps({ state }, props) {
|
||||||
|
return BankAccount.update({
|
||||||
|
where: state.bankAccountId,
|
||||||
|
data: props,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateBankAccount({ getters, dispatch }, props) {
|
||||||
|
await dispatch('bankAccountProps', props);
|
||||||
|
return BankAccountService.updateBankAccount(getters.bankAccount);
|
||||||
|
},
|
||||||
|
async openNewBankAccountModal({ commit }) {
|
||||||
|
const bankAccount = await BankAccount.createNew();
|
||||||
|
commit('bankAccountId', bankAccount.id);
|
||||||
|
commit('isModalOpen', true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
bankAccount(state) {
|
||||||
|
return BankAccount.query()
|
||||||
|
.find(state.bankAccountId);
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return BankAccount.query()
|
||||||
|
.where('$isNew', false)
|
||||||
|
.get();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
86
src/store/clients.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import ClientService from '@/services/client.service';
|
||||||
|
import Client from '@/store/models/client';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
clientId: null,
|
||||||
|
isModalOpen: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
clientId(state, clientId) {
|
||||||
|
state.clientId = clientId;
|
||||||
|
},
|
||||||
|
isModalOpen(state, isOpen) {
|
||||||
|
state.isModalOpen = isOpen;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
init({ dispatch }) {
|
||||||
|
return dispatch('getClients');
|
||||||
|
},
|
||||||
|
terminate() {
|
||||||
|
return Client.deleteAll();
|
||||||
|
},
|
||||||
|
async getClients() {
|
||||||
|
const clients = await ClientService.getClients();
|
||||||
|
await Client.create({ data: clients });
|
||||||
|
return clients;
|
||||||
|
},
|
||||||
|
async getClient({ commit }, clientId) {
|
||||||
|
const client = await ClientService.getClient(clientId);
|
||||||
|
commit('clientId', client.id);
|
||||||
|
Client.insert({ data: client });
|
||||||
|
},
|
||||||
|
async createNewClient(store, client) {
|
||||||
|
if (!client.hasOwnProperty('id')) {
|
||||||
|
client = new Client(client);
|
||||||
|
}
|
||||||
|
const res = await ClientService.createClient(client);
|
||||||
|
await Client.insert({ data: res });
|
||||||
|
return Client.find(res.id);
|
||||||
|
},
|
||||||
|
clientProps({ state }, props) {
|
||||||
|
return Client.update({
|
||||||
|
where: state.clientId,
|
||||||
|
data: props,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateClient({ getters, dispatch }, props) {
|
||||||
|
await dispatch('clientProps', props);
|
||||||
|
return ClientService.updateClient(getters.client);
|
||||||
|
},
|
||||||
|
async updateClientById(payload) {
|
||||||
|
const client = await Client.update({
|
||||||
|
where: payload.clientId,
|
||||||
|
data: payload.props,
|
||||||
|
});
|
||||||
|
return ClientService.updateClient(client);
|
||||||
|
},
|
||||||
|
async openNewClientModal({ commit }) {
|
||||||
|
const client = await Client.createNew();
|
||||||
|
commit('clientId', client.id);
|
||||||
|
commit('isModalOpen', true);
|
||||||
|
},
|
||||||
|
async deleteClient(clientId) {
|
||||||
|
const res = await ClientService.deleteClient(clientId);
|
||||||
|
if ('client_id' in res) {
|
||||||
|
Client.delete(res.client_id);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
client(state) {
|
||||||
|
return Client.query()
|
||||||
|
.with(['bank_account'])
|
||||||
|
.find(state.clientId);
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return Client.query()
|
||||||
|
.where('$isNew', false)
|
||||||
|
.with(['bank_account'])
|
||||||
|
.get();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
197
src/store/invoices.js
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import InvoiceService from '@/services/invoice.service';
|
||||||
|
import Invoice from '@/store/models/invoice';
|
||||||
|
import InvoiceRow from '@/store/models/invoice-row';
|
||||||
|
import { pick } from '@/utils/helpers';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import Errors from '@/utils/errors';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
errors: new Errors(),
|
||||||
|
invoiceId: null,
|
||||||
|
isSendModalOpen: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
invoiceId(state, invoiceId) {
|
||||||
|
state.invoiceId = invoiceId;
|
||||||
|
},
|
||||||
|
isSendModalOpen(state, isSendModalOpen) {
|
||||||
|
state.isSendModalOpen = isSendModalOpen;
|
||||||
|
},
|
||||||
|
setErrors(state, errors) {
|
||||||
|
state.errors.set(errors);
|
||||||
|
},
|
||||||
|
clearErrors(state) {
|
||||||
|
state.errors.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
init({ dispatch }) {
|
||||||
|
dispatch('getInvoices');
|
||||||
|
},
|
||||||
|
terminate() {
|
||||||
|
return Invoice.deleteAll();
|
||||||
|
},
|
||||||
|
async getInvoices() {
|
||||||
|
const invoices = await InvoiceService.getInvoices();
|
||||||
|
await Invoice.create({ data: invoices });
|
||||||
|
return invoices;
|
||||||
|
},
|
||||||
|
async getInvoice({ commit }, invoiceId) {
|
||||||
|
const invoice = await InvoiceService.getInvoice(invoiceId);
|
||||||
|
await Invoice.insert({ data: invoice });
|
||||||
|
commit('invoiceId', invoiceId);
|
||||||
|
return invoice;
|
||||||
|
},
|
||||||
|
async createNewInvoice() {
|
||||||
|
const invoice = await Invoice.createNew();
|
||||||
|
await InvoiceService.createInvoice(invoice);
|
||||||
|
return invoice.id;
|
||||||
|
},
|
||||||
|
invoiceProps({ state }, props) {
|
||||||
|
return Invoice.update({
|
||||||
|
where: state.invoiceId,
|
||||||
|
data: props,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
invoiceRowProps(store, payload) {
|
||||||
|
return InvoiceRow.update({
|
||||||
|
where: payload.id,
|
||||||
|
data: payload.props,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateInvoice({ getters, dispatch, commit }, props) {
|
||||||
|
await dispatch('invoiceProps', props);
|
||||||
|
|
||||||
|
// Update client
|
||||||
|
const clientProps = pick(props, {
|
||||||
|
bank_account_id: 'bank_account_id',
|
||||||
|
client_name: 'company_name',
|
||||||
|
client_address: 'company_address',
|
||||||
|
client_postal_code: 'company_postal_code',
|
||||||
|
client_country: 'company_country',
|
||||||
|
client_county: 'company_county',
|
||||||
|
client_city: 'company_city',
|
||||||
|
client_reg_no: 'company_reg_no',
|
||||||
|
client_vat_no: 'company_vat_no',
|
||||||
|
client_email: 'invoice_email',
|
||||||
|
currency: 'currency',
|
||||||
|
});
|
||||||
|
if ('vat_rate' in props) {
|
||||||
|
clientProps.has_vat = props.vat_rate > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(clientProps).length > 0 && getters.invoice.client_id) {
|
||||||
|
dispatch('clients/updateClientById', {
|
||||||
|
props: clientProps,
|
||||||
|
clientId: getters.invoice.client_id,
|
||||||
|
}, { root: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamProps = pick(props, {
|
||||||
|
late_fee: 'invoice_late_fee',
|
||||||
|
from_name: 'company_name',
|
||||||
|
from_address: 'company_address',
|
||||||
|
from_postal_code: 'company_postal_code',
|
||||||
|
from_city: 'company_city',
|
||||||
|
from_country: 'company_country',
|
||||||
|
from_county: 'company_county',
|
||||||
|
from_reg_no: 'company_reg_no',
|
||||||
|
from_vat_no: 'company_vat_no',
|
||||||
|
from_website: 'website',
|
||||||
|
from_email: 'contact_email',
|
||||||
|
from_phone: 'contact_phone',
|
||||||
|
vat_rate: 'vat_rate',
|
||||||
|
});
|
||||||
|
if ('due_at' in props || 'issued_at' in props) {
|
||||||
|
teamProps.invoice_due_days = dayjs(getters.invoice.due_at)
|
||||||
|
.diff(getters.invoice.issued_at, 'days');
|
||||||
|
}
|
||||||
|
if ('vat_rate' in props) {
|
||||||
|
// You can only set VAT to 0 if setting it directly under settings
|
||||||
|
// This is to avoid setting general VAT to 0, if only changing per invoice
|
||||||
|
if (parseFloat(teamProps.vat_rate) === 0) {
|
||||||
|
delete teamProps.vat_rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(teamProps).length > 0) {
|
||||||
|
dispatch('teams/updateTeam', teamProps, { root: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
commit('clearErrors');
|
||||||
|
|
||||||
|
return InvoiceService.updateInvoice(getters.invoice)
|
||||||
|
.catch(err => commit('setErrors', err.response.data.errors));
|
||||||
|
},
|
||||||
|
async updateInvoiceRow({ getters, dispatch, commit }, payload) {
|
||||||
|
await dispatch('invoiceRowProps', payload);
|
||||||
|
|
||||||
|
commit('clearErrors');
|
||||||
|
|
||||||
|
return InvoiceService.updateInvoice(getters.invoice)
|
||||||
|
.catch(err => commit('setErrors', err.response.data.errors));
|
||||||
|
},
|
||||||
|
async deleteInvoice(invoice) {
|
||||||
|
const res = await InvoiceService.deleteInvoice(invoice.id);
|
||||||
|
if ('invoice_id' in res) {
|
||||||
|
Invoice.delete(res.invoice_id);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
async addRow({ state, getters }) {
|
||||||
|
const row = await InvoiceRow.createNew();
|
||||||
|
row.$update({
|
||||||
|
invoice_id: state.invoiceId,
|
||||||
|
order: getters.invoice.rows.length,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async removeRow(store, row) {
|
||||||
|
await InvoiceRow.delete(row.id);
|
||||||
|
},
|
||||||
|
async sendInvoice({ state, dispatch }, message) {
|
||||||
|
const res = await InvoiceService.sendInvoice(state.invoiceId, message);
|
||||||
|
dispatch('invoiceProps', {
|
||||||
|
status: 'sent',
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
async bookInvoice({ getters, commit, dispatch }) {
|
||||||
|
commit('clearErrors');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await InvoiceService.bookInvoice(getters.invoice);
|
||||||
|
return dispatch('getInvoice', res.invoice_id);
|
||||||
|
} catch (err) {
|
||||||
|
commit('setErrors', err.response.data.errors);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
invoice(state) {
|
||||||
|
return Invoice.query()
|
||||||
|
.with(['client', 'project', 'team.logos'])
|
||||||
|
.with('rows', query => query.orderBy('order', 'asc'))
|
||||||
|
.find(state.invoiceId);
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return Invoice.query()
|
||||||
|
.where('$isNew', false)
|
||||||
|
.with(['client'])
|
||||||
|
.with('rows', query => query.orderBy('order', 'asc')) // TODO: do we need this?
|
||||||
|
.orderBy('issued_at', 'desc')
|
||||||
|
.orderBy('number', 'desc')
|
||||||
|
.get();
|
||||||
|
},
|
||||||
|
subTotal(state, getters) {
|
||||||
|
return getters.invoice.rows.reduce((carr, row) => (row.quantity * row.price) + carr, 0);
|
||||||
|
},
|
||||||
|
total(state, getters) {
|
||||||
|
return getters.subTotal + getters.totalVat;
|
||||||
|
},
|
||||||
|
totalVat(state, getters) {
|
||||||
|
return (getters.invoice.vat_rate / 100) * getters.subTotal;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
17
src/store/models/bank-account.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Model } from '@vuex-orm/core';
|
||||||
|
import { uuidv4 } from '@/utils/helpers';
|
||||||
|
|
||||||
|
export default class BankAccount extends Model {
|
||||||
|
// This is the name used as module name of the Vuex Store.
|
||||||
|
static entity = 'bank_accounts';
|
||||||
|
|
||||||
|
static fields() {
|
||||||
|
return {
|
||||||
|
id: this.attr(() => uuidv4()),
|
||||||
|
account_no: this.attr(''),
|
||||||
|
bank_name: this.attr(''),
|
||||||
|
updated_at: this.attr(''),
|
||||||
|
created_at: this.attr(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/store/models/client.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Model } from '@vuex-orm/core';
|
||||||
|
import { uuidv4 } from '@/utils/helpers';
|
||||||
|
import BankAccount from '@/store/models/bank-account';
|
||||||
|
|
||||||
|
export default class Client extends Model {
|
||||||
|
// This is the name used as module name of the Vuex Store.
|
||||||
|
static entity = 'clients';
|
||||||
|
|
||||||
|
static fields() {
|
||||||
|
return {
|
||||||
|
id: this.attr(() => uuidv4()),
|
||||||
|
company_name: this.attr(''),
|
||||||
|
company_address: this.attr(''),
|
||||||
|
company_postal_code: this.attr(''),
|
||||||
|
company_country: this.attr(''),
|
||||||
|
company_county: this.attr(''),
|
||||||
|
company_city: this.attr(''),
|
||||||
|
company_reg_no: this.attr(''),
|
||||||
|
company_vat_no: this.attr(''),
|
||||||
|
has_vat: this.attr(null),
|
||||||
|
currency: this.attr(null),
|
||||||
|
rate: this.attr(null),
|
||||||
|
invoice_email: this.attr(''),
|
||||||
|
bank_account_id: this.attr(null),
|
||||||
|
bank_account: this.belongsTo(BankAccount, 'bank_account_id', 'id'),
|
||||||
|
updated_at: this.attr(''),
|
||||||
|
created_at: this.attr(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/store/models/invoice-row.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Model } from '@vuex-orm/core';
|
||||||
|
import { uuidv4 } from '@/utils/helpers';
|
||||||
|
import Invoice from '@/store/models/invoice';
|
||||||
|
|
||||||
|
export default class InvoiceRow extends Model {
|
||||||
|
// This is the name used as module name of the Vuex Store.
|
||||||
|
static entity = 'invoice_rows';
|
||||||
|
|
||||||
|
static fields() {
|
||||||
|
return {
|
||||||
|
id: this.attr(() => uuidv4()),
|
||||||
|
invoice_id: this.attr(null),
|
||||||
|
invoice: this.belongsTo(Invoice, 'invoice_id'),
|
||||||
|
item: this.attr(''),
|
||||||
|
quantity: this.attr(null),
|
||||||
|
price: this.attr(null),
|
||||||
|
unit: this.attr(''),
|
||||||
|
order: this.attr(null),
|
||||||
|
updated_at: this.attr(''),
|
||||||
|
created_at: this.attr(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/store/models/invoice.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Model } from '@vuex-orm/core';
|
||||||
|
import { uuidv4 } from '@/utils/helpers';
|
||||||
|
import Client from '@/store/models/client';
|
||||||
|
import InvoiceRow from '@/store/models/invoice-row';
|
||||||
|
|
||||||
|
export default class Invoice extends Model {
|
||||||
|
// This is the name used as module name of the Vuex Store.
|
||||||
|
static entity = 'invoices';
|
||||||
|
|
||||||
|
static fields() {
|
||||||
|
return {
|
||||||
|
id: this.attr(() => uuidv4()),
|
||||||
|
number: this.attr(''),
|
||||||
|
status: this.attr('draft'),
|
||||||
|
issued_at: this.attr(''),
|
||||||
|
due_at: this.attr(''),
|
||||||
|
late_fee: this.attr(''),
|
||||||
|
vat_rate: this.attr(''),
|
||||||
|
currency: this.attr(''),
|
||||||
|
from_name: this.attr(''),
|
||||||
|
from_address: this.attr(''),
|
||||||
|
from_postal_code: this.attr(''),
|
||||||
|
from_city: this.attr(''),
|
||||||
|
from_country: this.attr(''),
|
||||||
|
from_county: this.attr(''),
|
||||||
|
from_reg_no: this.attr(''),
|
||||||
|
from_vat_no: this.attr(''),
|
||||||
|
from_website: this.attr(''),
|
||||||
|
from_email: this.attr(''),
|
||||||
|
from_phone: this.attr(''),
|
||||||
|
bank_name: this.attr(''),
|
||||||
|
bank_account_no: this.attr(''),
|
||||||
|
client_name: this.attr(''),
|
||||||
|
client_address: this.attr(''),
|
||||||
|
client_postal_code: this.attr(''),
|
||||||
|
client_country: this.attr(''),
|
||||||
|
client_county: this.attr(''),
|
||||||
|
client_city: this.attr(''),
|
||||||
|
client_reg_no: this.attr(''),
|
||||||
|
client_vat_no: this.attr(''),
|
||||||
|
client_email: this.attr(''),
|
||||||
|
client_id: this.attr(null),
|
||||||
|
client: this.belongsTo(Client, 'client_id'),
|
||||||
|
rows: this.hasMany(InvoiceRow, 'invoice_id'),
|
||||||
|
notes: this.attr(''),
|
||||||
|
updated_at: this.attr(''),
|
||||||
|
created_at: this.attr(''),
|
||||||
|
total: this.attr(null), // Only used in lists.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/store/models/team.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Model } from '@vuex-orm/core';
|
||||||
|
import { uuidv4 } from '@/utils/helpers';
|
||||||
|
|
||||||
|
export default class Team extends Model {
|
||||||
|
// This is the name used as module name of the Vuex Store.
|
||||||
|
static entity = 'teams';
|
||||||
|
|
||||||
|
static fields() {
|
||||||
|
return {
|
||||||
|
id: this.attr(() => uuidv4()),
|
||||||
|
company_name: this.attr(''),
|
||||||
|
company_address: this.attr(''),
|
||||||
|
company_postal_code: this.attr(''),
|
||||||
|
company_country: this.attr(''),
|
||||||
|
company_county: this.attr(''),
|
||||||
|
company_city: this.attr(''),
|
||||||
|
company_reg_no: this.attr(''),
|
||||||
|
company_vat_no: this.attr(''),
|
||||||
|
website: this.attr(''),
|
||||||
|
contact_email: this.attr(''),
|
||||||
|
contact_phone: this.attr(''),
|
||||||
|
vat_rate: this.attr(null),
|
||||||
|
invoice_late_fee: this.attr(null),
|
||||||
|
invoice_due_days: this.attr(null),
|
||||||
|
updated_at: this.attr(''),
|
||||||
|
created_at: this.attr(''),
|
||||||
|
logo_url: this.attr(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/store/store.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
import Vuex from 'vuex';
|
||||||
|
import VuexORM from '@vuex-orm/core';
|
||||||
|
import VuexORMisDirtyPlugin from '@vuex-orm/plugin-change-flags';
|
||||||
|
import BankAccount from '@/store/models/bank-account';
|
||||||
|
import Client from '@/store/models/client';
|
||||||
|
import Invoice from '@/store/models/invoice';
|
||||||
|
import InvoiceRow from '@/store/models/invoice-row';
|
||||||
|
import Team from '@/store/models/team';
|
||||||
|
import bankAccounts from '@/store/bank-accounts';
|
||||||
|
import clients from '@/store/clients';
|
||||||
|
import invoices from '@/store/invoices';
|
||||||
|
import teams from '@/store/teams';
|
||||||
|
import themes from '@/store/themes';
|
||||||
|
|
||||||
|
Vue.use(Vuex);
|
||||||
|
|
||||||
|
VuexORM.use(VuexORMisDirtyPlugin);
|
||||||
|
const database = new VuexORM.Database();
|
||||||
|
|
||||||
|
database.register(Team);
|
||||||
|
database.register(Client);
|
||||||
|
database.register(Invoice);
|
||||||
|
database.register(InvoiceRow);
|
||||||
|
database.register(BankAccount);
|
||||||
|
|
||||||
|
export default new Vuex.Store({
|
||||||
|
plugins: [VuexORM.install(database)],
|
||||||
|
modules: {
|
||||||
|
bankAccounts,
|
||||||
|
clients,
|
||||||
|
invoices,
|
||||||
|
teams,
|
||||||
|
themes,
|
||||||
|
},
|
||||||
|
state: {},
|
||||||
|
mutations: {},
|
||||||
|
actions: {},
|
||||||
|
});
|
||||||
49
src/store/teams.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import TeamService from '@/services/team.service';
|
||||||
|
import Team from '@/store/models/team';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {},
|
||||||
|
mutations: {},
|
||||||
|
actions: {
|
||||||
|
async init({ dispatch }) {
|
||||||
|
dispatch('clients/terminate', null, { root: true });
|
||||||
|
dispatch('bankAccounts/terminate', null, { root: true });
|
||||||
|
dispatch('invoices/terminate', null, { root: true });
|
||||||
|
|
||||||
|
await dispatch('getTeam');
|
||||||
|
|
||||||
|
dispatch('clients/init', null, { root: true });
|
||||||
|
dispatch('bankAccounts/init', null, { root: true });
|
||||||
|
dispatch('invoices/init', null, { root: true });
|
||||||
|
},
|
||||||
|
async getTeam() {
|
||||||
|
const team = await TeamService.getTeam();
|
||||||
|
await Team.create({ data: team });
|
||||||
|
return team;
|
||||||
|
},
|
||||||
|
async teamProps({ state }, props) {
|
||||||
|
return Team.update({
|
||||||
|
where: state.teamId,
|
||||||
|
data: props,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async updateTeam({ getters, dispatch }, props) {
|
||||||
|
if (props) {
|
||||||
|
await dispatch('teamProps', props);
|
||||||
|
}
|
||||||
|
return TeamService.updateTeam(getters.team);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
team() {
|
||||||
|
return Team.query().first();
|
||||||
|
},
|
||||||
|
all() {
|
||||||
|
return Team.query()
|
||||||
|
.with(['logos'])
|
||||||
|
.where('$isNew', false)
|
||||||
|
.get();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
11
src/store/themes.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
theme(state, theme) {
|
||||||
|
state.theme = theme;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
24
src/utils/errors.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export default class Errors {
|
||||||
|
constructor(errors) {
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(errors) {
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(field) {
|
||||||
|
if (this.has(field)) {
|
||||||
|
return this.errors[field];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(field) {
|
||||||
|
return this.errors && this.errors.hasOwnProperty(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.errors = {};
|
||||||
|
}
|
||||||
|
}
|
||||||