master: change repo

This commit is contained in:
Julien Calixte
2019-08-22 11:50:32 +02:00
commit dbd63d341c
263 changed files with 26153 additions and 0 deletions

158
src/App.vue Normal file
View 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>

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

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

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

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

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

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

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

View 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 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" />&nbsp;
<awe-icon icon="exchange-alt" />&nbsp;
<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&nbsp;!
Votre partenaire idéal pour vos voyages&nbsp;! ⛺
</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>&nbsp;!
</p>
<p>
Ou vous pouvez également me supporter sur
<a
target="_blank"
rel="noreferrer"
:href="brave"
>Brave</a>&nbsp;!
</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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,6 @@
enum TransactionType {
normal,
refund
}
export default TransactionType

38
src/main.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
export default interface ICurrency {
name: string
code: string
symbol?: string
}

View File

@@ -0,0 +1,4 @@
export default interface IIdPassword {
userId: string
password: string
}

View File

@@ -0,0 +1,6 @@
import IModel from '@/models/IModel'
export default interface IEncryptPassword extends IModel {
password: string
iv?: Uint8Array
}

View File

@@ -0,0 +1,3 @@
export default interface IEncryptable {
[key: string]: any
}

3
src/models/IEncrypted.ts Normal file
View File

@@ -0,0 +1,3 @@
export default interface IEncrypted {
[key: string]: string
}

8
src/models/IExchange.ts Normal file
View 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
View File

@@ -0,0 +1,5 @@
export default interface ILocation {
lat: number
lon: number
place?: string
}

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

@@ -0,0 +1,4 @@
export default interface IPoint {
x: number
y: number
}

7
src/models/IRefund.ts Normal file
View 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
View File

@@ -0,0 +1,4 @@
export default interface IResponse {
ok: boolean
message?: string
}

7
src/models/ISlice.ts Normal file
View 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
View File

@@ -0,0 +1,4 @@
export default interface ISplit {
alias: string
weight: number
}

8
src/models/IStat.ts Normal file
View File

@@ -0,0 +1,8 @@
import IBalance from '@/models/IBalance'
export default interface IStat {
label: string
value: number
percent: number
balance: IBalance
}

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

View File

@@ -0,0 +1,7 @@
import IUser from '@/models/IUser'
export default interface IUserPassword {
user: IUser
password: string
replicate?: boolean
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,3 @@
import { Component } from 'vue-property-decorator'
Component.registerHooks(['beforeRouteEnter', 'beforeRouteUpdate'])

76
src/utils/icons.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
declare const Notyf: any
export default new Notyf()

125
src/utils/notyf.js Normal file
View 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];
}
}
}
})();

View 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
View 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
View 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
View 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é&nbsp;!</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
View 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
View 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
View 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>

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

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

View 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