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 = {};
|
||||
}
|
||||
}
|
||||