Init commit

This commit is contained in:
Marek Fraczyk
2021-02-16 16:24:22 +02:00
parent 056a817632
commit 79e9705b01
106 changed files with 18400 additions and 0 deletions

2
.browserslistrc Normal file
View File

@ -0,0 +1,2 @@
> 1%
last 2 versions

7
.editorconfig Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/app',
],
};

13361
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View 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
View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

9
public/.htaccess.example Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

9
public/browserconfig.xml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

26
public/index.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View 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
View 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
View 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>

View File

@ -0,0 +1,7 @@
.alert {
&-secondary {
color: var(--text-primary);
background-color: var(--shade);
border-color: transparent;
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View File

@ -0,0 +1,6 @@
.invalid-feedback {
display: block;
font-size: 12px;
font-weight: 400;
color: var(--error-darken);
}

View 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;
}
}

View 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;
}
}
}

View File

@ -0,0 +1,5 @@
.nav-pills {
.nav-link {
color: var(--text-secondary);
}
}

View 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;
}
}

View 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;
}

View 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);
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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";

View 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;
}
}
}

View 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);
}
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}
}
}

View 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);
}

View 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);
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -0,0 +1,7 @@
import storage from 'localforage';
storage.config({
name: 'serverlessInvoices',
version: 1.0,
storeName: 'default',
});

View 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('.');
}
}

View 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
View 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
View 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;

View 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();

View 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();

View 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();

View 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();

View 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();

View 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
View 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
View 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;
},
},
};

View 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(''),
};
}
}

View 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(''),
};
}
}

View 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(''),
};
}
}

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

Some files were not shown because too many files have changed in this diff Show More