master: change repo
This commit is contained in:
158
src/App.vue
Normal file
158
src/App.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div id="app" class="app">
|
||||
<nav
|
||||
class="nav navbar is-fixed-top is-primary"
|
||||
role="navigation"
|
||||
aria-label="main navigation"
|
||||
:class="{ shadow }"
|
||||
v-click-outside="hideMenu"
|
||||
>
|
||||
<div class="navbar-brand">
|
||||
<router-link class="navbar-item" to="/">
|
||||
<img src="./assets/vaquant-root-white.png" title="vaquant" />
|
||||
<online-view class="online-view">
|
||||
<div slot="offline">
|
||||
<img
|
||||
class="icon"
|
||||
src="./assets/icons/baseline_cloud_off_white_48dp.png"
|
||||
alt="offline"
|
||||
/>
|
||||
</div>
|
||||
</online-view>
|
||||
</router-link>
|
||||
<span class="vaquant-title" :class="{ 'visible': shadow }">{{ title }}</span>
|
||||
<div
|
||||
class="navbar-burger burger"
|
||||
data-target="navbar-main"
|
||||
@click="toggleMenu"
|
||||
:class="{ 'is-active': menuOpen }"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="navbar-main"
|
||||
class="navbar-menu"
|
||||
:class="{ 'is-active': menuOpen }"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<div class="navbar-end">
|
||||
<router-link class="navbar-item" :to="{ name: 'about' }" v-t="'title.about'"></router-link>
|
||||
<router-link class="navbar-item" :to="{ name: 'pricing' }" v-t="'title.pricing'"></router-link>
|
||||
<a
|
||||
class="navbar-item"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:href="coffee"
|
||||
>Supportez Vaquant avec un ☕ !</a>
|
||||
<router-link
|
||||
class="navbar-item"
|
||||
:to="{ name: 'user' }"
|
||||
>{{ username ? username : $t('user.loginsignup') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="main">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
import { throttle } from 'lodash-es'
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import IUser from '@/models/IUser'
|
||||
import { COFFEE_LINK } from '@/utils/constants'
|
||||
import OnlineView from '@/components/OnlineView.vue'
|
||||
|
||||
@Component({
|
||||
directives: {
|
||||
ClickOutside
|
||||
},
|
||||
components: {
|
||||
'online-view': OnlineView
|
||||
}
|
||||
})
|
||||
export default class App extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Getter
|
||||
public username!: string
|
||||
@Getter
|
||||
public title!: string | null
|
||||
public menuOpen: boolean = false
|
||||
public transitionName: string = ''
|
||||
public shadow: boolean = false
|
||||
public coffee: string = COFFEE_LINK
|
||||
|
||||
public mounted(): void {
|
||||
const main = document.getElementById('main')
|
||||
if (main) {
|
||||
this.shadow = main.getBoundingClientRect().top < 0
|
||||
document.addEventListener(
|
||||
'scroll',
|
||||
throttle((evt) => {
|
||||
this.shadow = main.getBoundingClientRect().top < 0
|
||||
}, 150)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public toggleMenu(): void {
|
||||
this.menuOpen = !this.menuOpen
|
||||
}
|
||||
|
||||
public hideMenu(): void {
|
||||
this.menuOpen = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.navbar {
|
||||
color: red;
|
||||
.vaquant-title {
|
||||
opacity: 0;
|
||||
line-height: 52px;
|
||||
transition: opacity 0.25s ease-out;
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.online-view {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
right: 18px;
|
||||
width: 18px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
.navbar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
transition: opacity 0.25s ease-out;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
box-shadow: inset 0 5px 6px -3px rgba(0, 0, 0, 0.842);
|
||||
will-change: opacity;
|
||||
}
|
||||
.navbar.shadow::before {
|
||||
opacity: 1;
|
||||
}
|
||||
main {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
4
src/assets/icons/baseline-cloud_off-24px.svg
Normal file
4
src/assets/icons/baseline-cloud_off-24px.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4c-1.48 0-2.85.43-4.01 1.17l1.46 1.46C10.21 6.23 11.08 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3 0 1.13-.64 2.11-1.56 2.62l1.45 1.45C23.16 18.16 24 16.68 24 15c0-2.64-2.05-4.78-4.65-4.96zM3 5.27l2.75 2.74C2.56 8.15 0 10.77 0 14c0 3.31 2.69 6 6 6h11.73l2 2L21 20.73 4.27 4 3 5.27zM7.73 10l8 8H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h1.73z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 527 B |
BIN
src/assets/icons/baseline_cloud_off_white_48dp.png
Normal file
BIN
src/assets/icons/baseline_cloud_off_white_48dp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.0 KiB |
BIN
src/assets/minilogo.png
Normal file
BIN
src/assets/minilogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src/assets/vaquant-root-white.png
Normal file
BIN
src/assets/vaquant-root-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
37
src/base-components/BaseAccount.ts
Normal file
37
src/base-components/BaseAccount.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import IUser from '@/models/IUser'
|
||||
|
||||
@Component
|
||||
export default class BaseAccount extends Vue {
|
||||
@Getter public user!: IUser | null
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public account: IAccount | null = null
|
||||
public removing: boolean = false
|
||||
|
||||
public async mounted(): Promise<void> {
|
||||
bus.$on(SYNC, this.getData)
|
||||
await this.getData()
|
||||
const anchor = this.$router.currentRoute.hash
|
||||
if (anchor && document.querySelector(anchor)) {
|
||||
this.$nextTick(() => {
|
||||
location.href = anchor
|
||||
// add scroll to manage fixed header
|
||||
window.scrollBy(0, -60)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
bus.$off(SYNC, this.getData)
|
||||
}
|
||||
|
||||
public async getData(docIds?: string[]): Promise<void> {
|
||||
throw new Error(
|
||||
`abstract method, need to be override ${docIds && docIds.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
49
src/components/AccountEncrypted.vue
Normal file
49
src/components/AccountEncrypted.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class='account-encrypted'>
|
||||
Le compte est pour l'instant chiffré, la clé sera récupérée dès qu'une
|
||||
connexion internet est établie.
|
||||
<button class="button is-link is-rounded is-loading" v-if="fetching"></button>
|
||||
<online-view @online="fetchKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import axios from 'axios'
|
||||
import { GET_KEY_URL } from '@/utils/serverless-url'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import IUser from '@/models/IUser'
|
||||
import couchService from '@/services/CouchService'
|
||||
import notif from '@/utils/notif'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'online-view': () => import('@/components/OnlineView.vue')
|
||||
}
|
||||
})
|
||||
export default class AccountEncrypted extends Vue {
|
||||
@Getter
|
||||
public user!: IUser
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public fetching: boolean = false
|
||||
|
||||
public async fetchKey(): Promise<void> {
|
||||
if (!this.user) {
|
||||
return
|
||||
}
|
||||
this.fetching = true
|
||||
const retrieved: boolean = await couchService.retrievePassword(this.id || '', this.user)
|
||||
if (retrieved) {
|
||||
bus.$emit(SYNC, [this.id])
|
||||
} else {
|
||||
notif.alert(`
|
||||
Impossible de récupérer la clé,
|
||||
veuillez réessayer plus tard...
|
||||
`)
|
||||
}
|
||||
this.fetching = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
109
src/components/AccountList.vue
Normal file
109
src/components/AccountList.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="account-list">
|
||||
<div class="columns is-centered is-multiline" v-if="accounts.length">
|
||||
<div class="column is-2" v-for="(account, k) in accounts" :key="k">
|
||||
<div class="box" :style="getColor(account.color)" @click="goToAccount(account._id)">
|
||||
<router-link
|
||||
:class="{ 'is-primary': !account.color, 'is-white': account.color }"
|
||||
:style="`color: ${findDarkValue(account.color)}`"
|
||||
:to="{ name: 'account', params: { id: account._id } }"
|
||||
>{{ account.name }}</router-link>
|
||||
<div v-if="account.isPublic">
|
||||
<awe-icon icon="users" />
|
||||
</div>
|
||||
<hr v-else />
|
||||
{{ account.users && account.users.map(u => u.alias).join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="fetched">
|
||||
<awe-icon icon="inbox" />Les comptes s'afficheront ici.
|
||||
</div>
|
||||
<fab-button :to="{ name: 'account-new' }" v-if="user" :margin="true">
|
||||
<span slot="fulltext">créer un compte</span>
|
||||
</fab-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import { findDarkValue } from '@/utils'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import accountService from '@/services/AccountService'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import IColor from '@/models/IColor'
|
||||
import IUser from '@/models/IUser'
|
||||
import FabButton from '@/components/FabButton.vue'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'fab-button': FabButton
|
||||
}
|
||||
})
|
||||
export default class AccountList extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Prop({ type: Boolean, default: false })
|
||||
public archived!: boolean
|
||||
|
||||
public accounts: IAccount[] = []
|
||||
public fetched: boolean = false
|
||||
public textColor: string = ''
|
||||
public async created(): Promise<void> {
|
||||
this.getData()
|
||||
bus.$on(SYNC, this.getData)
|
||||
}
|
||||
|
||||
public async getData(): Promise<void> {
|
||||
this.accounts = await accountService.getAll(this.archived)
|
||||
this.fetched = true
|
||||
}
|
||||
|
||||
public getColor(color: string | null): any | null {
|
||||
if (!color) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
backgroundColor: color,
|
||||
color: this.findDarkValue(color)
|
||||
}
|
||||
}
|
||||
|
||||
public findDarkValue(color: string): string | null {
|
||||
return findDarkValue(color)
|
||||
}
|
||||
|
||||
public goToAccount(id: string): void {
|
||||
this.$router.push({ name: 'account', params: { id } })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../styles/variables';
|
||||
|
||||
.box {
|
||||
border-radius: 8px;
|
||||
word-wrap: break-word;
|
||||
hyphens: auto;
|
||||
|
||||
a {
|
||||
font-size: 1.2rem;
|
||||
color: $main;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 10px;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
src/components/AccountShare.vue
Normal file
119
src/components/AccountShare.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<article class="account-share message is-success is-medium" v-if="show && user">
|
||||
<div class="message-header">
|
||||
<p>Partagez le compte</p>
|
||||
<button class="delete" v-if="canClose" aria-label="close" @click="hide"></button>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<button class="button is-primary is-medium" @click="share" v-if="canShareAPI">Partager</button>
|
||||
<div class="field is-grouped is-grouped-centered" v-else>
|
||||
<div class="field" :class="{ 'has-addons': canClipboard }">
|
||||
<div class="control">
|
||||
<input class="input" type="text" readonly :value="accountUrl" />
|
||||
</div>
|
||||
<div class="control" if="canClipboard">
|
||||
<a href="#" class="button is-primary" @click.prevent="copy">Copier</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="qr-code">
|
||||
<p>ou via ce QR code :</p>
|
||||
<qr-code :value="accountUrl" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
declare const navigator: any
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import IUser from '@/models/IUser'
|
||||
import notif from '@/utils/notif'
|
||||
import QrCode from '@xkeshi/vue-qrcode'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'qr-code': QrCode
|
||||
}
|
||||
})
|
||||
export default class Home extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Prop({ type: Object, required: true })
|
||||
public account!: IAccount
|
||||
@Prop({ type: Boolean, default: true })
|
||||
public canClose!: boolean
|
||||
public show: boolean = true
|
||||
|
||||
public hide(): void {
|
||||
if (this.canClose) {
|
||||
this.show = false
|
||||
}
|
||||
}
|
||||
|
||||
public share(): void {
|
||||
if (this.canShareAPI) {
|
||||
navigator.share({
|
||||
title: `Vaquant - Compte ${this.account.name}`,
|
||||
text: `${this.username} vous invite sur Vaquant pour gérer vos dépenses sur le compte ${this.account.name}.`,
|
||||
url: this.accountUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public async copy(): Promise<void> {
|
||||
if (this.canClipboard) {
|
||||
await navigator.clipboard.writeText(this.accountUrl)
|
||||
notif.confirm('Adresse copiée')
|
||||
}
|
||||
}
|
||||
|
||||
public get accountUrl(): string {
|
||||
let url = window.location.href.replace('/setting', '')
|
||||
if (this.account.isPublic) {
|
||||
url = `${url}/public`
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
public get username(): string {
|
||||
if (!this.user) {
|
||||
return ''
|
||||
}
|
||||
return (
|
||||
`${this.user.firstname} ${this.user.lastname}`.trim() || this.user.email
|
||||
)
|
||||
}
|
||||
|
||||
public get canShareAPI(): boolean {
|
||||
return !!navigator.share
|
||||
}
|
||||
|
||||
public get canClipboard(): boolean {
|
||||
return !!navigator.clipboard
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
$padding-fab: 24px;
|
||||
|
||||
.is-fab {
|
||||
bottom: $padding-fab;
|
||||
position: fixed;
|
||||
right: $padding-fab;
|
||||
}
|
||||
|
||||
.fab-margin {
|
||||
margin: calc(#{$padding-fab} * 2) 0;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
canvas {
|
||||
margin: 1em 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
138
src/components/AccountTransactionList.vue
Normal file
138
src/components/AccountTransactionList.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="account-transaction-list" v-if="account">
|
||||
<div class="field has-addons has-addons-centered is-narrow" v-if="showFilter">
|
||||
<div class="control" v-show="filteredBy">
|
||||
<button type="submit" class="button" @click="filteredBy = ''">
|
||||
<awe-icon icon="times" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="filteredBy">
|
||||
<option value>Tout le monde</option>
|
||||
<option v-for="(user, k) in account.users" :key="k" :value="user.alias">{{ user.alias }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table is-hoverable is-striped">
|
||||
<thead v-if="head">
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<awe-icon icon="minus" />
|
||||
</th>
|
||||
<th scope="col" class="align">
|
||||
<awe-icon icon="user" />
|
||||
</th>
|
||||
<th scope="col" class="align">
|
||||
<awe-icon icon="money-bill-wave" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(transaction, k) in filteredTransactions"
|
||||
:key="k"
|
||||
:data-index="k"
|
||||
:id="transaction._id"
|
||||
>
|
||||
<td class="multiline">
|
||||
<router-link
|
||||
:to="{ name: 'transaction', params: { id: transaction._id } }"
|
||||
>{{ transaction.name }}</router-link>
|
||||
<br />
|
||||
<span class="date">{{ transaction.date | date }}</span>
|
||||
</td>
|
||||
<td class="pay-by">{{ transaction.payBy }}</td>
|
||||
<td class="numeric">{{ transaction.amount | moneypad(transaction.mainCurrency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot v-if="foot">
|
||||
<tr class="is-selected">
|
||||
<td colspan="2" class="total">total</td>
|
||||
<td class="numeric">{{ total | moneypad(account.mainCurrency) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
|
||||
@Component
|
||||
export default class AccountTransactionList extends Vue {
|
||||
@Prop({ type: Object, default: () => null })
|
||||
public account!: IAccount | null
|
||||
@Prop({ type: Array, required: true })
|
||||
public transactions!: ITransaction[]
|
||||
@Prop({ type: Boolean, default: true })
|
||||
public filter!: boolean
|
||||
@Prop({ type: Boolean, default: true })
|
||||
public head!: boolean
|
||||
@Prop({ type: Boolean, default: false })
|
||||
public foot!: boolean
|
||||
public filteredBy: string = ''
|
||||
|
||||
public get total(): number {
|
||||
return this.transactions.reduce(
|
||||
(a: number, transaction: ITransaction) => a + transaction.amount,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
public get filteredTransactions(): ITransaction[] {
|
||||
if (!this.filteredBy) {
|
||||
return this.transactions
|
||||
}
|
||||
return this.transactions.filter(
|
||||
(transaction: ITransaction) => transaction.payBy === this.filteredBy
|
||||
)
|
||||
}
|
||||
|
||||
public get isMultiUser(): boolean {
|
||||
const payBy = new Set(
|
||||
this.transactions.map((transaction: ITransaction) => transaction.payBy)
|
||||
)
|
||||
return payBy.size > 1
|
||||
}
|
||||
|
||||
public get showFilter(): boolean {
|
||||
return this.filter && this.isMultiUser
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.account-transaction-list {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table tr.is-selected {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-font-color);
|
||||
}
|
||||
|
||||
.multiline {
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
&.align {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
src/components/AccountUserNew.vue
Normal file
81
src/components/AccountUserNew.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="account-user-new">
|
||||
<h3 class="subtitle is-3">{{ $tc('account.friends', localeUsers.length, { count: localeUsers.length }) }}</h3>
|
||||
<user-new v-if="user" v-model="user" />
|
||||
<div v-for="(user, k) in localeUsers" :key="k">
|
||||
<hr />
|
||||
<user-new v-model="localeUsers[k]" @remove="() => remove(k)" />
|
||||
</div>
|
||||
<br />
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="nouvel ami"
|
||||
v-model="newUserId"
|
||||
@keyup.enter.prevent="newUser"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-primary" @click.prevent="newUser">ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Model, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IUser from '@/models/IUser'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'user-new': () => import('@/components/UserNew.vue')
|
||||
}
|
||||
})
|
||||
export default class HelloWorld extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Model('input', { type: Array, required: true })
|
||||
public users!: IUser[]
|
||||
public localeUsers: IUser[] = []
|
||||
public userId: string = ''
|
||||
public newUserId: string = ''
|
||||
|
||||
public mounted(): void {
|
||||
this.localeUsers = this.users
|
||||
}
|
||||
|
||||
public newUser(): void {
|
||||
if (!this.newUserId) {
|
||||
return
|
||||
}
|
||||
this.localeUsers.push({
|
||||
userId: this.newUserId,
|
||||
email: '',
|
||||
premium: this.user ? this.user.premium : false,
|
||||
slugEmail: '',
|
||||
alias: this.newUserId,
|
||||
firstname: '',
|
||||
lastname: ''
|
||||
})
|
||||
this.newUserId = ''
|
||||
}
|
||||
|
||||
public remove(index: number): void {
|
||||
this.localeUsers = this.localeUsers.filter(
|
||||
(u: IUser, i: number) => i !== index
|
||||
)
|
||||
}
|
||||
|
||||
public removeUser(): void {
|
||||
this.localeUsers.pop()
|
||||
}
|
||||
|
||||
@Watch('localeUsers')
|
||||
public onUserChange(users: IUser[]): void {
|
||||
this.$emit('input', users)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
121
src/components/AppResume.vue
Normal file
121
src/components/AppResume.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="app-resume" v-once>
|
||||
<div class="columns is-multiline text-description is-centered">
|
||||
<div class="column is-one-third">
|
||||
<div class="main-icon">
|
||||
<img class="icon" src="../assets/icons/baseline-cloud_off-24px.svg" alt="offline" />
|
||||
</div>
|
||||
<p>
|
||||
Vos données sont hors-lignes.
|
||||
Utilisez Vaquant où vous voulez, seul ou en groupe.
|
||||
Les données seront sauvegardées sur internet dès qu'une connexion est disponible
|
||||
afin de les synchroniser avec vos amis.
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<div class="main-icon">
|
||||
<awe-icon icon="users"></awe-icon>
|
||||
</div>
|
||||
<p>Choisissez de rendre le compte privé ou public.</p>
|
||||
<p>Lorsque le compte est privé, seules les personnes du compte accéderont aux dépenses.</p>
|
||||
<p>Lorsque le compte est public, toutes les personnes accédant au lien pourront voir, modifier ou supprimer les dépenses.</p>
|
||||
</div>
|
||||
<!-- <div class="column is-one-third">
|
||||
<div class="main-icon">
|
||||
<awe-icon icon="user-lock"/>
|
||||
</div>
|
||||
<p>
|
||||
Les informations sensibles de vos comptes et transactions sont chiffrées de bout en bout.
|
||||
Seuls les membres d'un compte peuvent visualiser ses données.
|
||||
</p>
|
||||
</div>-->
|
||||
<div class="column is-one-third">
|
||||
<div class="main-icon">
|
||||
<awe-icon icon="euro-sign" />
|
||||
<awe-icon icon="exchange-alt" />
|
||||
<awe-icon icon="dollar-sign" />
|
||||
</div>
|
||||
<p>
|
||||
Vaquant vous permet d'enregistrer les dépenses dans la monnaie locale de vos vacances
|
||||
et se charge de faire la conversion pour vous au jour le jour. 😙
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<div class="main-icon">
|
||||
<awe-icon icon="sync-alt" />
|
||||
</div>
|
||||
<p>
|
||||
Vaquant se met à jour en direct. Vous recevez automatiquement les
|
||||
nouveaux comptes et dépenses sans même vous en rendre compte. 🎉
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<div class="main-icon">
|
||||
<awe-icon icon="mobile" />
|
||||
</div>
|
||||
<p>
|
||||
Vaquant est simple, sécurisée, rapide et accessible depuis un simple lien internet !
|
||||
Votre partenaire idéal pour vos voyages ! ⛺
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<div class="main-icon">
|
||||
<awe-icon icon="hand-holding-usd" />
|
||||
</div>
|
||||
<p>
|
||||
Vaquant propose une offre gratuite sans publicité.
|
||||
Si vous souhaitez encourager son développement, vous pouvez toujours
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:href="coffee"
|
||||
>offrir un ☕</a> !
|
||||
</p>
|
||||
<p>
|
||||
Ou vous pouvez également me supporter sur
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:href="brave"
|
||||
>Brave</a> !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { COFFEE_LINK, BRAVE_LINK } from '@/utils/constants'
|
||||
|
||||
export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
coffee: COFFEE_LINK,
|
||||
brave: BRAVE_LINK
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-icon {
|
||||
font-size: 34pt;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-description {
|
||||
font-size: 14pt;
|
||||
text-align: justify;
|
||||
text-align-last: center;
|
||||
|
||||
.column {
|
||||
padding: 30px;
|
||||
.emoji {
|
||||
font-size: 20pt;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
src/components/AxisLabel.vue
Normal file
29
src/components/AxisLabel.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<text :x="point.x" :y="point.y">{{ stat.label }}</text>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator'
|
||||
import chartService from '@/services/ChartService'
|
||||
import IStat from '@/models/IStat'
|
||||
import IPoint from '@/models/IPoint'
|
||||
|
||||
@Component
|
||||
export default class AxisLabel extends Vue {
|
||||
@Prop({ type: Object, required: true })
|
||||
public stat!: IStat
|
||||
@Prop({ type: Number, required: true })
|
||||
public index!: number
|
||||
@Prop({ type: Number, required: true })
|
||||
public total!: number
|
||||
|
||||
public get point(): IPoint {
|
||||
return chartService.valueToPoint(
|
||||
this.stat.percent + 10,
|
||||
this.index,
|
||||
this.total
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
74
src/components/ChartBalance.vue
Normal file
74
src/components/ChartBalance.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="chart-balance">
|
||||
<table class="table is-hoverable is-striped" v-if="stats.length">
|
||||
<tbody>
|
||||
<tr v-for="(stat, k) in statOrdered" :key="k">
|
||||
<th scope="row">{{ stat.label }}</th>
|
||||
<td class="numeric">{{ stat.value | money(currency) }}</td>
|
||||
<td
|
||||
v-if="stat.balance.amount !== 0"
|
||||
class="numeric"
|
||||
:class="getColor(stat.balance.amount)"
|
||||
>{{ stat.balance.amount | money(currency) }}</td>
|
||||
<td v-else class="conclude" :class="getColor(stat.balance.amount)"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else>Aucune dépense faite</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import IStat from '@/models/IStat'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'axis-label': () => import('@/components/AxisLabel.vue')
|
||||
}
|
||||
})
|
||||
export default class ChartBalance extends Vue {
|
||||
@Prop({ type: Array, default: () => [] })
|
||||
public stats!: IStat[]
|
||||
@Prop({ type: Object, required: true })
|
||||
public currency!: ICurrency
|
||||
|
||||
public getColor(amount: number): string {
|
||||
return amount === 0 ? 'zero' : amount > 0 ? 'positive' : 'negative'
|
||||
}
|
||||
|
||||
public get statOrdered(): IStat[] {
|
||||
return [...this.stats].sort((a: IStat, b: IStat) =>
|
||||
a.balance.amount < b.balance.amount ? 1 : -1
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/variables';
|
||||
|
||||
.chart-balance {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table {
|
||||
margin: auto;
|
||||
.numeric {
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.zero {
|
||||
color: $primary;
|
||||
&.conclude::after {
|
||||
content: '👌🏾';
|
||||
}
|
||||
}
|
||||
.positive {
|
||||
color: $green;
|
||||
}
|
||||
.negative {
|
||||
color: $red;
|
||||
}
|
||||
</style>
|
||||
85
src/components/ChartPie.vue
Normal file
85
src/components/ChartPie.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<svg class="chart-pie" ref="chart" id="chart-pie" viewBox="-1 -1 2 2"></svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// https://codepen.io/soluhmin/pen/jexywg
|
||||
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
|
||||
import ISlice from '@/models/ISlice'
|
||||
|
||||
@Component
|
||||
export default class ChartPie extends Vue {
|
||||
@Prop({ type: Array, default: () => [] })
|
||||
public slices!: ISlice[]
|
||||
public svgEl: Element | null = null
|
||||
|
||||
public mounted(): void {
|
||||
this.svgEl = this.$refs.chart as Element
|
||||
this.constructChart()
|
||||
}
|
||||
|
||||
public getCoordinatesForPercent(percent: number): number[] {
|
||||
const x = Math.cos(2 * Math.PI * percent)
|
||||
const y = Math.sin(2 * Math.PI * percent)
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
public clearSvg(): void {
|
||||
if (!this.svgEl) {
|
||||
return
|
||||
}
|
||||
const svgEl = this.svgEl
|
||||
while (svgEl.lastChild) {
|
||||
svgEl.removeChild(svgEl.lastChild)
|
||||
}
|
||||
}
|
||||
|
||||
public constructChart(): void {
|
||||
if (!this.svgEl) {
|
||||
return
|
||||
}
|
||||
const svgEl = this.svgEl
|
||||
this.clearSvg()
|
||||
let cumulativePercent = 0
|
||||
|
||||
this.slices.forEach((slice: ISlice) => {
|
||||
const [startX, startY] = this.getCoordinatesForPercent(cumulativePercent)
|
||||
cumulativePercent += slice.percent
|
||||
const [endX, endY] = this.getCoordinatesForPercent(cumulativePercent)
|
||||
|
||||
/*
|
||||
* if the slice is more than 50%,
|
||||
* take the large arc (the long way around)
|
||||
*/
|
||||
const largeArcFlag = slice.percent > 0.5 ? 1 : 0
|
||||
|
||||
const pathData = [
|
||||
`M ${startX} ${startY}`, // Move
|
||||
`A 1 1 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc
|
||||
`L 0 0` // Line
|
||||
].join(' ')
|
||||
|
||||
const pathEl = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
)
|
||||
pathEl.setAttribute('d', pathData)
|
||||
pathEl.setAttribute('fill', slice.color)
|
||||
svgEl.appendChild(pathEl)
|
||||
})
|
||||
}
|
||||
|
||||
@Watch('slices')
|
||||
public onSliceChange(): void {
|
||||
this.constructChart()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-pie {
|
||||
transform: rotate(-90deg);
|
||||
/* height: 200px; */
|
||||
max-width: 400pt;
|
||||
}
|
||||
</style>
|
||||
98
src/components/ColorPicker.vue
Normal file
98
src/components/ColorPicker.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="color-picker">
|
||||
<h3 class="subtitle is-3">Couleur</h3>
|
||||
<div class="columns is-mobile is-centered is-multiline">
|
||||
<div class="column" v-for="(color, k) in colors" :key="k">
|
||||
<div
|
||||
@click.prevent="select(color)"
|
||||
class="color-container"
|
||||
:class="{ white: color.label }"
|
||||
:style="colorBackground(color)"
|
||||
>
|
||||
<awe-icon v-if="color.value === localColor" icon="check" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Model, Watch } from 'vue-property-decorator'
|
||||
import IColor from '@/models/IColor'
|
||||
import colors from '@/data/colors'
|
||||
|
||||
@Component
|
||||
export default class ColorPicker extends Vue {
|
||||
@Model('input', { type: String, required: false })
|
||||
public color!: string | null
|
||||
public localColor: string | null = null
|
||||
public colorLabel: string | null = null
|
||||
public colors: IColor[] = colors
|
||||
|
||||
public mounted(): void {
|
||||
this.localColor = this.color
|
||||
if (this.color) {
|
||||
const color: IColor | undefined = this.colors.find(
|
||||
(c: IColor) => c.value === this.color
|
||||
)
|
||||
if (color) {
|
||||
this.colorLabel = color.label
|
||||
}
|
||||
} else {
|
||||
this.localColor = this.colors[0].value
|
||||
}
|
||||
}
|
||||
|
||||
public select(color: IColor): void {
|
||||
this.localColor = color.value
|
||||
this.colorLabel = color.label
|
||||
}
|
||||
|
||||
public colorBackground(color: IColor): any {
|
||||
return {
|
||||
border: !color.label ? '1px solid #95afc0' : '',
|
||||
backgroundColor: color.value
|
||||
}
|
||||
}
|
||||
|
||||
public get background(): any | null {
|
||||
if (!this.localColor) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
'background-color': this.localColor
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('localColor')
|
||||
public onColorChange(color: string | null, oldColor: string | null) {
|
||||
this.$emit('input', color)
|
||||
if (oldColor !== color) {
|
||||
this.$emit('color-change')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.color-container {
|
||||
border-radius: 6px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
font-size: 25px;
|
||||
margin: auto;
|
||||
&.white {
|
||||
color: white;
|
||||
}
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.input {
|
||||
width: 100px;
|
||||
}
|
||||
.button.is-static {
|
||||
width: 50px;
|
||||
}
|
||||
</style>
|
||||
51
src/components/ConfirmButton.vue
Normal file
51
src/components/ConfirmButton.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<a
|
||||
href="#"
|
||||
class="button"
|
||||
:class="{ 'is-loading': loading }"
|
||||
@click.prevent="click"
|
||||
v-click-outside="resetTap"
|
||||
>
|
||||
<span v-show="confirmMessage">{{ confirmMessage }}</span>
|
||||
<span v-show="!confirmMessage">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator'
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
|
||||
@Component({
|
||||
directives: {
|
||||
ClickOutside
|
||||
}
|
||||
})
|
||||
export default class ConfirmButton extends Vue {
|
||||
public loading: boolean = false
|
||||
public firstTap: boolean = true
|
||||
public resetTap(): void {
|
||||
this.firstTap = true
|
||||
}
|
||||
public click(): void {
|
||||
if (this.loading) {
|
||||
return
|
||||
}
|
||||
if (this.firstTap) {
|
||||
this.firstTap = false
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
this.firstTap = true
|
||||
this.$emit('confirm', () => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
public get confirmMessage(): string {
|
||||
return this.firstTap ? '' : 'Confirmer pour valider'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
129
src/components/FabButton.vue
Normal file
129
src/components/FabButton.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="fab-button" :id="`fab-${uniqueId}`">
|
||||
<router-link
|
||||
v-if="to"
|
||||
class="button is-primary is-large is-fab"
|
||||
:class="getClass"
|
||||
:style="buttonStyle"
|
||||
:to="to"
|
||||
@click.native="valid">
|
||||
<span class="icon is-medium">
|
||||
<slot>
|
||||
<awe-icon icon="plus" />
|
||||
</slot>
|
||||
</span>
|
||||
<span class="fulltext" v-if="$slots.fulltext">
|
||||
<slot name="fulltext"></slot>
|
||||
</span>
|
||||
</router-link>
|
||||
<button
|
||||
v-else
|
||||
type="submit"
|
||||
class="button is-primary is-large is-fab"
|
||||
:class="getClass"
|
||||
:style="buttonStyle"
|
||||
@click="valid">
|
||||
<span class="icon is-medium">
|
||||
<slot>
|
||||
<awe-icon icon="plus" />
|
||||
</slot>
|
||||
</span>
|
||||
<span class="fulltext" v-if="$slots.fulltext">
|
||||
<slot name="fulltext"></slot>
|
||||
</span>
|
||||
</button>
|
||||
<div class="fab-margin" v-if="margin"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator'
|
||||
import { throttle } from 'lodash-es'
|
||||
|
||||
@Component
|
||||
export default class Home extends Vue {
|
||||
@Prop({ type: Boolean, default: false })
|
||||
public margin!: boolean
|
||||
@Prop({ type: Object, default: () => null })
|
||||
public to!: any | null
|
||||
@Prop({ type: Object, required: false, default: () => ({})})
|
||||
public buttonStyle!: any
|
||||
public uniqueId: string = Date.now().toString()
|
||||
public isLoading: boolean = false
|
||||
public showFull: boolean = true
|
||||
|
||||
public mounted(): void {
|
||||
if (!this.$slots.fulltext) {
|
||||
return
|
||||
}
|
||||
const main = document.getElementById('main')
|
||||
const fabElm = document.getElementById(`fab-${this.uniqueId}`)
|
||||
if (main && fabElm) {
|
||||
document.addEventListener('scroll', throttle((evt) => {
|
||||
this.showFull = main.getBoundingClientRect().top > 0
|
||||
if (this.showFull) {
|
||||
fabElm.classList.remove('small')
|
||||
} else {
|
||||
fabElm.classList.add('small')
|
||||
}
|
||||
}, 150))
|
||||
}
|
||||
}
|
||||
|
||||
public loading(): void {
|
||||
this.isLoading = true
|
||||
}
|
||||
public valid(): void {
|
||||
this.isLoading = true
|
||||
this.$emit('valid', () => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
public get getClass(): any {
|
||||
return { 'is-loading': this.isLoading }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../styles/variables';
|
||||
|
||||
$padding-fab: 24px;
|
||||
.fab-button {
|
||||
.button {
|
||||
padding-right: 0.75em;
|
||||
transition: padding-right 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
.fulltext {
|
||||
display: inline-block;
|
||||
max-width: 450px;
|
||||
opacity: 1;
|
||||
transition: max-width 0.3s cubic-bezier(0.55, 0, 0.1, 1),
|
||||
opacity 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
&.small {
|
||||
.button {
|
||||
padding-right: 0.2em;
|
||||
transition: padding-right 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
.fulltext {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transition: max-width 0.3s cubic-bezier(0.55, 0, 0.1, 1),
|
||||
opacity 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.is-fab {
|
||||
bottom: $padding-fab;
|
||||
position: fixed;
|
||||
right: $padding-fab;
|
||||
z-index: 2;
|
||||
border: 2px solid $white;
|
||||
}
|
||||
|
||||
.fab-margin {
|
||||
margin: calc(#{$padding-fab} * 2 + 30px) 0 calc(#{$padding-fab} * 2);
|
||||
}
|
||||
</style>
|
||||
12
src/components/HelloWorld.vue
Normal file
12
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="hello">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component
|
||||
export default class HelloWorld extends Vue {
|
||||
}
|
||||
</script>
|
||||
72
src/components/LangChanger.vue
Normal file
72
src/components/LangChanger.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="lang-changer field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Changer de langue</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field is-narrow">
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="lang-changer" v-model="lang">
|
||||
<option
|
||||
v-for="(locale, k) in locales"
|
||||
:key="k"
|
||||
:value="locale.code"
|
||||
>{{ locale.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
|
||||
interface ILocale {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class LangChanger extends Vue {
|
||||
@Getter
|
||||
public locale!: string
|
||||
@Action
|
||||
public setLocale!: any
|
||||
public lang: string = ''
|
||||
public locales: ILocale[] = [
|
||||
{
|
||||
code: 'en',
|
||||
label: 'English'
|
||||
},
|
||||
{
|
||||
code: 'fr',
|
||||
label: 'Français'
|
||||
}
|
||||
]
|
||||
|
||||
public mounted(): void {
|
||||
this.lang = this.locale
|
||||
}
|
||||
|
||||
@Watch('lang')
|
||||
public onlangChange(locale: string): void {
|
||||
if (locale !== this.locale) {
|
||||
this.setLocale(locale)
|
||||
}
|
||||
if (locale !== this.$i18n.locale) {
|
||||
this.$i18n.locale = locale
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('locale', { immediate: true })
|
||||
public onLocaleChange(locale: string): void {
|
||||
if (locale !== this.lang) {
|
||||
this.lang = locale
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
31
src/components/OnlineView.vue
Normal file
31
src/components/OnlineView.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="online-view">
|
||||
<slot v-if="online"></slot>
|
||||
<slot v-else name="offline"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component
|
||||
export default class OnlineView extends Vue {
|
||||
public online: boolean = navigator.onLine
|
||||
|
||||
public mounted(): void {
|
||||
window.addEventListener('online', this.onchange)
|
||||
window.addEventListener('offline', this.onchange)
|
||||
this.$emit(this.online ? 'online' : 'offline')
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
window.removeEventListener('online', this.onchange)
|
||||
window.removeEventListener('offline', this.onchange)
|
||||
}
|
||||
|
||||
public onchange(): void {
|
||||
this.online = navigator.onLine
|
||||
this.$emit(this.online ? 'online' : 'offline')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
60
src/components/PricingTable.vue
Normal file
60
src/components/PricingTable.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="pricing-table">
|
||||
<div class="pricing-plan" :class="{ 'is-active': plan === 'free' }">
|
||||
<div class="plan-header">Gratuit</div>
|
||||
Parfait pour démarrer rapidement
|
||||
<div class="plan-price"><span class="plan-price-amount"><span class="plan-price-currency">€</span>0</span>/mois</div>
|
||||
<div class="plan-items">
|
||||
<div class="plan-item">jusqu'à 20 comptes</div>
|
||||
<div class="plan-item">jusqu'à 20 amis/compte</div>
|
||||
<div class="plan-item">jusqu'à 200 dépenses/compte</div>
|
||||
<div class="plan-item">33 devises</div>
|
||||
</div>
|
||||
<div class="plan-footer">
|
||||
<button class="button is-fullwidth" @click="choose('free')">
|
||||
<span v-if="plan !== 'free'">Choix par défaut</span>
|
||||
<span v-else>Sélectionné</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-plan is-success" :class="{ 'is-active': plan === 'premium' }">
|
||||
<div class="plan-header">Premium</div>
|
||||
Vaquant sans aucune limite !
|
||||
<div class="plan-price"><span class="plan-price-amount"><span class="plan-price-currency">€</span>3</span>/mois</div>
|
||||
<div class="plan-items">
|
||||
<div class="plan-item"><awe-icon icon="infinity" /> comptes</div>
|
||||
<div class="plan-item"><awe-icon icon="infinity" /> amis/compte</div>
|
||||
<div class="plan-item"><awe-icon icon="infinity" /> dépenses/compte</div>
|
||||
<div class="plan-item">150+ devises</div>
|
||||
<div class="plan-item">pièces jointes disponibles</div>
|
||||
</div>
|
||||
<div class="plan-footer">
|
||||
<button class="button is-fullwidth" @click="choose('premium')" disabled>
|
||||
Bientôt disponible
|
||||
<!-- <span v-if="plan !== 'premium'">Passer en premium</span>
|
||||
<span v-else>Sélectionné</span> -->
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component
|
||||
export default class PrincingTable extends Vue {
|
||||
@Prop({ type: String, default: '' })
|
||||
public firstPlan!: string
|
||||
public plan: string = ''
|
||||
|
||||
public mounted(): void {
|
||||
this.plan = this.firstPlan
|
||||
}
|
||||
public choose(plan: string): void {
|
||||
this.plan = plan
|
||||
this.$emit(plan)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
156
src/components/RefundTransaction.vue
Normal file
156
src/components/RefundTransaction.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="refund-transaction card">
|
||||
<div class="card-content">
|
||||
<div class="content" v-if="refunded">Somme remboursée...</div>
|
||||
<div class="content" v-else>
|
||||
<span v-if="toMe">
|
||||
<span class="people">{{ refund.from.alias }}</span>
|
||||
vous doit
|
||||
<span class="numeric">{{ refund.amount | money(account.mainCurrency) }}</span>
|
||||
</span>
|
||||
<span v-else-if="fromMe">
|
||||
Vous devez
|
||||
<span class="numeric">{{ refund.amount | money(account.mainCurrency) }}</span>
|
||||
à
|
||||
<span class="people">{{ refund.to.alias }}</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="people">{{ refund.from.alias }}</span>
|
||||
doit
|
||||
<span class="numeric">{{ refund.amount | money(account.mainCurrency) }}</span>
|
||||
à
|
||||
<span class="people">{{ refund.to.alias }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-footer" v-if="canRefund && !refunded">
|
||||
<confirm-button class="is-text is-fullwidth" @confirm="refundTo">Rembourser</confirm-button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import IRefund from '@/models/IRefund'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import IUser from '@/models/IUser'
|
||||
import TransactionTag from '@/enums/TransactionTag'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import { confirmation } from '@/utils'
|
||||
|
||||
@Component({
|
||||
directives: {
|
||||
ClickOutside
|
||||
},
|
||||
components: {
|
||||
'confirm-button': () => import('@/components/ConfirmButton.vue')
|
||||
}
|
||||
})
|
||||
export default class RefundTransaction extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Prop({ type: Object, required: true })
|
||||
public refund!: IRefund
|
||||
@Prop({ type: Object, required: true })
|
||||
public account!: IAccount
|
||||
public firstTap: boolean = true
|
||||
public refunded: boolean = false
|
||||
public resetTap(): void {
|
||||
this.firstTap = true
|
||||
}
|
||||
|
||||
public async refundTo(done: any): Promise<void> {
|
||||
if (!this.account._id) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
const date: Date = new Date()
|
||||
const currencies: string[] = this.account.currencies.map(
|
||||
(c: ICurrency) => c.code
|
||||
)
|
||||
const transaction: ITransaction = {
|
||||
accountId: this.account._id,
|
||||
name: 'Remboursement',
|
||||
date,
|
||||
tag: TransactionTag.None,
|
||||
amount: this.refund.amount || 0,
|
||||
payBy: this.refund.from.alias || '',
|
||||
payFor: [
|
||||
{
|
||||
alias: this.refund.to.alias || '',
|
||||
weight: 1
|
||||
}
|
||||
],
|
||||
userIds: this.account.userIds,
|
||||
mainCurrency: this.account.mainCurrency,
|
||||
currencies: this.account.currencies,
|
||||
transactionType: TransactionType.refund,
|
||||
exchange: await exchangeService.get(
|
||||
this.account.mainCurrency.code,
|
||||
date,
|
||||
currencies
|
||||
)
|
||||
}
|
||||
const response: PouchDB.Core.Response = await transactionService.add(
|
||||
this.account._id,
|
||||
transaction
|
||||
)
|
||||
if (response.ok) {
|
||||
this.refunded = true
|
||||
confirmation('Remboursement effectué avec succès')
|
||||
}
|
||||
bus.$emit(SYNC)
|
||||
done()
|
||||
}
|
||||
|
||||
public get canRefund(): boolean {
|
||||
if (!this.user || !this.account.admin) {
|
||||
return true
|
||||
}
|
||||
const emails: string[] = [this.account.admin.email, this.refund.from.email]
|
||||
return emails.includes(this.user.email)
|
||||
}
|
||||
|
||||
public get fromMe(): boolean {
|
||||
if (!this.user || !this.refund.from) {
|
||||
return false
|
||||
}
|
||||
return this.refund.from.email === this.user.email
|
||||
}
|
||||
|
||||
public get toMe(): boolean {
|
||||
if (!this.user || !this.refund.to) {
|
||||
return false
|
||||
}
|
||||
return this.refund.to.email === this.user.email
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.people {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
&.to-me {
|
||||
font-style: italic;
|
||||
&:after {
|
||||
font-style: normal;
|
||||
content: ' (🤗)';
|
||||
}
|
||||
}
|
||||
&.from-me {
|
||||
font-style: italic;
|
||||
&:after {
|
||||
font-style: normal;
|
||||
content: ' (🤨)';
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
186
src/components/TagList.vue
Normal file
186
src/components/TagList.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="tag-list columns is-multiline is-centered">
|
||||
<div class="column is-half">
|
||||
<h3 class="subtitle is-3">Légende</h3>
|
||||
<table class="table">
|
||||
<tr v-for="(slice, k) in slices" :key="k" :style="legendStyle(slice)" class="legend-item">
|
||||
<td>
|
||||
<awe-icon :icon="categories[slice.key].tag.icon" />
|
||||
</td>
|
||||
<td class="legend-label">{{ slice.label }}</td>
|
||||
<td class="percent-label numeric">{{ Math.round(slice.percent * 100) }}%</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<h3 class="subtitle is-3">Graphique</h3>
|
||||
<div class="chart-container">
|
||||
<chart-pie :slices="slices" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<h3 class="subtitle is-3">Détails</h3>
|
||||
<div class="field has-addons has-addons-centered is-narrow">
|
||||
<div class="control" v-if="filterCategory">
|
||||
<button type="submit" class="button" @click="filterCategory = ''">
|
||||
<awe-icon icon="times" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="filterCategory">
|
||||
<option value>Toutes</option>
|
||||
<option
|
||||
v-for="(category, k) in categories"
|
||||
:key="k"
|
||||
:value="k"
|
||||
>{{ category.tag.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-multiline" :class="{ 'is-centered': !!filterCategory }">
|
||||
<div v-for="(category, k) in filteredCategories" :key="k" class="column is-half">
|
||||
<div class="columns category-list">
|
||||
<div class="column is-2 tag-element account-color">
|
||||
<awe-icon :icon="category.tag.icon" />
|
||||
</div>
|
||||
<div class="column transaction-list" v-if="category.transactions.length">
|
||||
<p>{{ $tc('account.total', category.transactions.length, { count: category.transactions.length }) }}</p>
|
||||
<account-transaction-list
|
||||
:filter="false"
|
||||
:head="false"
|
||||
:foot="true"
|
||||
:account="account"
|
||||
:transactions="category.transactions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import colors from '@/data/colors'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import ISlice from '@/models/ISlice'
|
||||
import TransactionTag, {
|
||||
ITagLabel,
|
||||
TransactionTagLabel
|
||||
} from '@/enums/TransactionTag'
|
||||
|
||||
interface ICategory {
|
||||
[key: string]: {
|
||||
tag: ITagLabel
|
||||
transactions: ITransaction[]
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'chart-pie': () => import('@/components/ChartPie.vue'),
|
||||
'account-transaction-list': () =>
|
||||
import('@/components/AccountTransactionList.vue')
|
||||
}
|
||||
})
|
||||
export default class TagList extends Vue {
|
||||
@Prop({ type: Object, required: true })
|
||||
public account!: IAccount
|
||||
@Prop({ type: Array, default: () => [] })
|
||||
public transactions!: ITransaction[]
|
||||
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
|
||||
public filterCategory: string = ''
|
||||
|
||||
public legendStyle(slice: ISlice): any {
|
||||
return {
|
||||
color: slice.color
|
||||
}
|
||||
}
|
||||
|
||||
public get categories(): ICategory {
|
||||
return this.transactions.reduce(
|
||||
(a: ICategory, transaction: ITransaction) => {
|
||||
if (!a[transaction.tag]) {
|
||||
a[transaction.tag] = {
|
||||
tag: this.transactionTagLabel[transaction.tag]
|
||||
? this.transactionTagLabel[transaction.tag]
|
||||
: this.transactionTagLabel[TransactionTag.None],
|
||||
transactions: []
|
||||
}
|
||||
}
|
||||
a[transaction.tag].transactions.push(transaction)
|
||||
return a
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
public get filteredCategories(): ICategory {
|
||||
if (this.filterCategory && this.filterCategory in this.categories) {
|
||||
return {
|
||||
[this.filterCategory]: this.categories[this.filterCategory]
|
||||
}
|
||||
}
|
||||
return this.categories
|
||||
}
|
||||
|
||||
public get slices(): ISlice[] {
|
||||
const tags = Object.keys(this.categories)
|
||||
const total = this.transactions.reduce(
|
||||
(t: number, b: ITransaction) =>
|
||||
t + parseFloat((b.amount || 0).toString()),
|
||||
0
|
||||
)
|
||||
const slices = tags.map((tag: string, index: number) => ({
|
||||
percent:
|
||||
this.categories[tag].transactions.reduce(
|
||||
(t: number, b: ITransaction) => t + b.amount,
|
||||
0
|
||||
) / total,
|
||||
color: colors[index].value || '#ffffff',
|
||||
darkColor: colors[index].darkValue || '#ffffff',
|
||||
key: tag,
|
||||
label: this.categories[tag].tag.label
|
||||
}))
|
||||
|
||||
return slices.sort((a: ISlice, b) => (a.percent >= b.percent ? -1 : 1))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../styles/variables';
|
||||
.category-list:not(:last-child) {
|
||||
border-bottom: 1px solid $main;
|
||||
}
|
||||
.tag-element {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
font-size: 30pt;
|
||||
}
|
||||
.chart-container {
|
||||
padding: 15px;
|
||||
}
|
||||
.legend {
|
||||
font-weight: bold;
|
||||
font-size: 16pt;
|
||||
}
|
||||
.legend-item {
|
||||
background-color: $main;
|
||||
font-size: 20px;
|
||||
line-height: 2em;
|
||||
}
|
||||
.legend-label {
|
||||
text-align: center;
|
||||
}
|
||||
.percent-label {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
354
src/components/TransactionCreate.vue
Normal file
354
src/components/TransactionCreate.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="transaction-create">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="account">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: 'account', params: { id: account._id } }">{{ account.name }}</router-link>
|
||||
</li>
|
||||
<li v-if="tag && tag !== noTag">
|
||||
<a href="#" @click.prevent>{{ transactionTagLabel[tag].label }}</a>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<a href="#" @click.prevent>
|
||||
<span v-if="transaction._id">{{ name }}</span>
|
||||
<span v-else>Nouvelle dépense</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Nom</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
v-model.trim="name"
|
||||
maxlength="30"
|
||||
placeholder="Nom de la dépense"
|
||||
/>
|
||||
<p
|
||||
class="help is-info"
|
||||
>{{ $tc('validation.max_char', 30 - name.length, { max: 30 - name.length }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Montant</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="field has-addons">
|
||||
<p class="control is-expanded">
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
v-model.number="amount"
|
||||
placeholder="Montant"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="1000000"
|
||||
/>
|
||||
</p>
|
||||
<p class="control">
|
||||
<span v-if="account.currencies.length > 1" class="select">
|
||||
<select name="currency" id="currency" v-model="currency">
|
||||
<option
|
||||
v-for="(currency, k) in account.currencies"
|
||||
:key="k"
|
||||
:value="currency"
|
||||
>{{ currency.symbol || currency.name }}</option>
|
||||
</select>
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
class="button is-static"
|
||||
>{{ account.mainCurrency.symbol || account.mainCurrency.name }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="help is-info">montant max : {{ maxAmount | money(currency) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Localisation</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control" v-if="transaction.location">
|
||||
<input type="text" readonly class="input" v-model="transaction.location.place" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Date</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input type="date" class="input" v-model="date" placeholder="Date" :max="today" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<transaction-tag-update v-model="tag" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Payé par</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field is-narrow">
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="pay-by" id="pay-by" v-model="payBy">
|
||||
<option
|
||||
v-for="(user, k) in account.users"
|
||||
:key="k"
|
||||
:value="user.alias"
|
||||
>{{ user.alias }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Pour</label>
|
||||
<button
|
||||
class="button is-primary is-small"
|
||||
:class="{ 'is-outlined': payForUsers.length !== account.users.length}"
|
||||
type="button"
|
||||
@click="selectAll"
|
||||
>tous</button>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field is-narrow">
|
||||
<div class="control">
|
||||
<div v-for="(split, k) in payFor" :key="k" class="user-item">
|
||||
<transaction-split :split="payFor[k]" />
|
||||
<hr v-if="k !== payFor.length - 1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<fab-button @valid="submitTransaction" :margin="true">
|
||||
<awe-icon icon="check" />
|
||||
</fab-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import IUser from '@/models/IUser'
|
||||
import ISplit from '@/models/ISplit'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
|
||||
import accountService from '@/services/AccountService'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import formatDate from '@/utils/format-date'
|
||||
import notif from '@/utils/notif'
|
||||
import { money } from '@/utils/filters'
|
||||
import { confirmation, alertMessage } from '@/utils'
|
||||
|
||||
const today: Date = new Date()
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'fab-button': () => import('@/components/FabButton.vue'),
|
||||
'transaction-split': () => import('@/components/TransactionSplit.vue'),
|
||||
'transaction-tag-update': () =>
|
||||
import('@/components/TransactionTagUpdate.vue')
|
||||
}
|
||||
})
|
||||
export default class TransactionCreate extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Prop({ type: Object, required: true })
|
||||
public transaction!: ITransaction
|
||||
@Prop({ type: Object, required: true })
|
||||
public account!: IAccount
|
||||
public name: string = ''
|
||||
public amount: number | null = null
|
||||
public tag: TransactionTag = TransactionTag.None
|
||||
public today: string = formatDate(today)
|
||||
public date: string = formatDate(today)
|
||||
public location: string = ''
|
||||
public currency: ICurrency | null = null
|
||||
public payBy: string = ''
|
||||
public payFor: ISplit[] = []
|
||||
public maxAmount: number = 1000000
|
||||
public noTag: string = TransactionTag.None
|
||||
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
|
||||
|
||||
public async created(): Promise<void> {
|
||||
this.setData(this.transaction)
|
||||
}
|
||||
|
||||
public setData(transaction: ITransaction): void {
|
||||
this.name = transaction.name
|
||||
this.date = formatDate(transaction.date)
|
||||
this.tag = transaction.tag
|
||||
this.amount = transaction.amount || null
|
||||
this.payBy = transaction.payBy
|
||||
this.payFor = transaction.payFor
|
||||
if (this.account.users) {
|
||||
this.account.users.forEach((user: IUser) => {
|
||||
if (!transaction.payFor.find((p: ISplit) => p.alias === user.alias)) {
|
||||
this.payFor.push({
|
||||
alias: user.alias || '',
|
||||
weight: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
this.currency = transaction.mainCurrency
|
||||
}
|
||||
|
||||
public async submitTransaction(next: any): Promise<void> {
|
||||
if (!this.validate()) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
if (!this.currency || !this.account) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const currencies: string[] = this.account.currencies.map(
|
||||
(c: ICurrency) => c.code
|
||||
)
|
||||
const date: Date = new Date(this.date)
|
||||
const hasId: boolean = !!this.transaction._id
|
||||
const amount: number = this.amount
|
||||
? parseFloat(this.amount.toString().replace(',', '.'))
|
||||
: 0
|
||||
const transaction: ITransaction = {
|
||||
_id: this.transaction._id,
|
||||
_rev: this.transaction._rev,
|
||||
doctype: this.transaction.doctype,
|
||||
accountId: this.account._id || '',
|
||||
name: this.name,
|
||||
date,
|
||||
tag: this.tag,
|
||||
amount,
|
||||
payBy: this.payBy,
|
||||
payFor: this.payForUsers,
|
||||
userIds: this.account.userIds,
|
||||
mainCurrency: this.currency,
|
||||
currencies: this.account.currencies,
|
||||
transactionType: TransactionType.normal,
|
||||
exchange: await exchangeService.get(this.currency.code, date, currencies)
|
||||
}
|
||||
const response: PouchDB.Core.Response = hasId
|
||||
? await transactionService.save(transaction)
|
||||
: await transactionService.add(this.account._id || '', transaction)
|
||||
if (response.ok) {
|
||||
if (hasId) {
|
||||
this.$router.push({
|
||||
name: 'transaction',
|
||||
params: { id: this.transaction._id || '' }
|
||||
})
|
||||
confirmation('Dépense mise à jour.')
|
||||
} else {
|
||||
this.$router.push({
|
||||
name: 'account',
|
||||
params: { id: this.account._id || '' }
|
||||
})
|
||||
confirmation('Dépense créée.')
|
||||
}
|
||||
} else {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn(response)
|
||||
alertMessage(`Une erreur s'est produite à la création d'une transaction.`)
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
if (!this.account) {
|
||||
return
|
||||
}
|
||||
this.payFor.forEach((p: ISplit) => (p.weight = p.weight || 1))
|
||||
}
|
||||
|
||||
public get payForUsers(): ISplit[] {
|
||||
return this.payFor.filter((p, index) => p.weight > 0)
|
||||
}
|
||||
|
||||
@Watch('payBy')
|
||||
public onPayByChange(payBy: string, oldPayBy: string) {
|
||||
if (
|
||||
oldPayBy &&
|
||||
this.account &&
|
||||
this.payFor.length !== this.account.users.length
|
||||
) {
|
||||
const old = this.payFor.find((p: ISplit) => p.alias === oldPayBy)
|
||||
if (old) {
|
||||
old.weight = 0
|
||||
}
|
||||
}
|
||||
if (payBy) {
|
||||
const newPayBy = this.payFor.find((p: ISplit) => p.alias === payBy)
|
||||
if (newPayBy) {
|
||||
newPayBy.weight = newPayBy.weight || 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validate(): boolean {
|
||||
if (
|
||||
!this.name ||
|
||||
!this.amount ||
|
||||
(!this.currency && !this.date && this.payFor.length === 0)
|
||||
) {
|
||||
if (this.amount === 0) {
|
||||
notif.alert('Le montant doit être supérieur à 0.')
|
||||
} else {
|
||||
notif.alert('Tous les champs sont requis.')
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (isNaN(this.amount) || this.amount > this.maxAmount) {
|
||||
notif.alert(
|
||||
`Veuillez saisir un montant numérique inférieur à ${money(
|
||||
this.maxAmount,
|
||||
this.currency
|
||||
)}.`
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (this.amount < 0) {
|
||||
notif.alert('Le montant doit être supérieur à 0.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
81
src/components/TransactionSplit.vue
Normal file
81
src/components/TransactionSplit.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="transaction-split field">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<input
|
||||
v-model="checked"
|
||||
class="is-checkradio"
|
||||
:id="`user-${split.alias}`"
|
||||
type="checkbox"
|
||||
:name="`user-${split.alias}`"
|
||||
/>
|
||||
<label
|
||||
:for="`user-${split.alias}`"
|
||||
>{{ split.alias }}, {{ $tc('transaction.split', split.weight, { count: split.weight }) }}</label>
|
||||
</div>
|
||||
<div class="column is-one-third">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<a @click="plus" class="button is-primary">
|
||||
<awe-icon icon="plus" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a @click="minus" class="button is-warning">
|
||||
<awe-icon icon="minus" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IUser from '@/models/IUser'
|
||||
import ISplit from '@/models/ISplit'
|
||||
|
||||
@Component
|
||||
export default class TransactionSplit extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Prop({ type: Object, required: true })
|
||||
public split!: ISplit
|
||||
public checked: boolean = false
|
||||
|
||||
public plus(): void {
|
||||
this.split.weight++
|
||||
}
|
||||
|
||||
public minus(): void {
|
||||
this.split.weight = Math.max(this.split.weight - 1, 0)
|
||||
}
|
||||
|
||||
@Watch('checked')
|
||||
public onCheckedChange(check: boolean): void {
|
||||
if (check && this.split.weight === 0) {
|
||||
this.split.weight = 1
|
||||
} else if (!check && this.split.weight > 0) {
|
||||
this.split.weight = 0
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('split.weight', { immediate: true })
|
||||
public onWeightChange(weight: number): void {
|
||||
if (weight === 0 && this.checked) {
|
||||
this.checked = false
|
||||
} else if (weight > 0 && !this.checked) {
|
||||
this.checked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
float: left;
|
||||
max-width: 160px;
|
||||
}
|
||||
</style>
|
||||
46
src/components/TransactionTagUpdate.vue
Normal file
46
src/components/TransactionTagUpdate.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="transaction-tag-update field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" for="tag">Catégorie</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control has-icons-left">
|
||||
<span class="select is-fullwidth">
|
||||
<select v-model="tagLocale" name="tag" id="tag">
|
||||
<option
|
||||
v-for="(transactionTag, k) in transactionTagLabel"
|
||||
:key="k"
|
||||
:value="k">
|
||||
{{ transactionTag.label }}
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
<span class="icon is-small is-left" v-if="transactionTagLabel[tag]">
|
||||
<awe-icon :icon="transactionTagLabel[tag].icon" />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Model, Watch } from 'vue-property-decorator'
|
||||
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
|
||||
|
||||
@Component
|
||||
export default class TransactionTagUpdate extends Vue {
|
||||
@Model('input', { type: String })
|
||||
public tag!: TransactionTag
|
||||
public tagLocale: TransactionTag = this.tag
|
||||
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
|
||||
|
||||
@Watch('tagLocale')
|
||||
public onTagChange(tag: TransactionTag) {
|
||||
if (tag !== this.tag) {
|
||||
this.$emit('input', tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
72
src/components/UserNew.vue
Normal file
72
src/components/UserNew.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="user-new columns">
|
||||
<div class="column is-5" v-if="user">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.pseudo'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input :readonly="isMainUser" class="input" type="text" v-model="newUser.userId" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-5" v-if="newUser">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.alias'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input required class="input" type="text" maxlength="20" v-model="newUser.alias" />
|
||||
</p>
|
||||
<p class="help is-info">Nom utilisé dans ce compte</p>
|
||||
<p
|
||||
class="help is-info"
|
||||
>{{ $tc('validation.max_char', maxChar(newUser), { max: maxChar(newUser) }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2" v-if="!isMainUser">
|
||||
<button class="button is-danger" @click="$emit('remove')">supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Model, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IUser from '@/models/IUser'
|
||||
import { slug } from '@/utils'
|
||||
|
||||
@Component
|
||||
export default class UserNew extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Model('input', { type: Object })
|
||||
public newUser!: IUser
|
||||
|
||||
public maxChar(user: IUser): number {
|
||||
return 20 - (user.alias ? user.alias.length : 0)
|
||||
}
|
||||
|
||||
public get isMainUser(): boolean {
|
||||
if (!this.newUser) {
|
||||
return false
|
||||
}
|
||||
return !!this.user && this.user.userId === this.newUser.userId
|
||||
}
|
||||
|
||||
@Watch('user.email', { immediate: true })
|
||||
public onEmailChange(email: string) {
|
||||
if (this.newUser) {
|
||||
this.newUser.slugEmail = slug(email || '')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
44
src/data/colors.ts
Normal file
44
src/data/colors.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import IColor from '@/models/IColor'
|
||||
import lightness from 'lightness'
|
||||
|
||||
const dark = -70
|
||||
|
||||
export default [
|
||||
{
|
||||
label: 'Bleu',
|
||||
value: '#bfccdd'
|
||||
},
|
||||
{
|
||||
label: 'Rose',
|
||||
value: '#e6ccc6'
|
||||
},
|
||||
{
|
||||
label: 'Orange',
|
||||
value: '#ed9374'
|
||||
},
|
||||
{
|
||||
label: 'Jaune',
|
||||
value: '#f1dbbd'
|
||||
},
|
||||
{
|
||||
label: 'Vert',
|
||||
value: '#cfe4d9'
|
||||
},
|
||||
{
|
||||
label: 'Violet',
|
||||
value: '#b487fd'
|
||||
},
|
||||
{
|
||||
label: 'Gris',
|
||||
value: '#efedf5'
|
||||
},
|
||||
{
|
||||
label: 'Acier',
|
||||
value: '#566274'
|
||||
}
|
||||
].map(
|
||||
(color: IColor): IColor => ({
|
||||
...color,
|
||||
darkValue: lightness(color.value, dark).toLowerCase()
|
||||
})
|
||||
)
|
||||
37
src/data/currencies.ts
Normal file
37
src/data/currencies.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
|
||||
export default [
|
||||
{ name: 'Euro', code: 'EUR', symbol: '€' },
|
||||
{ name: 'Dollar', code: 'USD', symbol: '$' },
|
||||
{ name: 'Japanese yen', code: 'JPY', symbol: '¥' },
|
||||
{ name: 'Bulgarian lev', code: 'BGN', symbol: 'лв' },
|
||||
{ name: 'Czech koruna', code: 'CZK', symbol: 'Kč' },
|
||||
{ name: 'Danish krone', code: 'DKK', symbol: 'øre' },
|
||||
{ name: 'Pound sterling', code: 'GBP', symbol: '£' },
|
||||
{ name: 'Hungarian forint', code: 'HUF', symbol: 'Ft' },
|
||||
{ name: 'Polish zloty', code: 'PLN', symbol: 'zł' },
|
||||
{ name: 'Romanian leu', code: 'RON' },
|
||||
{ name: 'Swedish krona', code: 'SEK', symbol: 'øre' },
|
||||
{ name: 'Swiss franc', code: 'CHF' },
|
||||
{ name: 'Icelandic krona', code: 'ISK', symbol: 'kr' },
|
||||
{ name: 'Norwegian krone', code: 'NOK', symbol: 'øre' },
|
||||
{ name: 'Croatian kuna', code: 'HRK', symbol: 'kn' },
|
||||
{ name: 'Russian rouble', code: 'RUB' },
|
||||
{ name: 'Turkish lira', code: 'TRY' },
|
||||
{ name: 'Australian dollar', code: 'AUD', symbol: 'A$' },
|
||||
{ name: 'Brazilian real', code: 'BRL', symbol: 'R$' },
|
||||
{ name: 'Canadian dollar', code: 'CAD', symbol: 'C$' },
|
||||
{ name: 'Chinese yuan renminbi', code: 'CNY', symbol: '¥' },
|
||||
{ name: 'Hong Kong dollar', code: 'HKD', symbol: 'HK$' },
|
||||
{ name: 'Indonesian rupiah', code: 'IDR', symbol: 'Rp' },
|
||||
{ name: 'Israeli shekel', code: 'ILS', symbol: '₪' },
|
||||
{ name: 'Indian rupee', code: 'INR', symbol: '₹' },
|
||||
{ name: 'South Korean won', code: 'KRW', symbol: '₩' },
|
||||
{ name: 'Mexican peso', code: 'MXN', symbol: 'Mex$' },
|
||||
{ name: 'Malaysian ringgit', code: 'MYR', symbol: 'RM' },
|
||||
{ name: 'New Zealand dollar', code: 'NZD', symbol: 'NZ$' },
|
||||
{ name: 'Philippine piso', code: 'PHP' },
|
||||
{ name: 'Singapore dollar', code: 'SGD', symbol: 'S$' },
|
||||
{ name: 'Thai baht', code: 'THB', symbol: '฿' },
|
||||
{ name: 'South African rand', code: 'ZAR', symbol: 'R' }
|
||||
] as ICurrency[]
|
||||
59
src/enums/TransactionTag.ts
Normal file
59
src/enums/TransactionTag.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
enum TransactionTag {
|
||||
None = 'none',
|
||||
Tourism = 'tourism',
|
||||
Food = 'food',
|
||||
Transport = 'transport',
|
||||
Housing = 'housing',
|
||||
Shopping = 'shopping',
|
||||
Gift = 'gift',
|
||||
Entertainment = 'entertainment',
|
||||
Tax = 'tax'
|
||||
}
|
||||
|
||||
export default TransactionTag
|
||||
|
||||
export interface ITagLabel {
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
interface ITagAllLabel {
|
||||
[key: string]: ITagLabel
|
||||
}
|
||||
export const TransactionTagLabel: ITagAllLabel = {
|
||||
[TransactionTag.None]: {
|
||||
label: 'Aucune',
|
||||
icon: 'circle'
|
||||
},
|
||||
[TransactionTag.Tourism]: {
|
||||
label: 'Tourisme',
|
||||
icon: 'torii-gate'
|
||||
},
|
||||
[TransactionTag.Food]: {
|
||||
label: 'Restaurant',
|
||||
icon: 'utensils'
|
||||
},
|
||||
[TransactionTag.Transport]: {
|
||||
label: 'Transport',
|
||||
icon: 'subway'
|
||||
},
|
||||
[TransactionTag.Housing]: {
|
||||
label: 'Logement',
|
||||
icon: 'home'
|
||||
},
|
||||
[TransactionTag.Shopping]: {
|
||||
label: 'Courses',
|
||||
icon: 'shopping-basket'
|
||||
},
|
||||
[TransactionTag.Gift]: {
|
||||
label: 'Cadeau',
|
||||
icon: 'gift'
|
||||
},
|
||||
[TransactionTag.Entertainment]: {
|
||||
label: 'Divertissement',
|
||||
icon: 'theater-masks'
|
||||
},
|
||||
[TransactionTag.Tax]: {
|
||||
label: 'Impôt',
|
||||
icon: 'hand-holding-usd'
|
||||
}
|
||||
}
|
||||
6
src/enums/TransactionType.ts
Normal file
6
src/enums/TransactionType.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
enum TransactionType {
|
||||
normal,
|
||||
refund
|
||||
}
|
||||
|
||||
export default TransactionType
|
||||
38
src/main.ts
Normal file
38
src/main.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import './utils/notyf'
|
||||
import Vue from 'vue'
|
||||
import VueI18n from 'vue-i18n'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import './styles/index.scss'
|
||||
import 'bulma-checkradio'
|
||||
import './registerServiceWorker'
|
||||
import filters from './utils/filters'
|
||||
import './utils/icons'
|
||||
import messages from './messages'
|
||||
|
||||
Vue.use(VueI18n)
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
for (const filter of Object.keys(filters)) {
|
||||
Vue.filter(filter, filters[filter])
|
||||
}
|
||||
|
||||
const i18n = new VueI18n({
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'en',
|
||||
messages
|
||||
})
|
||||
|
||||
const app = async () => {
|
||||
await store.dispatch('retrieveUser')
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
i18n,
|
||||
render: (h) => h(App)
|
||||
}).$mount('#app')
|
||||
}
|
||||
|
||||
app()
|
||||
52
src/messages/en.ts
Normal file
52
src/messages/en.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export default {
|
||||
base: {
|
||||
back: 'retour',
|
||||
add: 'add',
|
||||
delete: 'supprimer'
|
||||
},
|
||||
title: {
|
||||
app: 'Vaquant',
|
||||
about: 'About',
|
||||
pricing: 'Pricing'
|
||||
},
|
||||
account: {
|
||||
create: 'Create an account',
|
||||
new: 'New account',
|
||||
setting: 'Setting: {name}',
|
||||
users: 'Users',
|
||||
danger_zone: 'Danger zone',
|
||||
delete: 'Delete account',
|
||||
main_currency: 'Main currency',
|
||||
use_currency: 'Use others currencies',
|
||||
used_currencies: 'Used currency | Used currencies',
|
||||
friends: 'Friend | Friends',
|
||||
amount_name: 'Name',
|
||||
payed_by: 'Payed by',
|
||||
none: 'No account currently'
|
||||
},
|
||||
about: {
|
||||
description: 'Vaquant is an app. Voilà!'
|
||||
},
|
||||
transaction: {
|
||||
weight: 'ratio | ratio | ratio'
|
||||
},
|
||||
user: {
|
||||
account: 'Account',
|
||||
loginsignup: 'Log in / Sign up',
|
||||
login: 'log in',
|
||||
signup: 'signup',
|
||||
password: 'password',
|
||||
confirm_password: 'confirm password',
|
||||
alias: 'Alias',
|
||||
email: 'Email',
|
||||
firstname: 'Firstname',
|
||||
lastname: 'Lastname',
|
||||
connectedwith: 'You are login with the email ',
|
||||
logout: 'log out',
|
||||
delete: 'delete profile'
|
||||
},
|
||||
validation: {
|
||||
max_char:
|
||||
'no character available | 1 character max | {max} character maximum'
|
||||
}
|
||||
}
|
||||
68
src/messages/fr.ts
Normal file
68
src/messages/fr.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export default {
|
||||
base: {
|
||||
back: 'retour',
|
||||
add: 'ajouter',
|
||||
delete: 'supprimer'
|
||||
},
|
||||
title: {
|
||||
app: 'Vaquant',
|
||||
about: 'À propos',
|
||||
pricing: 'Offres'
|
||||
},
|
||||
account: {
|
||||
open: 'Ouvrir à nouveau',
|
||||
close: 'Cloturer',
|
||||
closed: 'Compte cloturé',
|
||||
create: 'Créer un compte',
|
||||
new: 'Nouveau compte',
|
||||
setting: 'Paramètre : {name}',
|
||||
users: 'Utilisateurs',
|
||||
danger_zone: 'Zone de danger',
|
||||
delete: 'Supprimer le compte',
|
||||
main_currency: 'Devise principale',
|
||||
use_currency: `Utiliser d'autre devises`,
|
||||
used_currencies: 'Devise utilisée | {count} devises utilisées',
|
||||
friends: `Seul | Avec un ami | Avec {count} amis`,
|
||||
amount_name: 'Dépense',
|
||||
payed_by: 'Payée par',
|
||||
none: 'Pas de compte actuellement',
|
||||
total: `Il n'y a actuellement aucune dépense. | Il y a une dépense. | Il y a {count} dépenses.`,
|
||||
publicInformation: `Toutes les personnes accédant à ce compte
|
||||
pourront ajouter, modifier et supprimer des dépenses.`,
|
||||
privateInformation:
|
||||
'Seul les amis inscrits à ce compte pourront ajouter, modifier et supprimer des dépenses.'
|
||||
},
|
||||
about: {
|
||||
description: `
|
||||
Vaquant est une application pour réaliser
|
||||
des balances équitables de vos budgets entre
|
||||
amis lors de vos colocations, vacances, séjours,
|
||||
voyages, etc.`
|
||||
},
|
||||
transaction: {
|
||||
weight: 'part | part | parts',
|
||||
money: 'Aucune monnaie | Autre monnaie | Autres monnaies',
|
||||
split: 'Aucune part | 1 part | {count} parts'
|
||||
},
|
||||
user: {
|
||||
account: 'Compte',
|
||||
loginsignup: `Se connecter / S'inscrire`,
|
||||
login: 'se connecter',
|
||||
signup: `s'inscrire`,
|
||||
password: 'mot de passe',
|
||||
confirm_password: 'confirmer le mot de passe',
|
||||
alias: 'alias',
|
||||
email: 'email',
|
||||
firstname: 'prénom',
|
||||
lastname: 'nom',
|
||||
connectedwith: `Vous êtes connecté avec l'identifiant`,
|
||||
logout: 'se déconnecter',
|
||||
delete: 'supprimer mon profil',
|
||||
purge: 'Réinitialiser les données',
|
||||
purgeDone: 'Réinitialisation des données effectuées avec succès.',
|
||||
pseudo: 'identifiant'
|
||||
},
|
||||
validation: {
|
||||
max_char: 'limite atteinte | 1 caractère maximum | {max} caractères maximum'
|
||||
}
|
||||
}
|
||||
7
src/messages/index.ts
Normal file
7
src/messages/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import en from '@/messages/en'
|
||||
import fr from '@/messages/fr'
|
||||
|
||||
export default {
|
||||
en,
|
||||
fr
|
||||
}
|
||||
17
src/models/IAccount.ts
Normal file
17
src/models/IAccount.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import IModel from '@/models/IModel'
|
||||
import IUser from '@/models/IUser'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
|
||||
export default interface IAccount extends IModel {
|
||||
name: string
|
||||
color: string | null
|
||||
slug?: string
|
||||
admin: IUser | null
|
||||
premium?: boolean
|
||||
users: IUser[]
|
||||
userIds: string[]
|
||||
mainCurrency: ICurrency
|
||||
currencies: ICurrency[]
|
||||
archive?: boolean
|
||||
isPublic?: boolean
|
||||
}
|
||||
6
src/models/IBalance.ts
Normal file
6
src/models/IBalance.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import IUser from '@/models/IUser'
|
||||
|
||||
export default interface IBalance {
|
||||
user: IUser
|
||||
amount: number
|
||||
}
|
||||
5
src/models/IColor.ts
Normal file
5
src/models/IColor.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default interface IColor {
|
||||
label: string | null
|
||||
value: string | null
|
||||
darkValue?: string | null
|
||||
}
|
||||
5
src/models/ICurrency.ts
Normal file
5
src/models/ICurrency.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default interface ICurrency {
|
||||
name: string
|
||||
code: string
|
||||
symbol?: string
|
||||
}
|
||||
4
src/models/IEmailPassword.ts
Normal file
4
src/models/IEmailPassword.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default interface IIdPassword {
|
||||
userId: string
|
||||
password: string
|
||||
}
|
||||
6
src/models/IEncryptPassword.ts
Normal file
6
src/models/IEncryptPassword.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import IModel from '@/models/IModel'
|
||||
|
||||
export default interface IEncryptPassword extends IModel {
|
||||
password: string
|
||||
iv?: Uint8Array
|
||||
}
|
||||
3
src/models/IEncryptable.ts
Normal file
3
src/models/IEncryptable.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default interface IEncryptable {
|
||||
[key: string]: any
|
||||
}
|
||||
3
src/models/IEncrypted.ts
Normal file
3
src/models/IEncrypted.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default interface IEncrypted {
|
||||
[key: string]: string
|
||||
}
|
||||
8
src/models/IExchange.ts
Normal file
8
src/models/IExchange.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import IModel from '@/models/IModel'
|
||||
|
||||
export default interface IExchange extends IModel {
|
||||
base: string
|
||||
date: string
|
||||
timestamp?: string
|
||||
rates: { [k: string]: number }
|
||||
}
|
||||
5
src/models/ILocation.ts
Normal file
5
src/models/ILocation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default interface ILocation {
|
||||
lat: number
|
||||
lon: number
|
||||
place?: string
|
||||
}
|
||||
44
src/models/ILocationQuery.ts
Normal file
44
src/models/ILocationQuery.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export default interface ILocationQuery {
|
||||
authenticationResultCode: string
|
||||
brandLogoUri: string
|
||||
copyright: string
|
||||
resourceSets: [
|
||||
{
|
||||
estimatedTotal: number
|
||||
resources: [
|
||||
{
|
||||
__type: string
|
||||
bbox: number[]
|
||||
name: string
|
||||
point: {
|
||||
type: string
|
||||
coordinates: number[]
|
||||
}
|
||||
address: {
|
||||
addressLine: string
|
||||
adminDistrict: string
|
||||
adminDistrict2: string
|
||||
countryRegion: string
|
||||
formattedAddress: string
|
||||
locality: string
|
||||
postalCode: string
|
||||
}
|
||||
confidence: string
|
||||
entityType: string
|
||||
geocodePoints: [
|
||||
{
|
||||
type: string
|
||||
coordinates: number[]
|
||||
calculationMethod: string
|
||||
usageTypes: string[]
|
||||
}
|
||||
]
|
||||
matchCodes: string[]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
statusCode: number
|
||||
statusDescription: string
|
||||
traceId: string
|
||||
}
|
||||
7
src/models/IModel.ts
Normal file
7
src/models/IModel.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default interface IModel {
|
||||
_id?: string
|
||||
_rev?: string
|
||||
_deleted?: boolean
|
||||
_conflicts?: string[]
|
||||
doctype?: string
|
||||
}
|
||||
4
src/models/IPoint.ts
Normal file
4
src/models/IPoint.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default interface IPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
7
src/models/IRefund.ts
Normal file
7
src/models/IRefund.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import IUser from '@/models/IUser'
|
||||
|
||||
export default interface IRefund {
|
||||
from: IUser
|
||||
to: IUser
|
||||
amount: number
|
||||
}
|
||||
4
src/models/IResponse.ts
Normal file
4
src/models/IResponse.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default interface IResponse {
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
7
src/models/ISlice.ts
Normal file
7
src/models/ISlice.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default interface ISlice {
|
||||
percent: number
|
||||
color: string
|
||||
darkColor: string
|
||||
key?: string
|
||||
label?: string
|
||||
}
|
||||
4
src/models/ISplit.ts
Normal file
4
src/models/ISplit.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default interface ISplit {
|
||||
alias: string
|
||||
weight: number
|
||||
}
|
||||
8
src/models/IStat.ts
Normal file
8
src/models/IStat.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import IBalance from '@/models/IBalance'
|
||||
|
||||
export default interface IStat {
|
||||
label: string
|
||||
value: number
|
||||
percent: number
|
||||
balance: IBalance
|
||||
}
|
||||
23
src/models/ITransaction.ts
Normal file
23
src/models/ITransaction.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import IModel from '@/models/IModel'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import IExchange from '@/models/IExchange'
|
||||
import TransactionTag from '@/enums/TransactionTag'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import ISplit from '@/models/ISplit'
|
||||
import ILocation from './ILocation'
|
||||
|
||||
export default interface ITransaction extends IModel {
|
||||
name: string
|
||||
accountId: string
|
||||
date: Date
|
||||
amount: number
|
||||
payBy: string
|
||||
payFor: ISplit[]
|
||||
userIds: string[]
|
||||
mainCurrency: ICurrency
|
||||
transactionType: TransactionType
|
||||
currencies: ICurrency[]
|
||||
exchange: IExchange | null
|
||||
tag: TransactionTag
|
||||
location?: ILocation
|
||||
}
|
||||
13
src/models/IUser.ts
Normal file
13
src/models/IUser.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default interface IUser {
|
||||
userId: string
|
||||
email: string
|
||||
premium: boolean
|
||||
slugEmail: string
|
||||
alias?: string
|
||||
firstname?: string
|
||||
lastname?: string
|
||||
roles?: string[]
|
||||
customerId?: string
|
||||
subscriptionId?: string
|
||||
salt?: string
|
||||
}
|
||||
7
src/models/IUserPassword.ts
Normal file
7
src/models/IUserPassword.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import IUser from '@/models/IUser'
|
||||
|
||||
export default interface IUserPassword {
|
||||
user: IUser
|
||||
password: string
|
||||
replicate?: boolean
|
||||
}
|
||||
75
src/registerServiceWorker.ts
Normal file
75
src/registerServiceWorker.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/* tslint:disable:no-console */
|
||||
|
||||
import { register } from 'register-service-worker'
|
||||
import notif from './utils/notif'
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const enableNotif: boolean = false
|
||||
let newWorker: any | undefined
|
||||
|
||||
const reloadLink = document.getElementById('reload-notify-worker')
|
||||
if (reloadLink) {
|
||||
reloadLink.addEventListener('click', () => {
|
||||
if (newWorker) {
|
||||
newWorker.postMessage({ action: 'skipWaiting' })
|
||||
}
|
||||
})
|
||||
}
|
||||
const showUpdateBar = () => {
|
||||
const notify = document.getElementById('notify-worker')
|
||||
if (notify) {
|
||||
notify.classList.add('show')
|
||||
}
|
||||
}
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
registrationOptions: {},
|
||||
ready() {
|
||||
notif.confirm('Application mise à jour !')
|
||||
console.log(
|
||||
'App is being served from cache by a service worker.\n' +
|
||||
'For more details, visit https://goo.gl/AFskqB'
|
||||
)
|
||||
},
|
||||
cached() {
|
||||
console.log('Content has been cached for offline use.')
|
||||
},
|
||||
updatefound(reg) {
|
||||
if (enableNotif) {
|
||||
console.log('New content found;')
|
||||
newWorker = reg.installing
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
// Has network.state changed?
|
||||
switch (newWorker.state) {
|
||||
case 'installed':
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// new update available
|
||||
showUpdateBar()
|
||||
}
|
||||
// No update available
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
console.log('New content is available; please refresh.')
|
||||
},
|
||||
offline() {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
)
|
||||
},
|
||||
error(error) {
|
||||
console.error('Error during service worker registration:', error)
|
||||
}
|
||||
})
|
||||
let refreshing: any | undefined
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing) {
|
||||
return
|
||||
}
|
||||
window.location.reload()
|
||||
refreshing = true
|
||||
})
|
||||
}
|
||||
105
src/router.ts
Normal file
105
src/router.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import Home from './views/Home.vue'
|
||||
import store from './store'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
const router: Router = new Router({
|
||||
mode: 'history',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "about" */ './views/About.vue')
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
name: 'pricing',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "pricing" */ './views/Pricing.vue')
|
||||
},
|
||||
{
|
||||
path: '/security',
|
||||
name: 'security',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "pricing" */ './views/Security.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/new',
|
||||
name: 'account-new',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "account-new" */ './views/accounts/AccountNew.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/:id',
|
||||
name: 'account',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "account" */ './views/accounts/AccountItem.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/:id/setting',
|
||||
name: 'account-setting',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "account-setting" */ './views/accounts/AccountSetting.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/:id/public',
|
||||
name: 'account-public',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "account-public" */ './views/accounts/AccountPublic.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/:id/transaction/new',
|
||||
name: 'transaction-new',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "transaction-new" */ './views/transactions/TransactionNew.vue')
|
||||
},
|
||||
{
|
||||
path: '/transaction/:id/update',
|
||||
name: 'transaction-update',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "transaction-update" */ './views/transactions/TransactionUpdate.vue')
|
||||
},
|
||||
{
|
||||
path: '/transaction/:id',
|
||||
name: 'transaction',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "transaction" */ './views/transactions/TransactionItem.vue')
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'user',
|
||||
component: () => import(/* webpackChunkName: "user" */ './views/User.vue')
|
||||
},
|
||||
{
|
||||
path: '/user/:premail',
|
||||
name: 'user-premail',
|
||||
props: true,
|
||||
component: () => import(/* webpackChunkName: "user" */ './views/User.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// tslint:disable-next-line:variable-name
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const accountRoutes: string[] = ['account', 'transaction']
|
||||
if (!accountRoutes.includes(to.name || '')) {
|
||||
store.dispatch('setTitle', null)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
141
src/services/AccountService.ts
Normal file
141
src/services/AccountService.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import couchService from '@/services/CouchService'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import { slug } from '@/utils'
|
||||
import IEncryptable from '@/models/IEncryptable'
|
||||
import IEncrypted from '@/models/IEncrypted'
|
||||
|
||||
class AccountService {
|
||||
public async get(id: string): Promise<IAccount | null> {
|
||||
const account: IAccount = await couchService.get(id)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
public async getRemote(id: string): Promise<IAccount | null> {
|
||||
const account: IAccount = await couchService.getRemote(id)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
public async add(account: IAccount): Promise<PouchDB.Core.Response> {
|
||||
try {
|
||||
account.slug = slug(account.name)
|
||||
const id = couchService.newId('acc')
|
||||
account._id = id
|
||||
account.doctype = 'account'
|
||||
const response = await couchService.save(account)
|
||||
return response
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn({ error })
|
||||
return {
|
||||
ok: false,
|
||||
id: '',
|
||||
rev: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async active(id: string): Promise<boolean> {
|
||||
try {
|
||||
const account = await this.get(id)
|
||||
if (account) {
|
||||
account.archive = false
|
||||
}
|
||||
const { ok } = await couchService.save(account)
|
||||
return ok
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.info('erreur dans la cloture du compte', {
|
||||
error
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public async archive(id: string): Promise<boolean> {
|
||||
try {
|
||||
const account = await this.get(id)
|
||||
if (account) {
|
||||
account.archive = true
|
||||
}
|
||||
const { ok } = await couchService.save(account)
|
||||
return ok
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.info('erreur dans la cloture du compte', {
|
||||
error
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
const transactions: ITransaction[] = await transactionService.getAllByAccountId(
|
||||
id
|
||||
)
|
||||
transactions.forEach((transaction: ITransaction) => {
|
||||
transaction._deleted = true
|
||||
})
|
||||
await transactionService.multipleSave(transactions)
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.info('erreur dans la suppression des transactions associées', {
|
||||
error
|
||||
})
|
||||
}
|
||||
return await couchService.remove(id)
|
||||
}
|
||||
|
||||
public async getAll(archived: boolean = false): Promise<IAccount[]> {
|
||||
try {
|
||||
const response = await couchService.getByPrefix('acc')
|
||||
const accounts = response.rows.map((row: any) => row.doc) as IAccount[]
|
||||
const result = []
|
||||
for (const account of accounts) {
|
||||
if ((archived && account.archive) || (!archived && !account.archive)) {
|
||||
result.push(account)
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn('get all', { error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async update(id: string, account: IAccount): Promise<boolean> {
|
||||
const response = await couchService.get(id)
|
||||
if (response) {
|
||||
const result = await couchService.save({
|
||||
...account,
|
||||
_id: id,
|
||||
doctype: 'account'
|
||||
})
|
||||
account._rev = result.rev
|
||||
return result.ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private toEncrypt(account: IAccount): IEncryptable | IEncrypted {
|
||||
if (!account) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
name: account.name,
|
||||
slug: account.slug,
|
||||
premium: account.premium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccountService()
|
||||
10
src/services/AdminService.ts
Normal file
10
src/services/AdminService.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import couchService from '@/services/CouchService'
|
||||
|
||||
class AdminService {
|
||||
public async getAccountList(): Promise<any[]> {
|
||||
const result = await couchService.remote.allDocs()
|
||||
return result.rows
|
||||
}
|
||||
}
|
||||
|
||||
export default new AdminService()
|
||||
90
src/services/BalanceService.ts
Normal file
90
src/services/BalanceService.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import IBalance from '@/models/IBalance'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import IRefund from '@/models/IRefund'
|
||||
import IUser from '@/models/IUser'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import ISplit from '@/models/ISplit'
|
||||
|
||||
class BalanceService {
|
||||
public getBalanceByUser(
|
||||
user: IUser,
|
||||
currency: ICurrency,
|
||||
transactions: ITransaction[]
|
||||
): IBalance {
|
||||
const convert = exchangeService.getAmountInBase
|
||||
const transactionPayedAmount: number = transactions
|
||||
.filter((transaction: ITransaction) => transaction.payBy === user.alias)
|
||||
.reduce((amount: number, transaction: ITransaction) => {
|
||||
const convertedAmount = convert(
|
||||
transaction.amount,
|
||||
currency.code,
|
||||
transaction.exchange
|
||||
)
|
||||
return amount + convertedAmount
|
||||
}, 0)
|
||||
const transactionToRefundAmout: number = transactions
|
||||
.filter((transaction: ITransaction) =>
|
||||
transaction.payFor
|
||||
.map((p: ISplit) => p.alias)
|
||||
.includes(user.alias || '')
|
||||
)
|
||||
.reduce((amount: number, transaction: ITransaction) => {
|
||||
const convertedAmount = convert(
|
||||
transaction.amount *
|
||||
transactionService.getMultiply(transaction, user.alias || ''),
|
||||
currency.code,
|
||||
transaction.exchange
|
||||
)
|
||||
const totalWeight = transaction.payFor
|
||||
.map((p: ISplit) => p.weight)
|
||||
.reduce((w, weight) => w + weight)
|
||||
return amount + convertedAmount / totalWeight
|
||||
}, 0)
|
||||
return {
|
||||
user,
|
||||
amount: this.round(transactionPayedAmount - transactionToRefundAmout)
|
||||
}
|
||||
}
|
||||
|
||||
public getRefund(balances: IBalance[]): IRefund[] {
|
||||
const giver: IBalance | undefined = balances.find(
|
||||
(balance: IBalance) => balance.amount < 0
|
||||
)
|
||||
const receiver: IBalance | undefined = balances.find(
|
||||
(balance: IBalance) => balance.amount > 0
|
||||
)
|
||||
if (!giver || !receiver) {
|
||||
return []
|
||||
}
|
||||
const minAmount: number = this.round(
|
||||
Math.min(Math.abs(giver.amount), Math.abs(receiver.amount))
|
||||
)
|
||||
const refund: IRefund = {
|
||||
from: giver.user,
|
||||
to: receiver.user,
|
||||
amount: minAmount
|
||||
}
|
||||
return [
|
||||
refund,
|
||||
...this.getRefund(
|
||||
balances.map((balance: IBalance) => {
|
||||
const newBalance: IBalance = { ...balance }
|
||||
if (newBalance.user === giver.user) {
|
||||
newBalance.amount = this.round(newBalance.amount + minAmount)
|
||||
} else if (newBalance.user === receiver.user) {
|
||||
newBalance.amount = this.round(newBalance.amount - minAmount)
|
||||
}
|
||||
return newBalance
|
||||
})
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private round(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
export default new BalanceService()
|
||||
28
src/services/ChartService.ts
Normal file
28
src/services/ChartService.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import IPoint from '@/models/IPoint'
|
||||
import { money } from '@/utils/filters'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
|
||||
class ChartService {
|
||||
public valueToPoint(value: number, index: number, total: number): IPoint {
|
||||
const x = 0
|
||||
const y = -value * 1.6
|
||||
const angle = ((Math.PI * 2) / total) * index
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
const tx = x * cos - y * sin + 200
|
||||
const ty = x * sin + y * cos + 200
|
||||
return {
|
||||
x: tx,
|
||||
y: ty
|
||||
}
|
||||
}
|
||||
|
||||
public getLabel(user: string, cost: number, currency: ICurrency): string {
|
||||
if (!user) {
|
||||
return ''
|
||||
}
|
||||
return `${user} (${money(cost, currency)})`
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChartService()
|
||||
374
src/services/CouchService.ts
Normal file
374
src/services/CouchService.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import axios from 'axios'
|
||||
import PouchDb from 'pouchdb-browser'
|
||||
import PouchDbAuthentication from 'pouchdb-authentication'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import IUser from '@/models/IUser'
|
||||
import notif from '@/utils/notif'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { confirmation, toHex } from '@/utils'
|
||||
import cryptoService from '@/services/CryptoService'
|
||||
import IEncryptable from '@/models/IEncryptable'
|
||||
import IEncrypted from '@/models/IEncrypted'
|
||||
import IEncryptPassword from '@/models/IEncryptPassword'
|
||||
import { SET_KEY_URL, GET_KEY_URL } from '@/utils/serverless-url'
|
||||
import store from '@/store'
|
||||
|
||||
PouchDb.plugin(PouchDbAuthentication)
|
||||
|
||||
const emit = debounce((ev: string, arr?: string[]) => bus.$emit(ev, arr), 150)
|
||||
|
||||
const remoteConfig = {
|
||||
skip_setup: true,
|
||||
ajax: {
|
||||
cache: true,
|
||||
withCredentials: true
|
||||
}
|
||||
}
|
||||
const LOCALE_DB: string = 'LOCALE_DB'
|
||||
const REMOTE: string = 'https://juliencalixte.ddns.net/database'
|
||||
|
||||
class CouchService {
|
||||
public remote: PouchDB.Database = new PouchDb(
|
||||
`${REMOTE}/vaquant`,
|
||||
remoteConfig
|
||||
)
|
||||
public db: PouchDB.Database | null = new PouchDb(LOCALE_DB)
|
||||
public remoteUserDb: PouchDB.Database | null = null
|
||||
public userDb: PouchDB.Database | null = null
|
||||
private user: IUser | null = null
|
||||
private sync: PouchDB.Replication.Sync<{}> | null = null
|
||||
private userSync: PouchDB.Replication.Sync<{}> | null = null
|
||||
private eventListened: boolean = false
|
||||
|
||||
constructor() {
|
||||
if (!this.eventListened) {
|
||||
this.eventListened = true
|
||||
window.addEventListener('online', () => {
|
||||
if (!this.sync) {
|
||||
this.initLive()
|
||||
}
|
||||
})
|
||||
window.addEventListener('offline', () => {
|
||||
this.cancelSync()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public async initLive(): Promise<boolean> {
|
||||
if (!this.db) {
|
||||
return false
|
||||
}
|
||||
await this.cancelSync()
|
||||
if (this.userDb && this.remoteUserDb) {
|
||||
this.userSync = this.userDb
|
||||
.sync(this.remoteUserDb, {
|
||||
live: true,
|
||||
retry: true
|
||||
})
|
||||
.on('change', (result: PouchDB.Replication.SyncResult<any>) => {
|
||||
if (result.direction === 'pull') {
|
||||
emit(SYNC, result.change.docs.map((doc: any) => doc._id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.sync = this.db
|
||||
.sync(this.remote, {
|
||||
live: true,
|
||||
retry: true,
|
||||
filter: 'account/by_user',
|
||||
query_params: {
|
||||
userId: (this.user && this.user.userId) || '',
|
||||
accountIds: [...store.getters.publicAccountIds]
|
||||
}
|
||||
})
|
||||
.on('change', (result: PouchDB.Replication.SyncResult<any>) => {
|
||||
if (result.direction === 'pull') {
|
||||
const docNoCurrency = result.change.docs.find(
|
||||
(doc: any) => !doc._id.startsWith('cur')
|
||||
)
|
||||
if (docNoCurrency) {
|
||||
emit(SYNC)
|
||||
confirmation('Mise à jour...')
|
||||
}
|
||||
} else if (result.direction === 'push') {
|
||||
emit(SYNC)
|
||||
}
|
||||
})
|
||||
.on('error', (error: any) => {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn('on error', { error })
|
||||
if (error.name !== 'unauthorized') {
|
||||
notif.alert(`une erreur s'est produite`)
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
public async hasLocaleDoc(): Promise<boolean> {
|
||||
if (!this.db || this.db.name !== LOCALE_DB) {
|
||||
return false
|
||||
}
|
||||
const docs = await this.db.allDocs()
|
||||
return !!docs.total_rows
|
||||
}
|
||||
|
||||
public setUser(
|
||||
user: IUser | null,
|
||||
hexUser: string | null,
|
||||
replicateLocale: boolean
|
||||
): void {
|
||||
const newUser: boolean =
|
||||
!this.user || (!!user && this.user.userId !== user.userId)
|
||||
this.user = user
|
||||
if (user) {
|
||||
if (newUser) {
|
||||
if (replicateLocale && this.db) {
|
||||
const vaquantDb = new PouchDb(`vaquant-${user.userId}`)
|
||||
this.db.replicate.to(vaquantDb).on('complete', async () => {
|
||||
if (this.db) {
|
||||
await this.db.destroy()
|
||||
this.db = vaquantDb
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.db = new PouchDb(`vaquant-${user.userId}`)
|
||||
}
|
||||
}
|
||||
// this.remoteUserDb = hexUser
|
||||
// ? new PouchDb(`${REMOTE}/userdb-${hexUser}`, remoteConfig)
|
||||
// : null
|
||||
// this.userDb = hexUser ? new PouchDb(`userdb-${hexUser}`) : null
|
||||
this.initLive()
|
||||
} else {
|
||||
this.cancelSync()
|
||||
}
|
||||
}
|
||||
|
||||
public get online(): boolean {
|
||||
return navigator.onLine
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<any | null> {
|
||||
if (!this.db) {
|
||||
return null
|
||||
}
|
||||
return await this.db.get(id)
|
||||
}
|
||||
|
||||
public async getRemote(id: string): Promise<any | null> {
|
||||
if (!this.remote) {
|
||||
return null
|
||||
}
|
||||
return await this.remote.get(id)
|
||||
}
|
||||
|
||||
public async getByPrefix<T>(
|
||||
prefix: string
|
||||
): Promise<PouchDB.Core.AllDocsResponse<T>> {
|
||||
if (!this.db) {
|
||||
return {
|
||||
offset: 0,
|
||||
total_rows: 0,
|
||||
rows: []
|
||||
}
|
||||
}
|
||||
return (await this.db.allDocs({
|
||||
include_docs: true,
|
||||
startkey: prefix,
|
||||
endkey: `${prefix}\ufff0`
|
||||
})) as PouchDB.Core.AllDocsResponse<T>
|
||||
}
|
||||
|
||||
public async query(query: string): Promise<any> {
|
||||
if (!this.db) {
|
||||
return []
|
||||
}
|
||||
const response = await this.db.query(query, {
|
||||
include_docs: true
|
||||
})
|
||||
return response.rows.map((row: any) => row.doc)
|
||||
}
|
||||
|
||||
public async save(doc: any): Promise<PouchDB.Core.Response> {
|
||||
if (!this.db) {
|
||||
return {
|
||||
id: '',
|
||||
rev: '',
|
||||
ok: false
|
||||
}
|
||||
}
|
||||
const response = await this.db.put(doc)
|
||||
return response
|
||||
}
|
||||
|
||||
public async multipleSave(
|
||||
docs: any[]
|
||||
): Promise<Array<PouchDB.Core.Response | PouchDB.Core.Error>> {
|
||||
if (!this.db) {
|
||||
return []
|
||||
}
|
||||
const response = await this.db.bulkDocs(docs)
|
||||
return response
|
||||
}
|
||||
|
||||
public async retrievePassword(id: string, user: IUser): Promise<boolean> {
|
||||
if (!this.userDb || !this.remoteUserDb) {
|
||||
return false
|
||||
}
|
||||
if (this.userDb.get(id)) {
|
||||
return true
|
||||
}
|
||||
const response = await axios.post(GET_KEY_URL, {
|
||||
accountId: id,
|
||||
username: user.userId,
|
||||
salt: user.salt
|
||||
})
|
||||
if (response.data) {
|
||||
const passwordDoc: IEncryptPassword = {
|
||||
_id: id,
|
||||
password: response.data
|
||||
}
|
||||
const putResponse = await this.userDb.put(passwordDoc)
|
||||
if (putResponse.ok) {
|
||||
await this.userDb.sync(this.remoteUserDb)
|
||||
}
|
||||
return putResponse.ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public async encrypt(
|
||||
id: string,
|
||||
doc: IEncryptable,
|
||||
savePassword: boolean = false,
|
||||
usernames: string[] = []
|
||||
): Promise<IEncrypted | null> {
|
||||
try {
|
||||
if (!this.userDb) {
|
||||
return null
|
||||
}
|
||||
let passwordDoc: IEncryptPassword | null = null
|
||||
try {
|
||||
passwordDoc = (await this.userDb.get(id)) as IEncryptPassword
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn('encrypt retrieve password', { error, savePassword })
|
||||
}
|
||||
if (!passwordDoc && savePassword) {
|
||||
try {
|
||||
passwordDoc = await cryptoService.createPassword()
|
||||
passwordDoc._id = id
|
||||
await this.userDb.put(passwordDoc)
|
||||
if (this.remoteUserDb) {
|
||||
this.userDb.sync(this.remoteUserDb).then(() => {
|
||||
if (!passwordDoc || !usernames.length || !this.user) {
|
||||
return
|
||||
}
|
||||
axios.post(SET_KEY_URL, {
|
||||
accountId: id,
|
||||
password: passwordDoc.password,
|
||||
usernames,
|
||||
username: this.user.userId,
|
||||
salt: this.user.salt
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn('encrypt and save password', { error, savePassword })
|
||||
}
|
||||
}
|
||||
if (!passwordDoc) {
|
||||
return doc
|
||||
}
|
||||
return await cryptoService.encrypt(doc, passwordDoc.password)
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn('encrypt and save', { error })
|
||||
return doc
|
||||
}
|
||||
}
|
||||
|
||||
public async decrypt(
|
||||
id: string,
|
||||
doc: IEncrypted
|
||||
): Promise<IEncryptable | null> {
|
||||
try {
|
||||
if (!this.userDb) {
|
||||
return doc
|
||||
}
|
||||
const passwordDoc = (await this.userDb.get(id)) as IEncryptPassword
|
||||
if (!passwordDoc) {
|
||||
return doc
|
||||
}
|
||||
return await cryptoService.decrypt(doc, passwordDoc.password)
|
||||
} catch (error) {
|
||||
return doc
|
||||
}
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<boolean> {
|
||||
if (!this.db) {
|
||||
return false
|
||||
}
|
||||
const doc = await this.db.get(id)
|
||||
if (doc) {
|
||||
const response = await this.save({
|
||||
...doc,
|
||||
_deleted: true
|
||||
})
|
||||
if (this.userDb) {
|
||||
const userDb = this.userDb
|
||||
userDb
|
||||
.get(id)
|
||||
.then((password: any) => {
|
||||
if (password) {
|
||||
userDb.put({
|
||||
...password,
|
||||
_deleted: true
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn('remove password', { error })
|
||||
})
|
||||
}
|
||||
return response.ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public newId(...prefixes: string[]): string {
|
||||
return `${prefixes.join('-')}${Date.now().toString()}`
|
||||
}
|
||||
|
||||
public async purgeDb(): Promise<void> {
|
||||
if (this.user) {
|
||||
if (this.db) {
|
||||
await this.db.destroy()
|
||||
this.db = null
|
||||
}
|
||||
if (this.userDb) {
|
||||
await this.userDb.destroy()
|
||||
this.userDb = null
|
||||
}
|
||||
const hexUser: string = toHex(this.user.userId)
|
||||
this.setUser(this.user, hexUser, false)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelSync(): any {
|
||||
if (this.sync) {
|
||||
this.sync.cancel()
|
||||
this.sync = null
|
||||
}
|
||||
if (this.userSync) {
|
||||
this.userSync.cancel()
|
||||
this.userSync = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new CouchService()
|
||||
55
src/services/CryptoService.ts
Normal file
55
src/services/CryptoService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import IEncryptable from '@/models/IEncryptable'
|
||||
import IEncryptPassword from '@/models/IEncryptPassword'
|
||||
import AES from 'crypto-js/aes'
|
||||
import utf8 from 'crypto-js/enc-utf8'
|
||||
|
||||
const encrypting: boolean = false
|
||||
|
||||
class CryptoService {
|
||||
public createPassword(): IEncryptPassword {
|
||||
return {
|
||||
password: this.guid()
|
||||
}
|
||||
}
|
||||
|
||||
public encrypt(toEncrypt: IEncryptable, password: string): IEncryptable {
|
||||
if (!encrypting) {
|
||||
return toEncrypt
|
||||
}
|
||||
const encrypted: IEncryptable = {}
|
||||
for (const t in toEncrypt) {
|
||||
if (t) {
|
||||
encrypted[t] = AES.encrypt(
|
||||
toEncrypt[t] && toEncrypt[t].toString(),
|
||||
password
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
public decrypt(encrypted: IEncryptable, password: string): IEncryptable {
|
||||
if (!encrypting) {
|
||||
return encrypted
|
||||
}
|
||||
const decrypted: IEncryptable = {}
|
||||
for (const t in encrypted) {
|
||||
if (t && encrypted[t]) {
|
||||
const bytes = AES.decrypt(encrypted[t], password)
|
||||
decrypted[t] = bytes.toString(utf8)
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
private guid(): string {
|
||||
const s4 = () => {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1)
|
||||
}
|
||||
return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}`
|
||||
}
|
||||
}
|
||||
|
||||
export default new CryptoService()
|
||||
23
src/services/EmailService.ts
Normal file
23
src/services/EmailService.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
declare const Email: any
|
||||
import '@/utils/email'
|
||||
import IUser from '@/models/IUser'
|
||||
|
||||
class EmailService {
|
||||
public sendActivation(
|
||||
user: IUser,
|
||||
callback: (message: string) => void
|
||||
): void {
|
||||
Email.send(
|
||||
'juliencalixte@gmail.com',
|
||||
user.email,
|
||||
'Welcome to Vaquant',
|
||||
'Body',
|
||||
{
|
||||
token: 'e085d2ab-6be2-4c69-bc85-8d787893e4fc',
|
||||
callback
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default new EmailService()
|
||||
92
src/services/ExchangeService.ts
Normal file
92
src/services/ExchangeService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import IExchange from '@/models/IExchange'
|
||||
import couchService from '@/services/CouchService'
|
||||
import formatDate from '@/utils/format-date'
|
||||
|
||||
class ExchangeService {
|
||||
private baseUrl: string = 'https://api.exchangeratesapi.io/'
|
||||
|
||||
/**
|
||||
* Get an exchange with defined date and desired currencies
|
||||
* @param base currency of reference
|
||||
* @param date date of the transaction
|
||||
* @param rates multiple currencies needed
|
||||
*/
|
||||
public async get(
|
||||
base: string = '',
|
||||
date: Date | null = null,
|
||||
rates: string[] = []
|
||||
): Promise<IExchange | null> {
|
||||
const formattedDate: string = formatDate(date || new Date())
|
||||
const needFetch = rates.length > 1
|
||||
let couchResponse: any | null = null
|
||||
try {
|
||||
const id: string = needFetch
|
||||
? `cur-${base}-${formattedDate}`
|
||||
: `cur-${base}`
|
||||
try {
|
||||
couchResponse = await couchService.get(id)
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line
|
||||
console.info({ error })
|
||||
}
|
||||
if (!couchResponse) {
|
||||
let exchange: IExchange | null = null
|
||||
// No need to fetch currencies if only one money
|
||||
if (!needFetch) {
|
||||
exchange = {
|
||||
base,
|
||||
date: formattedDate,
|
||||
rates: {
|
||||
[base]: 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const url = new URL(
|
||||
date ? `${this.baseUrl}${formattedDate}` : 'latest'
|
||||
)
|
||||
if (base) {
|
||||
url.searchParams.append('base', base)
|
||||
}
|
||||
const response = await fetch(url.toString())
|
||||
if (response.ok) {
|
||||
exchange = await response.json()
|
||||
}
|
||||
}
|
||||
if (exchange) {
|
||||
exchange = {
|
||||
...exchange,
|
||||
doctype: 'exchange',
|
||||
_id: id
|
||||
}
|
||||
exchange.rates[base] = 1
|
||||
couchService.save(exchange)
|
||||
return exchange
|
||||
}
|
||||
}
|
||||
return couchResponse
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line
|
||||
console.info({ error })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get amount in base with exchange.
|
||||
* @param amount Raw amount
|
||||
* @param toCurrency Currency in which it need to be converted
|
||||
* @param exchange Exchange for the date and currencies
|
||||
*/
|
||||
public getAmountInBase(
|
||||
amount: number,
|
||||
toCurrency: string,
|
||||
exchange: IExchange | null
|
||||
): number {
|
||||
if (!exchange || !(toCurrency in exchange.rates)) {
|
||||
return amount
|
||||
}
|
||||
return amount * exchange.rates[toCurrency]
|
||||
}
|
||||
}
|
||||
|
||||
export default new ExchangeService()
|
||||
32
src/services/MapService.ts
Normal file
32
src/services/MapService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import ILocation from '@/models/ILocation'
|
||||
import ILocationQuery from '@/models/ILocationQuery'
|
||||
|
||||
class MapService {
|
||||
private key: string = process.env.VUE_APP_MAP_KEY || ''
|
||||
|
||||
public async getLocation(location: ILocation): Promise<string> {
|
||||
const result = await fetch(this.url(location.lat, location.lon))
|
||||
if (!result) {
|
||||
return ''
|
||||
}
|
||||
const json: ILocationQuery = await result.json()
|
||||
if (
|
||||
!json ||
|
||||
!json.resourceSets ||
|
||||
!json.resourceSets.length ||
|
||||
!json.resourceSets[0].resources.length
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
const address = json.resourceSets[0].resources[0].address
|
||||
return address ? address.locality : ''
|
||||
}
|
||||
|
||||
private url(latitude: number, longitude: number) {
|
||||
return `https://dev.virtualearth.net/REST/v1/Locations/${latitude},${longitude}?key=${
|
||||
this.key
|
||||
}`
|
||||
}
|
||||
}
|
||||
|
||||
export default new MapService()
|
||||
187
src/services/TransactionService.ts
Normal file
187
src/services/TransactionService.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import couchService from '@/services/CouchService'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import TransactionTag from '@/enums/TransactionTag'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import ISplit from '@/models/ISplit'
|
||||
import IUser from '@/models/IUser'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import IEncryptable from '@/models/IEncryptable'
|
||||
import IEncrypted from '@/models/IEncrypted'
|
||||
import { getCurrentPosition } from '@/utils'
|
||||
import mapService from './MapService'
|
||||
|
||||
class TransactionService {
|
||||
public async getNew(
|
||||
user: IUser | null,
|
||||
account: IAccount
|
||||
): Promise<ITransaction> {
|
||||
const current: IUser | undefined = account.users.find(
|
||||
(u: IUser) => !!(user && u.email === user.email)
|
||||
)
|
||||
const transaction: ITransaction = {
|
||||
name: '',
|
||||
accountId: account._id || '',
|
||||
date: new Date(),
|
||||
amount: 0,
|
||||
payBy: current ? current.alias || '' : '',
|
||||
payFor: account.users.map((u: IUser) => ({
|
||||
alias: u.alias || '',
|
||||
weight: 1
|
||||
})),
|
||||
mainCurrency: account.mainCurrency,
|
||||
currencies: account.currencies,
|
||||
userIds: [],
|
||||
doctype: 'transaction',
|
||||
tag: TransactionTag.None,
|
||||
transactionType: TransactionType.normal,
|
||||
exchange: null,
|
||||
location: {
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
place: ''
|
||||
}
|
||||
}
|
||||
return transaction
|
||||
}
|
||||
|
||||
public getMultiply(transaction: ITransaction, user: string): number {
|
||||
const userSplit: ISplit | undefined = transaction.payFor.find(
|
||||
(p) => p.alias === user
|
||||
)
|
||||
return userSplit ? userSplit.weight : 1
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<ITransaction | null> {
|
||||
const transaction: ITransaction = await couchService.get(id)
|
||||
return await this.decrypt(transaction)
|
||||
}
|
||||
|
||||
public async add(
|
||||
accountId: string,
|
||||
transaction: ITransaction
|
||||
): Promise<PouchDB.Core.Response> {
|
||||
try {
|
||||
transaction._id = couchService.newId('tra', accountId)
|
||||
transaction.doctype = 'transaction'
|
||||
transaction = await this.encrypt(transaction)
|
||||
return await couchService.save(transaction)
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn({ error })
|
||||
return {
|
||||
ok: false,
|
||||
id: '',
|
||||
rev: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async save(transaction: ITransaction): Promise<PouchDB.Core.Response> {
|
||||
return await couchService.save(transaction)
|
||||
}
|
||||
|
||||
public async multipleSave(
|
||||
transactions: ITransaction[]
|
||||
): Promise<Array<PouchDB.Core.Response | PouchDB.Core.Error>> {
|
||||
return await couchService.multipleSave(transactions)
|
||||
}
|
||||
|
||||
public async getAllByAccountId(accountId: string): Promise<ITransaction[]> {
|
||||
const response: PouchDB.Core.AllDocsResponse<
|
||||
ITransaction
|
||||
> = await couchService.getByPrefix<ITransaction>(`tra-${accountId}`)
|
||||
const transactions = response.rows.map((row) => row.doc) as ITransaction[]
|
||||
return transactions.sort((a: ITransaction, b: ITransaction) =>
|
||||
a.date <= b.date ? -1 : 1
|
||||
)
|
||||
}
|
||||
|
||||
public getTotalCost(
|
||||
transactions: ITransaction[],
|
||||
currency: string,
|
||||
user: string | null = null
|
||||
): number {
|
||||
let n: number = 0
|
||||
const reduceFn = (value: number, transaction: ITransaction) => {
|
||||
const amount = exchangeService.getAmountInBase(
|
||||
transaction.amount,
|
||||
currency,
|
||||
transaction.exchange
|
||||
)
|
||||
return this.round(value + amount)
|
||||
}
|
||||
if (user) {
|
||||
const plus: number = transactions
|
||||
.filter((transaction: ITransaction) => transaction.payBy === user)
|
||||
.reduce(reduceFn, 0)
|
||||
const minus = transactions
|
||||
.filter(
|
||||
(transaction: ITransaction) =>
|
||||
transaction.transactionType === TransactionType.refund &&
|
||||
transaction.payFor.map((p: ISplit) => p.alias).includes(user)
|
||||
)
|
||||
.reduce(reduceFn, n)
|
||||
n = Math.abs(plus - minus)
|
||||
} else {
|
||||
n = transactions
|
||||
.filter(
|
||||
(transaction: ITransaction) =>
|
||||
transaction.transactionType === TransactionType.normal
|
||||
)
|
||||
.reduce(reduceFn, 0)
|
||||
}
|
||||
return this.round(n)
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<boolean> {
|
||||
return await couchService.remove(id)
|
||||
}
|
||||
|
||||
private round(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
private toEncrypt(transaction: ITransaction): IEncryptable | IEncrypted {
|
||||
if (!transaction) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
name: transaction.name,
|
||||
date: transaction.date,
|
||||
amount: transaction.amount,
|
||||
payBy: transaction.payBy,
|
||||
tag: transaction.tag
|
||||
}
|
||||
}
|
||||
|
||||
private async encrypt(transaction: ITransaction): Promise<ITransaction> {
|
||||
if (!transaction) {
|
||||
return transaction
|
||||
}
|
||||
const encrypt = await couchService.encrypt(
|
||||
transaction.accountId || '',
|
||||
this.toEncrypt(transaction)
|
||||
)
|
||||
return {
|
||||
...transaction,
|
||||
...encrypt
|
||||
}
|
||||
}
|
||||
|
||||
private async decrypt(transaction: ITransaction): Promise<ITransaction> {
|
||||
if (!transaction) {
|
||||
return transaction
|
||||
}
|
||||
const decrypt = await couchService.decrypt(
|
||||
transaction.accountId || '',
|
||||
this.toEncrypt(transaction)
|
||||
)
|
||||
return {
|
||||
...transaction,
|
||||
...decrypt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TransactionService()
|
||||
111
src/services/UserService.ts
Normal file
111
src/services/UserService.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import IUser from '@/models/IUser'
|
||||
import couchService from '@/services/CouchService'
|
||||
import IResponse from '@/models/IResponse'
|
||||
|
||||
class UserService {
|
||||
public async signup(user: IUser, password: string): Promise<IResponse> {
|
||||
try {
|
||||
const response = await couchService.remote.signUp(user.userId, password, {
|
||||
metadata: user
|
||||
})
|
||||
if (response.ok) {
|
||||
const login: IResponse = await this.login(user.userId, password)
|
||||
return login
|
||||
}
|
||||
return {
|
||||
ok: response.ok
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async login(userId: string, password: string): Promise<IResponse> {
|
||||
try {
|
||||
const response = await couchService.remote.logIn(userId, password)
|
||||
return {
|
||||
ok: response.ok
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* purge
|
||||
*/
|
||||
public async purge(): Promise<void> {
|
||||
await couchService.purgeDb()
|
||||
}
|
||||
|
||||
public async logout(): Promise<IResponse> {
|
||||
try {
|
||||
const { ok } = await couchService.remote.logOut()
|
||||
return {
|
||||
ok
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteUser(userId: string): Promise<IResponse> {
|
||||
try {
|
||||
const { ok } = await couchService.remote.deleteUser(userId)
|
||||
return {
|
||||
ok
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async getUser(current: IUser | null): Promise<IUser | null> {
|
||||
try {
|
||||
const session = await couchService.remote.getSession()
|
||||
if (session.ok && session.userCtx && session.userCtx.name) {
|
||||
if (current && session.userCtx.name === current.userId) {
|
||||
return current
|
||||
}
|
||||
const user: any = await couchService.remote.getUser(
|
||||
session.userCtx.name
|
||||
)
|
||||
return user as IUser
|
||||
}
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn({ error })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
public async updateUser(user: IUser): Promise<boolean> {
|
||||
try {
|
||||
const result = await couchService.remote.putUser(user.userId, {
|
||||
metadata: user
|
||||
})
|
||||
return result.ok
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn({ error })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public async hasAnonymousData(): Promise<boolean> {
|
||||
return await couchService.hasLocaleDoc()
|
||||
}
|
||||
}
|
||||
|
||||
export default new UserService()
|
||||
13
src/shims-tsx.d.ts
vendored
Normal file
13
src/shims-tsx.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import Vue, { VNode } from 'vue'
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
// tslint:disable no-empty-interface
|
||||
interface Element extends VNode {}
|
||||
// tslint:disable no-empty-interface
|
||||
interface ElementClass extends Vue {}
|
||||
interface IntrinsicElements {
|
||||
[elem: string]: any
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/shims-vue.d.ts
vendored
Normal file
10
src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module '*.vue' {
|
||||
import Vue from 'vue'
|
||||
export default Vue
|
||||
}
|
||||
declare module '@fortawesome/vue-fontawesome'
|
||||
declare module 'vue-click-outside'
|
||||
declare module 'lightness'
|
||||
declare module 'crypto-js/aes'
|
||||
declare module 'crypto-js/enc-utf8'
|
||||
declare module '@xkeshi/vue-qrcode'
|
||||
147
src/store.ts
Normal file
147
src/store.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import VuexPersistence from 'vuex-persist'
|
||||
import IUser from '@/models/IUser'
|
||||
import userService from '@/services/UserService'
|
||||
import couchService from '@/services/CouchService'
|
||||
import IResponse from '@/models/IResponse'
|
||||
import IUserPassword from '@/models/IUserPassword'
|
||||
import IIdPassword from '@/models/IEmailPassword'
|
||||
import notif from '@/utils/notif'
|
||||
import { hasGoodNetwork } from '@/utils/network'
|
||||
import { toHex } from './utils'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
const SET_LOCALE: string = 'SET_LOCALE'
|
||||
const SET_TITLE: string = 'SET_TITLE'
|
||||
const SET_USER: string = 'SET_USER'
|
||||
const ADD_PUBLIC_ACCOUNT: string = 'ADD_PUBLIC_ACCOUNT'
|
||||
const REMOVE_PUBLIC_ACCOUNT: string = 'REMOVE_PUBLIC_ACCOUNT'
|
||||
|
||||
interface IState {
|
||||
locale: string
|
||||
user: IUser | null
|
||||
hexUser: string | null
|
||||
title: string | null
|
||||
publicAccountIds: string[]
|
||||
}
|
||||
|
||||
export default new Vuex.Store<IState>({
|
||||
state: {
|
||||
locale: '',
|
||||
user: null,
|
||||
hexUser: null,
|
||||
title: null,
|
||||
publicAccountIds: []
|
||||
},
|
||||
getters: {
|
||||
locale: ({ locale }) => locale,
|
||||
user: ({ user }) => user,
|
||||
title: ({ title }) => title,
|
||||
username: (state: IState) =>
|
||||
state.user
|
||||
? state.user.firstname && state.user.lastname
|
||||
? `${state.user.firstname} ${state.user.lastname}`.trim()
|
||||
: state.user.email
|
||||
: '',
|
||||
isAppAdmin: (state: IState) =>
|
||||
state.user &&
|
||||
state.user.roles &&
|
||||
state.user.roles.find((role: string) => role === 'vaquant-admin'),
|
||||
publicAccountIds: ({ publicAccountIds }) => publicAccountIds
|
||||
},
|
||||
mutations: {
|
||||
[SET_LOCALE](store, locale: string) {
|
||||
store.locale = locale
|
||||
},
|
||||
[SET_TITLE](store, title: string | null) {
|
||||
store.title = title
|
||||
},
|
||||
[SET_USER](
|
||||
store,
|
||||
{ user, replicate }: { user: IUser; replicate?: boolean }
|
||||
) {
|
||||
store.user = user
|
||||
store.hexUser = user ? toHex(user.userId) : null
|
||||
couchService.setUser(user, store.hexUser, replicate || false)
|
||||
},
|
||||
[ADD_PUBLIC_ACCOUNT](store, { accountId }: { accountId: string }) {
|
||||
if (!accountId) {
|
||||
return
|
||||
}
|
||||
store.publicAccountIds = [
|
||||
...new Set([...store.publicAccountIds, accountId])
|
||||
]
|
||||
},
|
||||
[REMOVE_PUBLIC_ACCOUNT](store, { accountId }: { accountId: string }) {
|
||||
if (!accountId) {
|
||||
return
|
||||
}
|
||||
store.publicAccountIds = store.publicAccountIds.filter(
|
||||
(id: string) => id !== accountId && id !== null
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setLocale({ commit }, locale: string) {
|
||||
commit(SET_LOCALE, locale)
|
||||
},
|
||||
setTitle({ commit }, title: string) {
|
||||
commit(SET_TITLE, title)
|
||||
},
|
||||
async retrieveUser({ commit, state }) {
|
||||
let user = state.user
|
||||
if (navigator.onLine && hasGoodNetwork()) {
|
||||
user = await userService.getUser(user)
|
||||
}
|
||||
commit(SET_USER, { user, replicate: false })
|
||||
},
|
||||
async signup({ commit }, { user, password, replicate }: IUserPassword) {
|
||||
const response: IResponse = await userService.signup(user, password)
|
||||
if (response.ok) {
|
||||
commit(SET_USER, { user, replicate })
|
||||
} else {
|
||||
notif.alert(response.message)
|
||||
}
|
||||
},
|
||||
async login({ commit, state }, { userId, password }: IIdPassword) {
|
||||
const response: IResponse = await userService.login(userId, password)
|
||||
if (response.ok) {
|
||||
const user: IUser | null = await userService.getUser(state.user)
|
||||
commit(SET_USER, { user })
|
||||
} else {
|
||||
notif.alert(response.message)
|
||||
}
|
||||
},
|
||||
async logout({ commit }) {
|
||||
const response = await userService.logout()
|
||||
if (response.ok) {
|
||||
commit(SET_USER, { user: null })
|
||||
} else {
|
||||
notif.alert(response.message)
|
||||
}
|
||||
},
|
||||
async remove({ commit, state }) {
|
||||
if (state.user) {
|
||||
const response = await userService.deleteUser(state.user.userId)
|
||||
if (response.ok) {
|
||||
commit(SET_USER, { user: null })
|
||||
} else {
|
||||
notif.alert(response.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
addAccountId({ commit }, { accountId }: { accountId: string }) {
|
||||
commit(ADD_PUBLIC_ACCOUNT, { accountId })
|
||||
},
|
||||
removeAccountId({ commit }, { accountId }: { accountId: string }) {
|
||||
commit(REMOVE_PUBLIC_ACCOUNT, { accountId })
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new VuexPersistence<any>({
|
||||
storage: window.localStorage
|
||||
}).plugin
|
||||
]
|
||||
})
|
||||
3
src/styles/_notyf-colors.scss
Normal file
3
src/styles/_notyf-colors.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
|
||||
$red-notyf: #ed3d3d;
|
||||
$green-notyf: #3dc763;
|
||||
184
src/styles/index.scss
Normal file
184
src/styles/index.scss
Normal file
@@ -0,0 +1,184 @@
|
||||
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
|
||||
@charset 'utf-8';
|
||||
@import url('https://fonts.googleapis.com/css?family=Nunito|Raleway|Open+Sans&display=swap');
|
||||
@import './variables';
|
||||
@import '~bulma';
|
||||
@import '~bulma-pricingtable';
|
||||
@import './notyf';
|
||||
@import './transitions';
|
||||
|
||||
:root {
|
||||
--primary-color: #{$primary};
|
||||
--primary-font-color: #{$main};
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app {
|
||||
color: $main;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
&.nav {
|
||||
a {
|
||||
color: $main;
|
||||
font-weight: bold;
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app,
|
||||
a,
|
||||
button {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
.numeric {
|
||||
font-family: 'Cutive Mono', monospace;
|
||||
}
|
||||
|
||||
$navbar-color: #eaeaea4d;
|
||||
|
||||
.navbar-menu {
|
||||
.navbar-item {
|
||||
border: 2px solid $navbar-color;
|
||||
border-radius: 10px;
|
||||
margin: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.subtitle,
|
||||
tr {
|
||||
color: $main;
|
||||
font-family: 'Raleway', sans-serif;
|
||||
}
|
||||
|
||||
img {
|
||||
&.logo {
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
width: 180pt;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: auto;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 16pt;
|
||||
text-align: center;
|
||||
|
||||
ul {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.notyf {
|
||||
left: 30px;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.notyf {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes a {
|
||||
0% {
|
||||
bottom: -15px;
|
||||
margin-top: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
30% {
|
||||
bottom: -3px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: 0;
|
||||
margin-top: 12px;
|
||||
max-height: 200px;
|
||||
max-width: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.notyf__message {
|
||||
animation: a 0.3s forwards;
|
||||
animation-delay: 0.15s;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.notyf__toast {
|
||||
&.notyf--confirm {
|
||||
background-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.notyf__icon--confirm {
|
||||
&::after,
|
||||
&::before {
|
||||
background-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Service Worker update
|
||||
.notify-worker {
|
||||
background-color: $main;
|
||||
border-radius: 2px;
|
||||
bottom: 30px;
|
||||
color: $white;
|
||||
margin: auto;
|
||||
max-width: 450px;
|
||||
min-width: 250px;
|
||||
padding: 16px;
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
visibility: hidden;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
|
||||
&.show {
|
||||
animation: fadein 0.5s;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
.total {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: 30px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
196
src/styles/notyf.scss
Normal file
196
src/styles/notyf.scss
Normal file
@@ -0,0 +1,196 @@
|
||||
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
|
||||
@import './notyf-colors';
|
||||
@import './variables';
|
||||
@keyframes fadeinup {
|
||||
0% {
|
||||
bottom: -15px;
|
||||
margin-top: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
bottom: -3px;
|
||||
opacity: .8;
|
||||
}
|
||||
100% {
|
||||
bottom: 0;
|
||||
margin-top: 12px;
|
||||
max-height: 200px;
|
||||
max-width: 420px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeoutdown {
|
||||
0% {
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
30% {
|
||||
bottom: -3px;
|
||||
opacity: .2;
|
||||
}
|
||||
100% {
|
||||
bottom: -15px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: .5;
|
||||
}
|
||||
100% {
|
||||
opacity: .6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes disappear {
|
||||
0% {
|
||||
opacity: .6;
|
||||
}
|
||||
30% {
|
||||
opacity: .1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notyf__icon--alert,
|
||||
.notyf__icon--confirm {
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
height: 21px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
width: 21px;
|
||||
}
|
||||
|
||||
.notyf__icon--alert {
|
||||
&::after,
|
||||
&::before {
|
||||
background: $red-notyf;
|
||||
border-radius: 3px;
|
||||
content: '';
|
||||
display: block;
|
||||
left: 9px;
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
}
|
||||
&::after {
|
||||
height: 3px;
|
||||
top: 14px;
|
||||
}
|
||||
&::before {
|
||||
height: 8px;
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.notyf__icon--confirm {
|
||||
&::after,
|
||||
&::before {
|
||||
background: $primary;
|
||||
border-radius: 3px;
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
}
|
||||
&::after {
|
||||
height: 6px;
|
||||
left: 6px;
|
||||
top: 9px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
&::before {
|
||||
height: 11px;
|
||||
left: 10px;
|
||||
top: 5px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.notyf__toast {
|
||||
animation: fadeinup .3s forwards;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .45);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
padding-right: 13px;
|
||||
position: relative;
|
||||
&.notyf--alert {
|
||||
background: $red-notyf;
|
||||
}
|
||||
&.notyf--confirm {
|
||||
background: $primary;
|
||||
}
|
||||
&.notyf--disappear {
|
||||
animation: fadeoutdown .3s 1 forwards;
|
||||
animation-delay: .25s;
|
||||
.notyf__message {
|
||||
animation: fadeoutdown .3s 1 forwards;
|
||||
animation-delay: .1s;
|
||||
opacity: 1;
|
||||
}
|
||||
.notyf__icon {
|
||||
animation: disappear .3s 1 forwards;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notyf__wrapper {
|
||||
border-radius: 3px;
|
||||
display: table;
|
||||
padding-bottom: 20px;
|
||||
padding-right: 15px;
|
||||
padding-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notyf__icon {
|
||||
animation: appear .5s forwards;
|
||||
animation-delay: .25s;
|
||||
display: table-cell;
|
||||
font-size: 1.3em;
|
||||
opacity: 0;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.notyf__message {
|
||||
animation: fadeinup .3s forwards;
|
||||
animation-delay: .15s;
|
||||
display: table-cell;
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.notyf {
|
||||
bottom: 20px;
|
||||
color: white;
|
||||
position: fixed;
|
||||
right: 30px;
|
||||
width: 20%;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
// Small screens
|
||||
@media only screen and (max-width: 736px) {
|
||||
.notyf__container {
|
||||
display: block;
|
||||
left: 0;
|
||||
margin: 0 auto;
|
||||
right: 0;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
12
src/styles/transitions.scss
Normal file
12
src/styles/transitions.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
// sass-lint:disable no-transition-all final-newline
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition-duration: .1s;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
6
src/styles/variables.scss
Normal file
6
src/styles/variables.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
|
||||
$primary: #3f4fa6;
|
||||
$main: #2c3e50;
|
||||
$green: hsl(141, 63%, 44%);
|
||||
$red: hsl(348, 54%, 52%);
|
||||
$white: #fff;
|
||||
7
src/utils/bus-event.ts
Normal file
7
src/utils/bus-event.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
export default new Vue()
|
||||
|
||||
export const ONLINE: string = 'ONLINE'
|
||||
export const OFFLINE: string = 'OFFLINE'
|
||||
export const SYNC: string = 'SYNC'
|
||||
2
src/utils/constants.ts
Normal file
2
src/utils/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const COFFEE_LINK = 'https://ko-fi.com/juliencalixte'
|
||||
export const BRAVE_LINK = 'https://brave.com/vaq785'
|
||||
48
src/utils/filters.ts
Normal file
48
src/utils/filters.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
|
||||
export const money = (
|
||||
value: number,
|
||||
currency: ICurrency | null | undefined,
|
||||
country: string = 'fr-FR'
|
||||
): string => {
|
||||
if (!currency) {
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(country, {
|
||||
style: 'currency',
|
||||
currency: currency.code,
|
||||
minimumFractionDigits: 2
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export const moneypad = (
|
||||
value: number,
|
||||
currency: ICurrency | null | undefined
|
||||
): string => {
|
||||
const m = money(value, currency)
|
||||
const s: string[] = m.split('\xa0')
|
||||
const cur: string | undefined = s.pop()
|
||||
|
||||
return `${s.join('\xa0')}\xa0${(cur || '').padEnd(3, '\xa0')}`
|
||||
}
|
||||
|
||||
export const date = (value: string, country: string = 'fr-FR') => {
|
||||
return new Intl.DateTimeFormat(country).format(new Date(value))
|
||||
}
|
||||
|
||||
export const fulldate = (value: string, country: string = 'fr-FR') => {
|
||||
return new Intl.DateTimeFormat(country, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
export default {
|
||||
money,
|
||||
moneypad,
|
||||
date,
|
||||
fulldate
|
||||
} as { [key: string]: (...args: any[]) => any }
|
||||
6
src/utils/format-date.ts
Normal file
6
src/utils/format-date.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default (date: Date | string): string => {
|
||||
if (date instanceof Date) {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
return date.split('T')[0]
|
||||
}
|
||||
3
src/utils/hooks.ts
Normal file
3
src/utils/hooks.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Component } from 'vue-property-decorator'
|
||||
|
||||
Component.registerHooks(['beforeRouteEnter', 'beforeRouteUpdate'])
|
||||
76
src/utils/icons.ts
Normal file
76
src/utils/icons.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import Vue from 'vue'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faBalanceScale,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faCog,
|
||||
faCircle,
|
||||
faClock,
|
||||
faToriiGate,
|
||||
faUtensils,
|
||||
faSubway,
|
||||
faHome,
|
||||
faGift,
|
||||
faDollarSign,
|
||||
faEuroSign,
|
||||
faExchangeAlt,
|
||||
faInbox,
|
||||
faEquals,
|
||||
faMinus,
|
||||
faInfinity,
|
||||
faHandHoldingUsd,
|
||||
faShoppingBasket,
|
||||
faMobile,
|
||||
faMoneyBillWave,
|
||||
faPenSquare,
|
||||
faPlus,
|
||||
faTag,
|
||||
faSignal,
|
||||
faSyncAlt,
|
||||
faUser,
|
||||
faUsers,
|
||||
faUserLock,
|
||||
faTimes,
|
||||
faTheaterMasks
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
library.add(
|
||||
faBalanceScale,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faCog,
|
||||
faCircle,
|
||||
faClock,
|
||||
faToriiGate,
|
||||
faUtensils,
|
||||
faSubway,
|
||||
faHome,
|
||||
faGift,
|
||||
faDollarSign,
|
||||
faEuroSign,
|
||||
faExchangeAlt,
|
||||
faInbox,
|
||||
faEquals,
|
||||
faMinus,
|
||||
faInfinity,
|
||||
faHandHoldingUsd,
|
||||
faShoppingBasket,
|
||||
faMobile,
|
||||
faMoneyBillWave,
|
||||
faPenSquare,
|
||||
faPlus,
|
||||
faTag,
|
||||
faSignal,
|
||||
faSyncAlt,
|
||||
faUser,
|
||||
faUsers,
|
||||
faUserLock,
|
||||
faTimes,
|
||||
faTheaterMasks
|
||||
)
|
||||
|
||||
Vue.component('awe-icon', FontAwesomeIcon)
|
||||
66
src/utils/index.ts
Normal file
66
src/utils/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { debounce } from 'lodash-es'
|
||||
import notif from '@/utils/notif'
|
||||
import IColor from '@/models/IColor'
|
||||
import colors from '@/data/colors'
|
||||
|
||||
export const confirmation = debounce(
|
||||
(message: string) => {
|
||||
notif.confirm(message)
|
||||
},
|
||||
3000,
|
||||
{
|
||||
leading: true
|
||||
}
|
||||
)
|
||||
|
||||
export const alertMessage = debounce(
|
||||
(message: string) => {
|
||||
notif.alert(message)
|
||||
},
|
||||
3000,
|
||||
{
|
||||
leading: true
|
||||
}
|
||||
)
|
||||
|
||||
export const primary: string = '#3f4fa6'
|
||||
export const main: string = '#2c3e50'
|
||||
|
||||
export const slug = (text: string): string => {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
return text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-') // Replace spaces with -
|
||||
.replace(/[^\w\-]+/g, '') // Remove all non-word chars
|
||||
.replace(/\-\-+/g, '-') // Replace multiple - with single -
|
||||
.replace(/^-+/, '') // Trim - from start of text
|
||||
.replace(/-+$/, '') // Trim - from end of text
|
||||
}
|
||||
|
||||
export const findDarkValue = (value: string): string | null => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const find: IColor | undefined = colors.find(
|
||||
(color: IColor) => color.value === value
|
||||
)
|
||||
return find && find.darkValue ? find.darkValue : null
|
||||
}
|
||||
|
||||
export const toHex = (text: string): string => {
|
||||
return (
|
||||
[...text]
|
||||
// tslint:disable-next-line: variable-name
|
||||
.map((_c: string, i: number) => Number(text.charCodeAt(i)).toString(16))
|
||||
.join('')
|
||||
)
|
||||
}
|
||||
|
||||
export const getCurrentPosition = (options = {}): Promise<Position> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, options)
|
||||
})
|
||||
}
|
||||
21
src/utils/network.ts
Normal file
21
src/utils/network.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
declare var navigator: any
|
||||
|
||||
export const hasGoodNetwork = (): boolean => {
|
||||
const connection =
|
||||
navigator.connection ||
|
||||
navigator.mozConnection ||
|
||||
navigator.webkitConnection
|
||||
|
||||
if (connection) {
|
||||
if (connection.effectiveType) {
|
||||
const goodNetworks: string[] = ['3g', '4g']
|
||||
return goodNetworks.includes(connection.effectiveType)
|
||||
} else {
|
||||
const highBandwidth: boolean =
|
||||
connection.metered && (connection.bandwidth || 0) > 2
|
||||
return highBandwidth
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
3
src/utils/notif.ts
Normal file
3
src/utils/notif.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const Notyf: any
|
||||
|
||||
export default new Notyf()
|
||||
125
src/utils/notyf.js
Normal file
125
src/utils/notyf.js
Normal file
@@ -0,0 +1,125 @@
|
||||
(function () {
|
||||
var $this = window
|
||||
$this.Notyf = function () {
|
||||
//List of notifications currently active
|
||||
this.notifications = [];
|
||||
|
||||
var defaults = {
|
||||
delay: 2000,
|
||||
alertIcon: 'notyf__icon--alert',
|
||||
confirmIcon: 'notyf__icon--confirm'
|
||||
}
|
||||
|
||||
if (arguments[0] && typeof arguments[0] == "object") {
|
||||
this.options = extendDefaults(defaults, arguments[0]);
|
||||
} else {
|
||||
this.options = defaults;
|
||||
}
|
||||
|
||||
//Creates the main notifications container
|
||||
var docFrag = document.createDocumentFragment();
|
||||
var notyfContainer = document.createElement('div');
|
||||
notyfContainer.className = 'notyf';
|
||||
docFrag.appendChild(notyfContainer);
|
||||
document.body.appendChild(docFrag);
|
||||
this.container = notyfContainer;
|
||||
|
||||
//Stores which transitionEnd event this browser supports
|
||||
this.animationEnd = animationEndSelect();
|
||||
}
|
||||
|
||||
//---------- Public methods ---------------
|
||||
/**
|
||||
* Shows an alert card
|
||||
*/
|
||||
$this.Notyf.prototype.alert = function (alertMessage) {
|
||||
var card = buildNotificationCard.call(this, alertMessage, this.options.alertIcon);
|
||||
card.className += ' notyf--alert';
|
||||
this.container.appendChild(card);
|
||||
this.notifications.push(card);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a confirm card
|
||||
*/
|
||||
$this.Notyf.prototype.confirm = function (alertMessage) {
|
||||
var card = buildNotificationCard.call(this, alertMessage, this.options.confirmIcon);
|
||||
card.className += ' notyf--confirm';
|
||||
this.container.appendChild(card);
|
||||
this.notifications.push(card);
|
||||
}
|
||||
|
||||
//---------- Private methods ---------------
|
||||
|
||||
/**
|
||||
* Populates the source object with the value from the same keys found in destination
|
||||
*/
|
||||
function extendDefaults(source, destination) {
|
||||
for (const property in destination) {
|
||||
//Avoid asigning inherited properties of destination, only asign to source the destination own properties
|
||||
if (destination.hasOwnProperty(property)) {
|
||||
source[property] = destination[property];
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a generic card with the param message. Returns a document fragment.
|
||||
*/
|
||||
function buildNotificationCard(messageText, iconClass) {
|
||||
//Card wrapper
|
||||
var notification = document.createElement('div');
|
||||
notification.className = 'notyf__toast';
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'notyf__wrapper';
|
||||
|
||||
var iconContainer = document.createElement('div');
|
||||
iconContainer.className = 'notyf__icon';
|
||||
|
||||
var icon = document.createElement('i');
|
||||
icon.className = iconClass;
|
||||
|
||||
var message = document.createElement('div');
|
||||
message.className = 'notyf__message';
|
||||
message.innerHTML = messageText;
|
||||
|
||||
//Build the card
|
||||
iconContainer.appendChild(icon);
|
||||
wrapper.appendChild(iconContainer);
|
||||
wrapper.appendChild(message);
|
||||
notification.appendChild(wrapper);
|
||||
|
||||
var _this = this;
|
||||
setTimeout(function () {
|
||||
notification.className += " notyf--disappear";
|
||||
notification.addEventListener(_this.animationEnd, function (event) {
|
||||
event.target == notification && _this.container.removeChild(notification);
|
||||
});
|
||||
var index = _this.notifications.indexOf(notification);
|
||||
_this.notifications.splice(index, 1);
|
||||
}, _this.options.delay);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
// Determine which animationend event is supported
|
||||
function animationEndSelect() {
|
||||
var t;
|
||||
var el = document.createElement('fake');
|
||||
var transitions = {
|
||||
'transition': 'animationend',
|
||||
'OTransition': 'oAnimationEnd',
|
||||
'MozTransition': 'animationend',
|
||||
'WebkitTransition': 'webkitAnimationEnd'
|
||||
}
|
||||
|
||||
for (t in transitions) {
|
||||
if (el.style[t] !== undefined) {
|
||||
return transitions[t];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
12
src/utils/serverless-url.ts
Normal file
12
src/utils/serverless-url.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const ROOT: string =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'https://vaquant.azurewebsites.net/api'
|
||||
: 'http://localhost:7071/api'
|
||||
|
||||
const setAccountKeyCode: string =
|
||||
'fIisMFMT5v0bUKlpJloiv6NomppqEXE06rB9/c04anacRuqTCSFgWw=='
|
||||
export const SET_KEY_URL: string = `${ROOT}/setaccountkey?code=${setAccountKeyCode}`
|
||||
|
||||
const getAccountKeyCode: string =
|
||||
'k1x7Ct7EnShWttLoUHbsRM0T2lN23GDwzvsAsTSqBf5qMINQGxOeYQ=='
|
||||
export const GET_KEY_URL: string = `${ROOT}/getaccountkey?code=${getAccountKeyCode}`
|
||||
2
src/utils/smtp.js
Normal file
2
src/utils/smtp.js
Normal file
@@ -0,0 +1,2 @@
|
||||
/* SmtpJS.com - v2.0.1 */
|
||||
Email = { send: function (e, o, t, n, a, s, r, c) { var d = Math.floor(1e6 * Math.random() + 1), i = "From=" + e; i += "&to=" + o, i += "&Subject=" + encodeURIComponent(t), i += "&Body=" + encodeURIComponent(n), void 0 == a.token ? (i += "&Host=" + a, i += "&Username=" + s, i += "&Password=" + r, i += "&Action=Send") : (i += "&SecureToken=" + a.token, i += "&Action=SendFromStored", c = a.callback), i += "&cachebuster=" + d, Email.ajaxPost("https://smtpjs.com/v2/smtp.aspx?", i, c) }, sendWithAttachment: function (e, o, t, n, a, s, r, c, d) { var i = Math.floor(1e6 * Math.random() + 1), m = "From=" + e; m += "&to=" + o, m += "&Subject=" + encodeURIComponent(t), m += "&Body=" + encodeURIComponent(n), m += "&Attachment=" + encodeURIComponent(c), void 0 == a.token ? (m += "&Host=" + a, m += "&Username=" + s, m += "&Password=" + r, m += "&Action=Send") : (m += "&SecureToken=" + a.token, m += "&Action=SendFromStored"), m += "&cachebuster=" + i, Email.ajaxPost("https://smtpjs.com/v2/smtp.aspx?", m, d) }, ajaxPost: function (e, o, t) { var n = Email.createCORSRequest("POST", e); n.setRequestHeader("Content-type", "application/x-www-form-urlencoded"), n.onload = function () { var e = n.responseText; void 0 != t && t(e) }, n.send(o) }, ajax: function (e, o) { var t = Email.createCORSRequest("GET", e); t.onload = function () { var e = t.responseText; void 0 != o && o(e) }, t.send() }, createCORSRequest: function (e, o) { var t = new XMLHttpRequest; return "withCredentials" in t ? t.open(e, o, !0) : "undefined" != typeof XDomainRequest ? (t = new XDomainRequest).open(e, o) : t = null, t } };
|
||||
29
src/views/About.vue
Normal file
29
src/views/About.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<img class="logo" src="../assets/logo.svg" title="logo Vaquant" >
|
||||
<p class="simple-description" v-t="'about.description'"></p>
|
||||
<hr>
|
||||
<app-resume />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'app-resume': () => import('@/components/AppResume.vue')
|
||||
}
|
||||
})
|
||||
export default class About extends Vue {}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.simple-description {
|
||||
max-width: 450pt;
|
||||
margin: auto;
|
||||
font-size: 16pt;
|
||||
text-align: justify;
|
||||
text-align-last: center;
|
||||
}
|
||||
</style>
|
||||
48
src/views/Home.vue
Normal file
48
src/views/Home.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div v-if="user">
|
||||
<div class="account-list-container">
|
||||
<account-list />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div>
|
||||
<img class="logo" src="../assets/logo.svg" title="logo Vaquant">
|
||||
<h2 class="subtitle is-2">Gérer vos budgets de groupe en toute simplicité !</h2>
|
||||
</div>
|
||||
<br>
|
||||
<router-link
|
||||
class="button is-primary"
|
||||
:to="{ name: 'user' }"
|
||||
v-t="'user.loginsignup'"></router-link>
|
||||
<div class="account-list-container">
|
||||
<account-list />
|
||||
</div>
|
||||
<app-resume />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IUser from '@/models/IUser'
|
||||
import AccountList from '@/components/AccountList.vue'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'account-list': AccountList,
|
||||
'app-resume': () => import('@/components/AppResume.vue')
|
||||
}
|
||||
})
|
||||
export default class Home extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-list-container {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
23
src/views/Pricing.vue
Normal file
23
src/views/Pricing.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<h1 class="is-1 title">Offres disponibles</h1>
|
||||
<h2 class="is-2 subtitle">
|
||||
Vaquant simplifie vos budgets.
|
||||
</h2>
|
||||
<pricing-table />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'pricing-table': () => import('@/components/PricingTable.vue')
|
||||
}
|
||||
})
|
||||
export default class Princing extends Vue {
|
||||
}
|
||||
</script>
|
||||
18
src/views/Security.vue
Normal file
18
src/views/Security.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="security is-centered" v-once>
|
||||
<h2 class="title is-2">
|
||||
Comment est fait le chiffrement des données ?
|
||||
</h2>
|
||||
<p>
|
||||
Le chiffrement de vos données
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component
|
||||
export default class Security extends Vue {
|
||||
}
|
||||
</script>
|
||||
359
src/views/User.vue
Normal file
359
src/views/User.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div class="user">
|
||||
<div v-if="user">
|
||||
<h2 class="title is-2">
|
||||
{{ user.userId }}
|
||||
</h2>
|
||||
<lang-changer />
|
||||
<div class="buttons is-centered">
|
||||
<button class="button is-warning" type="button" @click="logout" v-t="'user.logout'"></button>
|
||||
<button class="button is-warning" type="button" @click="purge" v-t="'user.purge'"></button>
|
||||
<confirm-button class="is-danger" @confirm="remove">{{ $t('user.delete') }}</confirm-button>
|
||||
</div>
|
||||
<hr />
|
||||
<account-list />
|
||||
<div>
|
||||
<h3 class="subtitle is-3">Comptes cloturés</h3>
|
||||
<account-list :archived="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="tabs is-centered is-fullwidth">
|
||||
<ul>
|
||||
<li :class="{ 'is-active': activeTab === 'login'}">
|
||||
<a href="#" @click.prevent="activeTab = 'login'" v-t="'user.login'"></a>
|
||||
</li>
|
||||
<li :class="{ 'is-active': activeTab === 'signup'}">
|
||||
<a href="#" @click.prevent="activeTab = 'signup'" v-t="'user.signup'"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="user-container">
|
||||
<div v-show="activeTab === 'login'">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">Pseudo</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
name="login"
|
||||
id="login"
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="signin"
|
||||
v-model.trim="userId"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.password'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
name="password"
|
||||
id="password"
|
||||
class="input"
|
||||
type="password"
|
||||
@keyup.enter="signin"
|
||||
v-model="password"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="button is-primary"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@click="signin"
|
||||
v-t="'user.login'"
|
||||
></button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'signup'">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">pseudo</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
required
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="register"
|
||||
v-model.trim="userId"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">email</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
required
|
||||
class="input"
|
||||
type="email"
|
||||
@keyup.enter="register"
|
||||
v-model.trim="email"
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>-->
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.password'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
required
|
||||
class="input"
|
||||
type="password"
|
||||
@keyup.enter="register"
|
||||
v-model="password"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.confirm_password'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
required
|
||||
class="input"
|
||||
type="password"
|
||||
@keyup.enter="register"
|
||||
v-model="confirmPassword"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.firstname'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="register"
|
||||
v-model.trim="firstname"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.lastname'">nom</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="register"
|
||||
v-model.trim="lastname"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <pricing-table first-plan="free" @choose="plan => user.premium = plan === 'premium'"/> -->
|
||||
<hr />
|
||||
<button
|
||||
class="button is-primary"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@click="register(false)"
|
||||
v-t="'user.signup'"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal" :class="{ 'is-active': show }">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<section
|
||||
class="modal-card-body"
|
||||
>Voulez-vous récupérer les comptes utilisés avant d'être inscrit ?</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button type="button" class="button is-success" @click="toReplicate(true)">Oui</button>
|
||||
<button type="button" class="button" @click="toReplicate(false)">Non</button>
|
||||
</footer>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import { slug } from '@/utils'
|
||||
import notif from '@/utils/notif'
|
||||
import IUser from '@/models/IUser'
|
||||
import LangChanger from '@/components/LangChanger.vue'
|
||||
import userService from '@/services/UserService'
|
||||
|
||||
const enableAnonymous: boolean = false
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LangChanger,
|
||||
'account-list': () => import('@/components/AccountList.vue'),
|
||||
'confirm-button': () => import('@/components/ConfirmButton.vue'),
|
||||
'pricing-table': () => import('@/components/PricingTable.vue')
|
||||
}
|
||||
})
|
||||
export default class User extends Vue {
|
||||
@Prop({ type: String, required: false, default: '' })
|
||||
public premail!: string
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Action
|
||||
public login!: any
|
||||
@Action
|
||||
public signup!: any
|
||||
@Action
|
||||
public logout!: any
|
||||
@Action
|
||||
public remove!: any
|
||||
public activeTab: string = 'login'
|
||||
public userId: string = ''
|
||||
public email: string = ''
|
||||
public password: string = ''
|
||||
public confirmPassword: string = ''
|
||||
public firstname: string = ''
|
||||
public lastname: string = ''
|
||||
public isLoading: boolean = false
|
||||
public show: boolean = false
|
||||
public replicate: boolean = false
|
||||
public confirmed: boolean = false
|
||||
|
||||
public mounted(): void {
|
||||
if (this.premail) {
|
||||
this.email = this.premail
|
||||
}
|
||||
}
|
||||
|
||||
public async signin(): Promise<void> {
|
||||
this.isLoading = true
|
||||
await this.login({
|
||||
userId: this.userId,
|
||||
password: this.password
|
||||
})
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
public toReplicate(replicate: boolean): void {
|
||||
this.replicate = replicate
|
||||
this.show = false
|
||||
this.register(true)
|
||||
}
|
||||
|
||||
public async register(confirmed: boolean): Promise<void> {
|
||||
if (!this.validateRegistration()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (
|
||||
enableAnonymous &&
|
||||
(await userService.hasAnonymousData()) &&
|
||||
!confirmed
|
||||
) {
|
||||
if (!this.show) {
|
||||
this.show = true
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
notif.alert(
|
||||
`Une erreur est survenue à la vérification d'un compte anonyme déjà créé.`
|
||||
)
|
||||
// tslint:disable-next-line
|
||||
console.warn({ error })
|
||||
}
|
||||
this.isLoading = true
|
||||
try {
|
||||
const user: IUser = {
|
||||
userId: this.userId,
|
||||
email: this.email,
|
||||
premium: false,
|
||||
slugEmail: this.slugEmail,
|
||||
firstname: this.firstname,
|
||||
lastname: this.lastname
|
||||
}
|
||||
await this.signup({
|
||||
user,
|
||||
password: this.password,
|
||||
replicate: this.replicate
|
||||
})
|
||||
} catch (error) {
|
||||
notif.alert(
|
||||
`Une erreur est survenue à la création du compte utilisateur.`
|
||||
)
|
||||
// tslint:disable-next-line
|
||||
console.warn({ error })
|
||||
}
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
public validateRegistration(): boolean {
|
||||
if (!this.userId) {
|
||||
notif.alert('Un pseudo est obligatoire.')
|
||||
return false
|
||||
}
|
||||
if (this.password !== this.confirmPassword) {
|
||||
notif.alert('Les mots de passe doivent être identique.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public async purge(): Promise<void> {
|
||||
await userService.purge()
|
||||
notif.confirm(this.$t('user.purgeDone').toString())
|
||||
}
|
||||
|
||||
public get slugEmail(): string {
|
||||
return slug(this.email)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user {
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
550
src/views/accounts/AccountItem.vue
Normal file
550
src/views/accounts/AccountItem.vue
Normal file
@@ -0,0 +1,550 @@
|
||||
<template>
|
||||
<section class="hero is-fullheight account-item" v-if="account">
|
||||
<account-encrypted v-if="isEncrypted" />
|
||||
<div class="account-item-container">
|
||||
<h2 class="title is-2">
|
||||
{{ account.name }}
|
||||
<router-link v-if="isAdmin" class="tag" :to="{ name: 'account-setting', params: { id } }">
|
||||
<awe-icon icon="cog" />
|
||||
</router-link>
|
||||
</h2>
|
||||
<div class="columns is-centered" v-if="showShare">
|
||||
<div class="column is-one-third">
|
||||
<account-share :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
<article class="message is-warning" v-if="transactionWithoutExchange.length">
|
||||
<div class="message-header">
|
||||
<p>Attention</p>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
Certaines dépenses ne sont pas comptées car en attente
|
||||
de récupérer les taux pratiqués à leur date respectives.
|
||||
<ul>
|
||||
<li v-for="(transaction, k) in transactionWithoutExchange" :key="k">
|
||||
-
|
||||
<router-link
|
||||
:to="{ name: 'transaction', params: { id: transaction._id } }"
|
||||
>{{ transaction.name }}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<online-view @online="retrieveExchange">
|
||||
<button
|
||||
@click="retrieveExchange"
|
||||
class="button is-success is-large is-rounded"
|
||||
:class="{ 'is-loading': retrievingExchange }"
|
||||
>Récupérer les devises</button>
|
||||
</online-view>
|
||||
</div>
|
||||
</article>
|
||||
<div class="tabs is-toggle is-fullwidth" v-if="transactions.length">
|
||||
<ul>
|
||||
<li :class="{ 'is-active': activeTab === 'transaction' }">
|
||||
<a href="#" @click.prevent="toggleTab('transaction')">
|
||||
<awe-icon icon="dollar-sign" />
|
||||
</a>
|
||||
</li>
|
||||
<li :class="{ 'is-active': activeTab === 'tag' }" v-if="tagCount > 1">
|
||||
<a href="#" @click.prevent="toggleTab('tag')">
|
||||
<awe-icon icon="tag" />
|
||||
</a>
|
||||
</li>
|
||||
<li :class="{ 'is-active': activeTab === 'balance' }" v-if="isMultiUser">
|
||||
<a href="#" @click.prevent="toggleTab('balance')">
|
||||
<awe-icon icon="balance-scale" />
|
||||
</a>
|
||||
</li>
|
||||
<li :class="{ 'is-active': activeTab === 'refund' }" v-if="isMultiUser">
|
||||
<a href="#" @click.prevent="toggleTab('refund')">
|
||||
<awe-icon icon="exchange-alt" />
|
||||
</a>
|
||||
</li>
|
||||
<li :class="{ 'is-active': activeTab === 'total' }">
|
||||
<a href="#" @click.prevent="toggleTab('total')">
|
||||
<awe-icon icon="equals" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<transition-group :name="transitionName" tag="div" mode="out-in" v-if="transactions.length">
|
||||
<div
|
||||
key="transaction"
|
||||
class="transaction-panel tab-content columns is-centered"
|
||||
v-if="activeTab === 'transaction'"
|
||||
>
|
||||
<div class="column">
|
||||
<account-transaction-list :account="account" :transactions="transactions" />
|
||||
</div>
|
||||
</div>
|
||||
<div key="tag" class="tag-panel tab-content columns is-centered" v-if="activeTab === 'tag'">
|
||||
<div class="column">
|
||||
<tag-list :transactions="transactionWithoutRefund" :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key="balance"
|
||||
class="balance-panel tab-content columns is-centered"
|
||||
v-if="activeTab === 'balance'"
|
||||
>
|
||||
<div class="column">
|
||||
<chart-balance :stats="userStats" :currency="account.mainCurrency" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key="refund"
|
||||
class="refund-panel tab-content columns is-centered"
|
||||
v-if="activeTab === 'refund'"
|
||||
>
|
||||
<div class="column">
|
||||
<div v-if="userRefunds.length" class="columns is-centered is-multiline">
|
||||
<div
|
||||
class="column is-one-fifth"
|
||||
v-for="(refund, k) in userRefunds"
|
||||
:key="`${refund.from.alias}-${refund.to.alias}`"
|
||||
>
|
||||
<refund-transaction :refund="userRefunds[k]" :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="equilibrium">Le compte est à l'équilibre !</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key="total"
|
||||
class="total-panel tab-content columns is-center"
|
||||
v-if="activeTab === 'total'"
|
||||
>
|
||||
<div class="column table-container">
|
||||
<p>{{ $tc('account.total', transactions.length, { count: transactions.length }) }}</p>
|
||||
<table class="table is-striped is-bordered">
|
||||
<tbody v-if="isMultiUser">
|
||||
<tr v-for="(user, k) in totalUserCost" :key="k">
|
||||
<th scope="row" v-if="user.alias === userAlias">
|
||||
<em>mes dépenses</em>
|
||||
</th>
|
||||
<th scope="row" v-else>{{ user.alias }}</th>
|
||||
<td class="numeric">{{ user.total | money(account.mainCurrency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="is-selected">
|
||||
<th scope="row" class="total">total</th>
|
||||
<th class="numeric">{{ totalCost | money(account.mainCurrency) }}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
<fab-button
|
||||
:to="{ name: 'transaction-new', params: { id: account._id } }"
|
||||
:button-style="backgroundColor"
|
||||
:margin="true"
|
||||
>
|
||||
<awe-icon icon="money-bill-wave" />
|
||||
<span slot="fulltext">dépense</span>
|
||||
</fab-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Watch } from 'vue-property-decorator'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import BaseAccount from '@/base-components/BaseAccount'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import notif from '@/utils/notif'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import accountService from '@/services/AccountService'
|
||||
import chartService from '@/services/ChartService'
|
||||
import balanceService from '@/services/BalanceService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import IBalance from '@/models/IBalance'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import IRefund from '@/models/IRefund'
|
||||
import IStat from '@/models/IStat'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import IUser from '@/models/IUser'
|
||||
import { primary, main, findDarkValue } from '@/utils'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'account-share': () => import('@/components/AccountShare.vue'),
|
||||
'account-encrypted': () => import('@/components/AccountEncrypted.vue'),
|
||||
'account-transaction-list': () =>
|
||||
import('@/components/AccountTransactionList.vue'),
|
||||
'tag-list': () => import('@/components/TagList.vue'),
|
||||
'chart-balance': () => import('@/components/ChartBalance.vue'),
|
||||
'online-view': () => import('@/components/OnlineView.vue'),
|
||||
'refund-transaction': () => import('@/components/RefundTransaction.vue'),
|
||||
'fab-button': () => import('@/components/FabButton.vue')
|
||||
}
|
||||
})
|
||||
export default class AccountItem extends BaseAccount {
|
||||
@Action
|
||||
public setTitle!: any
|
||||
public retrieveTransactions: boolean = false
|
||||
public transactions: ITransaction[] = []
|
||||
public activeTab: string = 'transaction'
|
||||
public retrievingExchange: boolean = false
|
||||
public transitionName: string = ''
|
||||
|
||||
public async getData(docIds?: string[]): Promise<void> {
|
||||
if (docIds && !docIds.find((i: string) => i === this.id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (this.id) {
|
||||
this.account = await accountService.get(this.id)
|
||||
if (!this.account) {
|
||||
notif.alert(`le compte ${this.id} n'existe pas`)
|
||||
this.$router.push({ name: 'home' })
|
||||
return
|
||||
}
|
||||
this.transactions = await transactionService.getAllByAccountId(this.id)
|
||||
this.retrieveTransactions = true
|
||||
if (document && document.documentElement) {
|
||||
if (this.account.color) {
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-color',
|
||||
this.account.color
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-font-color',
|
||||
findDarkValue(this.account.color) || main
|
||||
)
|
||||
} else {
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-color',
|
||||
primary
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-font-color',
|
||||
main
|
||||
)
|
||||
}
|
||||
}
|
||||
this.setTitle(this.account.name)
|
||||
}
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line
|
||||
console.error({ error })
|
||||
this.$router.push({ name: 'home' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
public toggleTab(tab: string): void {
|
||||
this.activeTab = tab
|
||||
}
|
||||
|
||||
public async retrieveExchange(): Promise<void> {
|
||||
if (!this.transactionWithoutExchange.length) {
|
||||
return
|
||||
}
|
||||
this.retrievingExchange = true
|
||||
try {
|
||||
const transactions: ITransaction[] = [...this.transactionWithoutExchange]
|
||||
for (const transaction of transactions) {
|
||||
transaction.exchange = await exchangeService.get(
|
||||
transaction.mainCurrency.code,
|
||||
transaction.date
|
||||
)
|
||||
}
|
||||
await transactionService.multipleSave(transactions)
|
||||
notif.confirm('Dépenses mises à jour.')
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line
|
||||
console.warn({ error })
|
||||
}
|
||||
this.retrievingExchange = false
|
||||
}
|
||||
|
||||
public totalCostByUserAlias(alias: string): number {
|
||||
if (!this.account) {
|
||||
return 0
|
||||
}
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return transactionService.getTotalCost(
|
||||
transactions,
|
||||
this.account.mainCurrency.code,
|
||||
alias
|
||||
)
|
||||
}
|
||||
|
||||
public get totalUserCost(): Array<{ alias: string; total: number }> {
|
||||
if (!this.account) {
|
||||
return []
|
||||
}
|
||||
return this.account.users
|
||||
.map((user: IUser) => ({
|
||||
alias: user.alias || '',
|
||||
total: this.totalCostByUserAlias(user.alias || '')
|
||||
}))
|
||||
.filter((user) => user.total > 0)
|
||||
}
|
||||
|
||||
public get showShare(): boolean {
|
||||
return !!(
|
||||
this.account &&
|
||||
this.account.users.length > 1 &&
|
||||
this.retrieveTransactions &&
|
||||
!this.transactions.length
|
||||
)
|
||||
}
|
||||
|
||||
public get transactionWithoutRefund(): ITransaction[] {
|
||||
if (!this.transactions) {
|
||||
return []
|
||||
}
|
||||
return this.transactions.filter(
|
||||
(transaction: ITransaction) =>
|
||||
transaction.transactionType === TransactionType.normal
|
||||
)
|
||||
}
|
||||
|
||||
public get tagCount(): number {
|
||||
const tags = new Set(
|
||||
this.transactionWithoutRefund.map(
|
||||
(transaction: ITransaction) => transaction.tag
|
||||
)
|
||||
)
|
||||
return tags.size
|
||||
}
|
||||
|
||||
public get transactionWithExchange(): ITransaction[] {
|
||||
if (!this.transactions) {
|
||||
return []
|
||||
}
|
||||
return this.transactions.filter(
|
||||
(transaction: ITransaction) => !!transaction.exchange
|
||||
)
|
||||
}
|
||||
|
||||
public get transactionWithoutExchange(): ITransaction[] {
|
||||
if (!this.transactions) {
|
||||
return []
|
||||
}
|
||||
return this.transactions.filter(
|
||||
(transaction: ITransaction) => !transaction.exchange
|
||||
)
|
||||
}
|
||||
|
||||
public get userStats(): IStat[] {
|
||||
const totalCost: number = this.totalCost
|
||||
if (!this.account || totalCost === 0) {
|
||||
return []
|
||||
}
|
||||
const account: IAccount = this.account
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
const mainCurrency: ICurrency = account.mainCurrency
|
||||
return this.account.users.map((user: IUser) => {
|
||||
const userCost: number = transactionService.getTotalCost(
|
||||
transactions,
|
||||
account.mainCurrency.code,
|
||||
user.alias
|
||||
)
|
||||
const percent: number = Math.round((userCost / totalCost) * 100)
|
||||
return {
|
||||
label: user.alias || '',
|
||||
value: userCost,
|
||||
percent,
|
||||
balance: balanceService.getBalanceByUser(
|
||||
user,
|
||||
mainCurrency,
|
||||
transactions
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public get myTotalCost(): number {
|
||||
if (!this.account) {
|
||||
return 0
|
||||
}
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return transactionService.getTotalCost(
|
||||
transactions,
|
||||
this.account.mainCurrency.code,
|
||||
this.userAlias
|
||||
)
|
||||
}
|
||||
|
||||
public get totalCost(): number {
|
||||
if (!this.account) {
|
||||
return 0
|
||||
}
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return transactionService.getTotalCost(
|
||||
transactions,
|
||||
this.account.mainCurrency.code
|
||||
)
|
||||
}
|
||||
|
||||
public get userBalance(): IBalance[] {
|
||||
if (!this.account || !this.account.users) {
|
||||
return []
|
||||
}
|
||||
const mainCurrency: ICurrency = this.account.mainCurrency
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return this.account.users.map((user: IUser) =>
|
||||
balanceService.getBalanceByUser(user, mainCurrency, transactions)
|
||||
)
|
||||
}
|
||||
|
||||
public get userRefunds(): IRefund[] {
|
||||
if (!this.account) {
|
||||
return []
|
||||
}
|
||||
return balanceService.getRefund(this.userBalance)
|
||||
}
|
||||
|
||||
public get isAdmin(): boolean {
|
||||
if (!this.account || !this.account.admin || !this.user) {
|
||||
return true
|
||||
}
|
||||
return this.account.admin.slugEmail === this.user.slugEmail
|
||||
}
|
||||
|
||||
public get userAlias(): string {
|
||||
if (!this.account || !this.user) {
|
||||
return ''
|
||||
}
|
||||
const mainUser = this.user
|
||||
const user = this.account.users.find(
|
||||
(u: IUser) => u.slugEmail === mainUser.slugEmail
|
||||
)
|
||||
return (user && user.alias) || ''
|
||||
}
|
||||
|
||||
public get isEncrypted(): boolean {
|
||||
return !!(this.account && this.account.name.length > 30)
|
||||
}
|
||||
|
||||
public get backgroundColor(): any | null {
|
||||
if (!this.account || !this.account.color) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
backgroundColor: this.account.color,
|
||||
color: findDarkValue(this.account.color) || 'black'
|
||||
}
|
||||
}
|
||||
|
||||
public get isMultiUser(): boolean {
|
||||
return !!this.account && this.account.users.length > 1
|
||||
}
|
||||
|
||||
@Watch('activeTab')
|
||||
public onActiveTabChange(newTab: string, oldTab: string): void {
|
||||
const tabs: string[] = ['transaction', 'tag', 'balance', 'refund', 'total']
|
||||
this.transitionName =
|
||||
tabs.indexOf(newTab) > tabs.indexOf(oldTab) ? 'slide-left' : 'slide-right'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables';
|
||||
.account-item {
|
||||
font-size: 14pt;
|
||||
|
||||
h2.title,
|
||||
a {
|
||||
color: var(--primary-font-color);
|
||||
}
|
||||
|
||||
table tr.is-selected {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--primary-font-color);
|
||||
}
|
||||
|
||||
.tabs.is-toggle {
|
||||
max-width: 400pt;
|
||||
margin: auto;
|
||||
li.is-active {
|
||||
a {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-font-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabs.is-toggle {
|
||||
a {
|
||||
height: 35pt;
|
||||
width: 35pt;
|
||||
border-radius: 50%;
|
||||
margin: auto;
|
||||
border-width: 2px;
|
||||
font-size: 14pt;
|
||||
}
|
||||
li:first-child,
|
||||
li:last-child {
|
||||
a {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.hero {
|
||||
&.is-fullheight {
|
||||
min-height: calc(100vh - 52px);
|
||||
}
|
||||
}
|
||||
.equilibrium {
|
||||
font-size: 25pt;
|
||||
color: var(--primary-font-color);
|
||||
}
|
||||
table.table {
|
||||
margin: auto;
|
||||
}
|
||||
th,
|
||||
.pay-by {
|
||||
text-align: center;
|
||||
}
|
||||
.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.total-panel {
|
||||
font-size: 16pt;
|
||||
}
|
||||
}
|
||||
.no-transaction {
|
||||
font-size: 16pt;
|
||||
}
|
||||
.tab-content {
|
||||
position: absolute;
|
||||
width: calc(100% - 30px);
|
||||
margin: auto;
|
||||
transition: opacity 0.5s cubic-bezier(0.55, 0, 0.1, 1),
|
||||
transform 0.5s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
&.columns {
|
||||
&:last-child {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.slide-left-enter,
|
||||
.slide-right-leave-active {
|
||||
opacity: 0;
|
||||
transform: translate(15px, 0);
|
||||
}
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter {
|
||||
opacity: 0;
|
||||
transform: translate(-15px, 0);
|
||||
}
|
||||
.table {
|
||||
th,
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.account-item-container {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
261
src/views/accounts/AccountNew.vue
Normal file
261
src/views/accounts/AccountNew.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="account-new">
|
||||
<h1 class="title is-1" v-t="'account.create'"></h1>
|
||||
<form @submit.prevent="submitAccount">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3 class="subtitle is-3">Nom</h3>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input type="text" class="input" maxlength="30" v-model="name" required />
|
||||
</div>
|
||||
<p
|
||||
class="help is-info"
|
||||
>{{ $tc('validation.max_char', 30 - name.length, { max: 30 - name.length }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h3 class="subtitle is-3" v-t="'account.main_currency'"></h3>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="main-currency" id="main-currency" v-model="mainCurrency">
|
||||
<option
|
||||
v-for="(cur, k) in currencies"
|
||||
:key="k"
|
||||
:value="cur"
|
||||
>{{ cur.name }} {{ cur.symbol }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<color-picker v-model="color" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<account-user-new v-model="users" />
|
||||
</div>
|
||||
<div class="column">
|
||||
<h3 class="subtitle is-3 title-currency" @click="toggleCurrencyShow">
|
||||
<div class="icon-container">
|
||||
<awe-icon icon="chevron-right" :rotation="currencyShow ? 90 : undefined" />
|
||||
</div>
|
||||
{{ $tc('account.used_currencies', accountCurrencies.length, { count: accountCurrencies.length }) }}
|
||||
</h3>
|
||||
<transition name="fade">
|
||||
<div class="currency-list" v-if="currencyShow">
|
||||
<div class="field">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-half" v-for="(currency, k) in currencies" :key="k">
|
||||
<input
|
||||
class="is-checkradio is-block is-medium"
|
||||
type="checkbox"
|
||||
:name="`cur-${currency.code}`"
|
||||
:id="`cur-${currency.code}`"
|
||||
v-model="accountCurrencies"
|
||||
:value="currency"
|
||||
:disabled="currency.code === mainCurrency.code"
|
||||
/>
|
||||
<label
|
||||
:for="`cur-${currency.code}`"
|
||||
>{{ currency.name }} ({{ currency.symbol || currency.code }})</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3 class="subtitle is-3">Compte privé / public</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
id="is-public"
|
||||
type="checkbox"
|
||||
name="is-public"
|
||||
class="switch"
|
||||
v-model="isPublic"
|
||||
/>
|
||||
<label for="is-public">Compte {{ isPublic ? 'public' : 'privé' }}</label>
|
||||
<p class="help is-danger" v-if="isPublic" v-t="'account.publicInformation'"></p>
|
||||
<p class="help is-info" v-else v-t="'account.privateInformation'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-one-third">
|
||||
<button type="submit" class="button is-primary is-fullwidth is-large">
|
||||
<awe-icon icon="check" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import currencies from '@/data/currencies'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import accountService from '@/services/AccountService'
|
||||
import IUser from '@/models/IUser'
|
||||
import notif from '@/utils/notif'
|
||||
import { slug } from '@/utils'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'color-picker': () => import('@/components/ColorPicker.vue'),
|
||||
'account-user-new': () => import('@/components/AccountUserNew.vue')
|
||||
}
|
||||
})
|
||||
export default class AccountNew extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
public name: string = ''
|
||||
public currencies: ICurrency[] = currencies
|
||||
public mainCurrency: ICurrency = currencies[0]
|
||||
public accountCurrencies: ICurrency[] = [currencies[0]]
|
||||
public color: string | null = null
|
||||
public users: IUser[] = []
|
||||
public currencyShow: boolean = false
|
||||
public isPublic: boolean = false
|
||||
|
||||
public toggleCurrencyShow(): void {
|
||||
this.currencyShow = !this.currencyShow
|
||||
}
|
||||
|
||||
public validate(): boolean {
|
||||
if (!this.name) {
|
||||
notif.alert('Le nom doit être rempli.')
|
||||
return false
|
||||
}
|
||||
const aliases: string[] = this.users.map((user: IUser) =>
|
||||
user.alias ? user.alias.toLowerCase() : ''
|
||||
)
|
||||
if (aliases.length !== new Set(aliases).size) {
|
||||
notif.alert('Les alias doivent être uniques.')
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.user) {
|
||||
const userIds: string[] = this.users.map((user: IUser) => user.userId)
|
||||
if (userIds.length !== new Set(userIds).size) {
|
||||
notif.alert('Les identifiants doivent être uniques.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public async submitAccount(): Promise<void> {
|
||||
if (!this.validate()) {
|
||||
return
|
||||
}
|
||||
let users: IUser[] = this.user ? [this.user, ...this.users] : this.users
|
||||
users = users.map((u: IUser) => ({
|
||||
userId: u.userId,
|
||||
email: u.email,
|
||||
slugEmail: u.slugEmail,
|
||||
premium: u.premium,
|
||||
alias: u.alias,
|
||||
firstname: u.firstname,
|
||||
lastname: u.lastname
|
||||
}))
|
||||
const userIds: string[] = users.map((u: IUser) => slug(u.userId))
|
||||
const newAccount: IAccount = {
|
||||
name: this.name,
|
||||
admin: this.user
|
||||
? {
|
||||
userId: this.user.userId,
|
||||
email: this.user.email,
|
||||
slugEmail: this.user.slugEmail,
|
||||
premium: this.user.premium,
|
||||
alias: this.user.alias,
|
||||
firstname: this.user.firstname,
|
||||
lastname: this.user.lastname
|
||||
}
|
||||
: null,
|
||||
color: this.color,
|
||||
users,
|
||||
userIds,
|
||||
mainCurrency: this.mainCurrency,
|
||||
currencies: this.accountCurrencies,
|
||||
isPublic: this.isPublic
|
||||
}
|
||||
const response: PouchDB.Core.Response = await accountService.add(newAccount)
|
||||
if (response.ok) {
|
||||
this.$router.push({ name: 'account', params: { id: response.id } })
|
||||
} else {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn(response)
|
||||
notif.alert(`Une erreur s'est produite à la création d'un compte.`)
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('mainCurrency', { immediate: true })
|
||||
public onMainCurrencyChange(
|
||||
currency: ICurrency,
|
||||
oldCurrency: ICurrency
|
||||
): void {
|
||||
if (oldCurrency) {
|
||||
this.accountCurrencies = this.accountCurrencies.filter(
|
||||
(c: ICurrency) => c.code !== oldCurrency.code
|
||||
)
|
||||
}
|
||||
const cur: ICurrency | undefined = this.currencies.find(
|
||||
(c: ICurrency) => c.code === currency.code
|
||||
)
|
||||
if (cur) {
|
||||
const already: ICurrency | undefined = this.accountCurrencies.find(
|
||||
(c: ICurrency) => c.code === cur.code
|
||||
)
|
||||
if (!already) {
|
||||
this.accountCurrencies.push(cur)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~bulma-switch';
|
||||
|
||||
h3 {
|
||||
max-width: 400px;
|
||||
margin: auto;
|
||||
}
|
||||
.icon-container {
|
||||
float: left;
|
||||
|
||||
svg {
|
||||
transition: transform 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
}
|
||||
.title-currency {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.currency-list {
|
||||
margin-top: 10px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s cubic-bezier(0.55, 0, 0.1, 1),
|
||||
transform 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
</style>
|
||||
|
||||
52
src/views/accounts/AccountPublic.vue
Normal file
52
src/views/accounts/AccountPublic.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="account-public">Récupération du compte public...</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
import IUser from '@/models/IUser'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import notif from '@/utils/notif'
|
||||
import accountService from '@/services/AccountService'
|
||||
import couchService from '../../services/CouchService'
|
||||
|
||||
@Component
|
||||
export default class AccountPublic extends Vue {
|
||||
@Getter public user!: IUser | null
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public account: IAccount | null = null
|
||||
@Action
|
||||
public addAccountId!: any
|
||||
|
||||
public async mounted(): Promise<void> {
|
||||
try {
|
||||
if (this.id) {
|
||||
this.account = await accountService.get(this.id)
|
||||
if (this.account) {
|
||||
this.$router.push({ name: 'account', params: { id: this.id } })
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.account = await accountService.getRemote(this.id)
|
||||
if (this.account && this.account.isPublic) {
|
||||
this.addAccountId({ accountId: this.id })
|
||||
notif.confirm(
|
||||
`Récupération du compte public ${this.account.name} en cours...`
|
||||
)
|
||||
|
||||
await couchService.initLive()
|
||||
this.$router.push({
|
||||
name: 'account',
|
||||
params: { id: this.id }
|
||||
})
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user