feat!: rewrite frontend in Vue 3 with Pinia, DaisyUI, vite-plugin-pwa

Migrates every component from class-based vue-property-decorator to
<script setup> + Composition API. Replaces Vuex 3 + vuex-class with a
single Pinia store (persisted via pinia-plugin-persistedstate). Swaps
Bulma + bulma-{checkradio,switch,pricingtable} for DaisyUI 5 utilities
on Tailwind 4. Replaces register-service-worker with vite-plugin-pwa
(workbox, skipWaiting/clientsClaim preserved).

Plugins replaced:
- vue-class-component / vue-property-decorator -> <script setup>
- vuex / vuex-class / vuex-persist -> pinia + persistedstate
- vue-i18n 8 -> vue-i18n 11 (composition mode, legacy: false)
- vue-click-outside -> @vueuse/core onClickOutside
- @xkeshi/vue-qrcode -> qrcode.vue
- vue-currency-input 1 -> vue-currency-input 3 (composable wrapper)
- bus-event (Vue instance) -> mitt
- Vue filters -> plain functions imported per component

BREAKING: drops Stripe / pricing entirely (Payment, PricingTable,
/pricing route, vue-stripe-checkout, bulma-pricingtable).

Clears unused Cypress and Jest test scaffolding; leaves a Vitest
harness behind for future tests.
This commit is contained in:
Julien Calixte
2026-06-01 21:03:34 +02:00
parent f6a518a43d
commit d4dab8c03f
95 changed files with 3359 additions and 7627 deletions

View File

@@ -1,112 +1,60 @@
<template>
<div
class="account-card box"
:style="getColor(account.color)"
@click="goToAccount(account._id || '')"
>
<a
class="account-name"
:class="{ 'is-primary': !account.color, 'is-white': account.color }"
:style="`color: ${findDarkValue(account.color || '')}`"
href="#"
>{{ account.name }}</a
>
<span v-if="account.isPublic">
&nbsp;
<vaquant-icon icon="users" />
</span>
<div class="numeric">
{{ totalCost | moneypad(account.mainCurrency, false) }}
</div>
<div class="account-users">
{{ account.users && account.users.map((u) => u.alias).join(', ') }}
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import type IAccount from '@/models/IAccount'
import type ITransaction from '@/models/ITransaction'
import { findDarkValue, findLightValue } 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 FabButton from '@/components/FabButton.vue'
import ITransaction from '../models/ITransaction'
import transactionService from '../services/TransactionService'
import TransactionType from '../enums/TransactionType'
import colors from '../data/colors'
import { moneypad } from '@/utils/format'
import transactionService from '@/services/TransactionService'
const GRADIENT = false
const props = defineProps<{ account: IAccount }>()
@Component
export default class AccountCard extends Vue {
@Prop({ type: Object, required: true })
public account!: IAccount
public transactions: ITransaction[] = []
const router = useRouter()
const transactions = ref<ITransaction[]>([])
public async mounted() {
this.transactions = await transactionService.getAllByAccountId(
this.account._id
)
}
onMounted(async () => {
transactions.value = await transactionService.getAllByAccountId(props.account._id)
})
public findDarkValue(color: string): string | null {
return findDarkValue(color)
}
const totalCost = computed(() =>
transactionService.getTotalCost(transactions.value, props.account.mainCurrency.code)
)
public goToAccount(id: string): void {
this.$router.push({ name: 'account', params: { id } })
}
const goToAccount = (id: string) => {
router.push({ name: 'account', params: { id } })
}
public getColor(color: string | null): any | null {
if (!color) {
return null
}
return {
background: GRADIENT
? `linear-gradient(135deg, ${color}, ${findLightValue(color)})`
: color,
color: findDarkValue(color)
}
}
private get totalCost(): number {
return transactionService.getTotalCost(
this.transactions,
this.account.mainCurrency.code
)
const getColor = (color: string | null) => {
if (!color) return null
return {
background: color,
color: findDarkValue(color) ?? undefined
}
}
</script>
<style lang="scss" scoped>
@import '../styles/variables';
.account-name {
font-weight: bold;
}
.box,
.box:not(:last-child) {
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 8px;
word-wrap: break-word;
hyphens: auto;
margin-bottom: 0;
a {
font-size: 1.2rem;
color: $main;
}
&:hover {
cursor: pointer;
}
.account-users {
text-overflow: ellipsis;
}
}
</style>
<template>
<div
class="account-card card bg-base-100 shadow p-4 cursor-pointer hover:shadow-lg transition-shadow"
:style="getColor(account.color ?? null) ?? undefined"
@click="goToAccount(account._id || '')"
>
<a
href="#"
class="account-name font-bold text-lg"
:style="`color: ${findDarkValue(account.color ?? '') ?? ''}`"
@click.prevent
>
{{ account.name }}
</a>
<span v-if="account.isPublic">
&nbsp;<vaquant-icon icon="users" />
</span>
<div class="numeric mt-1">
{{ moneypad(totalCost, account.mainCurrency, false) }}
</div>
<div class="account-users text-sm opacity-80 mt-1 truncate">
{{ account.users && account.users.map((u) => u.alias).join(', ') }}
</div>
</div>
</template>

View File

@@ -1,76 +1,46 @@
<template>
<div class="account-list">
<div class="account-grid" v-if="accounts.length">
<account-card
v-for="(account, k) in accounts"
:key="k"
:account="account"
/>
</div>
<div v-else-if="fetched">
<vaquant-icon icon="inbox" />&nbsp;Les comptes créés s'afficheront ici.
</div>
<fab-button :to="{ name: 'account-new' }" :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 { findContrastColor } from '@/utils'
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import bus, { SYNC } from '@/utils/bus-event'
import type IAccount from '@/models/IAccount'
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'
import AccountCard from '@/components/AccountCard.vue'
@Component({
components: {
'fab-button': FabButton,
'account-card': AccountCard
}
})
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)
}
const props = withDefaults(defineProps<{ archived?: boolean }>(), { archived: false })
public beforeDestroy() {
bus.$off(SYNC, this.getData)
}
const userStore = useUserStore()
const { user: _user } = storeToRefs(userStore)
public async getData(): Promise<void> {
this.accounts = await accountService.getAll(this.archived)
this.fetched = true
}
const accounts = ref<IAccount[]>([])
const fetched = ref(false)
const getData = async () => {
accounts.value = await accountService.getAll(props.archived)
fetched.value = true
}
onMounted(() => {
getData()
bus.on(SYNC, getData)
})
onBeforeUnmount(() => {
bus.off(SYNC, getData)
})
</script>
<style lang="scss" scoped>
.account-grid {
display: grid;
gap: 1rem;
margin: 0 1rem;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
justify-content: center;
}
li {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
</style>
<template>
<div class="account-list">
<div v-if="accounts.length" class="grid gap-4 mx-4" style="grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));">
<AccountCard v-for="(account, k) in accounts" :key="k" :account="account" />
</div>
<div v-else-if="fetched" class="p-4 text-center opacity-70">
<vaquant-icon icon="inbox" />&nbsp;Les comptes créés s'afficheront ici.
</div>
<FabButton :to="{ name: 'account-new' }" :margin="true">
<template #fulltext>créer un compte</template>
</FabButton>
</div>
</template>

View File

@@ -1,212 +1,157 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import type IAccount from '@/models/IAccount'
import type IUser from '@/models/IUser'
import { confirmation } from '@/utils'
import accountService from '@/services/AccountService'
import queueNotifService from '@/services/QueueNotifService'
import ConfirmButton from '@/components/ConfirmButton.vue'
import ColorPicker from '@/components/ColorPicker.vue'
import AccountShare from '@/components/AccountShare.vue'
import AccountUserNew from '@/components/AccountUserNew.vue'
const props = defineProps<{ account: IAccount }>()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { t } = useI18n()
const router = useRouter()
const newUsers = ref<IUser[]>([])
const isMultiUser = computed(() => props.account.users.length > 1)
const color = computed({
get: () => props.account.color ?? null,
set: (val) => {
props.account.color = val
}
})
const save = async () => {
if (!props.account || !props.account._id) return
const ok = await accountService.update(props.account._id, props.account)
if (!ok) {
queueNotifService.error(`Une erreur s'est produite.`)
}
}
const active = async () => {
const ok = await accountService.active(props.account._id || '')
if (ok) {
router.push({ name: 'home' })
confirmation(t('account.closed'))
}
}
const archive = async () => {
const ok = await accountService.archive(props.account._id || '')
if (ok) {
router.push({ name: 'home' })
confirmation(t('account.closed'))
}
}
const remove = async () => {
const ok = await accountService.remove(props.account._id || '')
if (ok) {
router.push({ name: 'home' })
confirmation('Compte supprimée')
}
}
const validate = (): boolean => {
const allUsers: IUser[] = [...props.account.users, ...newUsers.value]
const aliases = allUsers.map((u) => (u.alias ? u.alias.toLowerCase() : ''))
if (aliases.length === 0) {
queueNotifService.error('Au moins une personne doit être ajoutée au compte.')
return false
}
if (aliases.length !== new Set(aliases).size) {
queueNotifService.error('Les alias doivent être uniques.')
return false
}
const userIds = allUsers.map((u) => u.userId)
if (userIds.length !== new Set(userIds).size) {
queueNotifService.error('Les identifiants doivent être uniques.')
return false
}
return true
}
const saveUsers = async () => {
if (!props.account._id || !validate()) return
props.account.users = [...props.account.users, ...newUsers.value]
const ok = await accountService.update(props.account._id, props.account)
if (!ok) {
queueNotifService.error(`Une erreur s'est produite.`)
return
}
queueNotifService.success(t('account.newUserAdded', newUsers.value.length))
newUsers.value = []
}
const getUserInfo = (u: IUser): string => {
const email = (u.email && ` - ${u.email}`) || ''
return `${u.alias} (${u.userId}${email})`
}
</script>
<template>
<div class="account-setting no-margin">
<div class="columns is-centered">
<div class="column is-one-third">
<color-picker v-model="account.color" @color-change="save" />
<div class="account-setting">
<div class="flex justify-center">
<div class="w-full md:w-1/3">
<ColorPicker v-model="color" @color-change="save" />
</div>
</div>
<hr />
<hr class="my-6" />
<div>
<h3 class="subtitle is-3" v-t="'account.users'"></h3>
<div class="columns is-multiline is-centered">
<div class="column">
{{ account.users.map((u) => getUserInfo(u)).join(', ') }}
<h3 class="text-2xl font-semibold mb-3">{{ t('account.users') }}</h3>
<p class="text-center mb-4">
{{ account.users.map((u) => getUserInfo(u)).join(', ') }}
</p>
<h3 class="text-2xl font-semibold mb-3">Ajouter des amis</h3>
<div class="flex justify-center">
<div class="w-full md:w-1/3">
<AccountUserNew v-model="newUsers" :default-user="false" :show-title="false" />
</div>
</div>
<h3 class="subtitle is-3">Ajouter des amis</h3>
<div class="columns is-centered">
<div class="column is-one-third">
<account-user-new
v-model="newUsers"
:default-user="false"
:show-title="false"
/>
</div>
</div>
<div class="columns is-centered" v-if="newUsers.length">
<div class="column is-one-third">
<button
@click="saveUsers"
class="button is-primary is-fullwidth is-large"
>
<vaquant-icon icon="check" />
</button>
</div>
<div v-if="newUsers.length" class="flex justify-center mt-4">
<button class="btn btn-primary btn-block btn-lg max-w-sm" @click="saveUsers">
<vaquant-icon icon="check" />
</button>
</div>
</div>
<hr />
<div class="columns is-centered">
<div class="column is-one-third" v-if="isMultiUser">
<account-share :account="account" :can-close="false" />
<hr class="my-6" />
<div class="grid md:grid-cols-2 gap-4 max-w-3xl mx-auto">
<div v-if="isMultiUser">
<AccountShare :account="account" :can-close="false" />
</div>
<div class="column is-one-third">
<article class="message is-primary is-medium">
<div class="message-header">
<p>Actions</p>
</div>
<div class="buttons action-buttons is-centered">
<confirm-button
v-if="account.archive"
class="is-warning"
@confirm="active"
>
<span v-t="'account.open'"></span>
</confirm-button>
<confirm-button v-else class="is-warning" @confirm="archive">
<span v-t="'account.close'"></span>
</confirm-button>
<confirm-button class="is-danger" @confirm="remove">
<span v-t="'account.delete'"></span>
</confirm-button>
</div>
</article>
<div class="alert alert-info flex-col items-stretch">
<p class="font-semibold">Actions</p>
<div class="flex flex-wrap gap-2 mt-3 justify-center">
<ConfirmButton
v-if="account.archive"
class="btn-warning"
@confirm="active"
>
<span>{{ t('account.open') }}</span>
</ConfirmButton>
<ConfirmButton
v-else
class="btn-warning"
@confirm="archive"
>
<span>{{ t('account.close') }}</span>
</ConfirmButton>
<ConfirmButton class="btn-error" @confirm="remove">
<span>{{ t('account.delete') }}</span>
</ConfirmButton>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import BaseAccount from '@/base-components/BaseAccount'
import { confirmation } from '@/utils'
import bus, { SYNC } from '@/utils/bus-event'
import IAccount from '@/models/IAccount'
import IUser from '@/models/IUser'
import accountService from '@/services/AccountService'
import queueNotifService from '@/services/QueueNotifService'
@Component({
components: {
'confirm-button': () => import('@/components/ConfirmButton.vue'),
'color-picker': () => import('@/components/ColorPicker.vue'),
'account-share': () => import('@/components/AccountShare.vue'),
'account-user-new': () => import('@/components/AccountUserNew.vue')
}
})
export default class AccountSetting extends Vue {
@Getter
public user!: IUser | null
@Prop({ type: Object, required: true })
public account!: IAccount
public removing: boolean = false
public newUsers: IUser[] = []
public async active(): Promise<void> {
const ok: boolean = await accountService.active(this.account._id || '')
if (ok) {
this.$router.push({ name: 'home' })
confirmation(this.$t('account.closed').toString())
}
}
public async archive(): Promise<void> {
const ok: boolean = await accountService.archive(this.account._id || '')
if (ok) {
this.$router.push({ name: 'home' })
confirmation(this.$t('account.closed').toString())
}
}
public async remove(): Promise<void> {
const ok: boolean = await accountService.remove(this.account._id || '')
if (ok) {
this.$router.push({ name: 'home' })
confirmation('Compte supprimée')
}
}
public async save(): Promise<void> {
if (!this.account || !this.account._id) {
return
}
const ok: boolean = await accountService.update(
this.account._id,
this.account
)
if (!ok) {
queueNotifService.error(`Une erreur s'est produite.`)
}
}
public async saveUsers(): Promise<void> {
if (!this.account._id || !this.validate()) {
return
}
this.account.users = [...this.account.users, ...this.newUsers]
const ok: boolean = await accountService.update(
this.account._id,
this.account
)
if (!ok) {
queueNotifService.error(`Une erreur s'est produite.`)
return
}
queueNotifService.success(
this.$tc('account.newUserAdded', this.newUsers.length).toString()
)
this.newUsers = []
}
public getUserInfo(user: IUser): string {
const email = (user.email && ` - ${user.email}`) || ''
return `${user.alias} (${user.userId}${email})`
}
public validate(): boolean {
const allUsers: IUser[] = [...this.account.users, ...this.newUsers]
const aliases: string[] = allUsers.map((user: IUser) =>
user.alias ? user.alias.toLowerCase() : ''
)
if (aliases.length === 0) {
queueNotifService.error('Au moins une personne doit être ajoutée au compte.')
return false
}
if (aliases.length !== new Set(aliases).size) {
queueNotifService.error('Les alias doivent être uniques.')
return false
}
const userIds: string[] = allUsers.map((user: IUser) => user.userId)
if (userIds.length !== new Set(userIds).size) {
queueNotifService.error('Les identifiants doivent être uniques.')
return false
}
return true
}
public get isAdmin(): boolean {
if (!this.user) {
return true
}
return (
!this.account.admin ||
this.account.admin.slugEmail === this.user.slugEmail
)
}
public get backgroundColor(): any | null {
if (!this.account.color) {
return null
}
return {
backgroundColor: this.account.color,
color: 'white'
}
}
public get isMultiUser(): boolean {
return this.account.users.length > 1
}
}
</script>
<style>
.user-id {
font-family: 'Cutive Mono', monospace;
}
.action-buttons {
margin: 15px;
}
</style>

View File

@@ -1,131 +1,105 @@
<template>
<article class="account-share message is-success is-medium" v-if="show">
<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>
<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'
<script setup lang="ts">
import { ref, computed } from 'vue'
import QrCode from 'qrcode.vue'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import type IAccount from '@/models/IAccount'
import queueNotifService from '@/services/QueueNotifService'
import QrCode from '@xkeshi/vue-qrcode'
@Component({
components: {
'qr-code': QrCode
const props = withDefaults(
defineProps<{ account: IAccount; canClose?: boolean }>(),
{ canClose: true }
)
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const show = ref(true)
const hide = () => {
if (props.canClose) {
show.value = false
}
}
const accountUrl = computed(() => {
let url = `${window.location.origin}${window.location.pathname}`
if (props.account.isPublic) {
url = `${url}/public`
}
return url
})
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
}
const username = computed(() => {
if (!user.value) return ''
return (
`${user.value.firstname} ${user.value.lastname}`.trim() ||
user.value.email
)
})
const canShareAPI = computed(() => typeof navigator.share === 'function')
const canClipboard = computed(() => !!navigator.clipboard)
const share = () => {
if (canShareAPI.value) {
navigator.share({
title: `Vaquant - Compte ${props.account.name}`,
text: `${username.value} vous invite sur Vaquant pour gérer vos dépenses sur le compte ${props.account.name}.`,
url: accountUrl.value
})
}
}
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)
queueNotifService.success('Adresse copiée')
}
}
public get accountUrl(): string {
let url = `${window.location.origin}${window.location.pathname}`
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
const copy = async () => {
if (canClipboard.value) {
await navigator.clipboard.writeText(accountUrl.value)
queueNotifService.success('Adresse copiée')
}
}
</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>
<template>
<div v-if="show" class="account-share alert alert-success flex-col items-stretch">
<div class="flex justify-between items-center w-full">
<p class="font-semibold">Partagez le compte</p>
<button
v-if="canClose"
class="btn btn-ghost btn-sm btn-square"
aria-label="close"
@click="hide"
>
<vaquant-icon icon="x" />
</button>
</div>
<div class="mt-3 w-full">
<button
v-if="canShareAPI"
class="btn btn-primary"
@click="share"
>
Partager
</button>
<div v-else class="join">
<input
:value="accountUrl"
type="text"
readonly
class="input input-bordered join-item flex-1"
/>
<a
v-if="canClipboard"
href="#"
class="btn btn-primary join-item"
@click.prevent="copy"
>
Copier
</a>
</div>
<div class="qr-code mt-3 text-center">
<p class="mb-2">ou via ce QR code :</p>
<QrCode :value="accountUrl" :size="180" class="inline-block" />
</div>
</div>
</div>
</template>

View File

@@ -1,79 +1,107 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type IAccount from '@/models/IAccount'
import type ITransaction from '@/models/ITransaction'
import { findContrastColor } from '@/utils'
import { date as formatDate, moneypad } from '@/utils/format'
const props = withDefaults(
defineProps<{
account?: IAccount | null
transactions: ITransaction[]
filter?: boolean
head?: boolean
foot?: boolean
}>(),
{ account: null, filter: true, head: true, foot: false }
)
const filteredBy = ref('')
const total = computed(() =>
props.transactions.reduce((a, t) => a + t.amount, 0)
)
const filteredTransactions = computed(() =>
filteredBy.value
? props.transactions.filter((t) => t.payBy === filteredBy.value)
: props.transactions
)
const isMultiUser = computed(() => {
const payBy = new Set(props.transactions.map((t) => t.payBy))
return payBy.size > 1
})
const showFilter = computed(() => props.filter && isMultiUser.value)
const hasMultipleCurrencies = computed(
() => (props.account && props.account.currencies.length > 1) || false
)
</script>
<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-if="filteredBy">
<button type="submit" class="button" @click="filteredBy = ''">
<vaquant-icon icon="x" />
</button>
</div>
<div class="control">
<div class="select is-fullwidth">
<select v-model="filteredBy">
<option value>Tous</option>
<option
v-for="(user, k) in account.users"
:key="k"
:value="user.alias"
>
{{ user.alias }}
</option>
</select>
</div>
</div>
<div v-if="account" class="account-transaction-list">
<div v-if="showFilter" class="join justify-center mb-3">
<button
v-if="filteredBy"
type="submit"
class="btn join-item"
@click="filteredBy = ''"
>
<vaquant-icon icon="x" />
</button>
<select v-model="filteredBy" class="select select-bordered join-item">
<option value="">Tous</option>
<option v-for="(u, k) in account.users" :key="k" :value="u.alias">
{{ u.alias }}
</option>
</select>
</div>
<div class="table-container">
<table class="table is-hoverable is-striped">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead v-if="head">
<tr>
<th scope="col">
<vaquant-icon icon="minus" />
</th>
<th scope="col" class="align">
<th scope="col" class="text-center">
<vaquant-icon icon="user" />
</th>
<th scope="col" class="align">
<th scope="col" class="text-center">
<vaquant-icon icon="cash" />
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(transaction, k) in filteredTransactions"
v-for="(t, k) in filteredTransactions"
:id="t._id"
:key="k"
:data-index="k"
:id="transaction._id"
>
<td class="multiline">
<router-link
:to="{ name: 'transaction', params: { id: transaction._id } }"
>{{ transaction.name }}</router-link
>
<td>
<router-link :to="{ name: 'transaction', params: { id: t._id } }">
{{ t.name }}
</router-link>
<br />
<span class="date">{{ transaction.date | date }}</span>
<span class="italic text-sm">{{ formatDate(String(t.date)) }}</span>
</td>
<td class="pay-by">{{ transaction.payBy }}</td>
<td class="numeric">
{{
transaction.amount
| moneypad(transaction.mainCurrency, hasMultipleCurrencies)
}}
<td>{{ t.payBy }}</td>
<td class="numeric text-right">
{{ moneypad(t.amount, t.mainCurrency, hasMultipleCurrencies) }}
</td>
</tr>
</tbody>
<tfoot v-if="foot">
<tr
class="is-selected"
:style="{
'background-color': account.color,
color: findContrastColor(account.color)
backgroundColor: account.color ?? undefined,
color: findContrastColor(account.color ?? null) ?? undefined
}"
>
<td colspan="2" class="total">total</td>
<td class="numeric">
{{ total | moneypad(account.mainCurrency) }}
<td colspan="2" class="font-bold">total</td>
<td class="numeric text-right">
{{ moneypad(total, account.mainCurrency) }}
</td>
</tr>
</tfoot>
@@ -81,82 +109,3 @@
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import IAccount from '@/models/IAccount'
import ITransaction from '@/models/ITransaction'
import { findContrastColor } from '@/utils'
@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 findContrastColor = findContrastColor
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
}
public get hasMultipleCurrencies(): boolean {
return (this.account && this.account.currencies.length > 1) || false
}
}
</script>
<style lang="scss" scoped>
.account-transaction-list {
margin: auto;
}
.table-container {
overflow-x: auto;
}
.multiline {
line-height: 15px;
}
.date {
font-style: italic;
font-size: 14px;
}
th {
&.align {
text-align: center;
}
}
</style>

View File

@@ -1,102 +1,80 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import type IUser from '@/models/IUser'
import { useUserStore } from '@/stores/user'
import UserNew from '@/components/UserNew.vue'
const users = defineModel<IUser[]>({ required: true })
const props = withDefaults(
defineProps<{ defaultUser?: boolean; showTitle?: boolean }>(),
{ defaultUser: true, showTitle: true }
)
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { t } = useI18n()
const localeUsers = ref<IUser[]>([])
const newUserId = ref('')
onMounted(() => {
localeUsers.value = users.value
})
watch(users, (value) => {
localeUsers.value = value
})
watch(localeUsers, (value) => {
users.value = value
}, { deep: true })
const newUser = () => {
if (!newUserId.value) return
localeUsers.value.push({
userId: newUserId.value,
email: '',
premium: user.value ? user.value.premium : false,
slugEmail: '',
alias: newUserId.value,
firstname: '',
lastname: ''
})
newUserId.value = ''
}
const remove = (index: number) => {
localeUsers.value = localeUsers.value.filter((_u, i) => i !== index)
}
</script>
<template>
<div class="account-user-new">
<h3 v-if="showTitle" class="subtitle is-3">
{{
$tc('account.friends', localeUsers.length, {
count: localeUsers.length
})
}}
<h3 v-if="showTitle" class="text-2xl font-semibold mb-3">
{{ t('account.friends', { count: localeUsers.length }, localeUsers.length) }}
</h3>
<user-new v-if="user && defaultUser" v-model="user" />
<div v-for="(user, k) in localeUsers" :key="k">
<user-new v-model="localeUsers[k]" @remove="() => remove(k)" />
<UserNew v-if="user && defaultUser" v-model="user" />
<div v-for="(u, k) in localeUsers" :key="k">
<UserNew 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"
:disabled="!newUserId"
@click.prevent="newUser"
>
ajouter
</button>
</div>
<div class="join mt-4 w-full">
<input
v-model="newUserId"
type="text"
placeholder="nouvel ami"
class="input input-bordered join-item flex-1"
@keyup.enter.prevent="newUser"
/>
<button
type="button"
class="btn btn-primary join-item"
:disabled="!newUserId"
@click.prevent="newUser"
>
ajouter
</button>
</div>
</div>
</template>
<script lang="ts">
import { Component, Model, Prop, 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[]
@Prop({ type: Boolean, default: true })
public defaultUser!: boolean
@Prop({ type: Boolean, default: true })
public showTitle!: boolean
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('users')
public onUsersChange(users: IUser[]): void {
this.localeUsers = users
}
@Watch('localeUsers')
public onUserChange(users: IUser[]): void {
this.$emit('input', users)
}
}
</script>

View File

@@ -1,21 +1,18 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const isInstalled = ref(false)
onMounted(() => {
isInstalled.value =
typeof window.matchMedia === 'function' &&
window.matchMedia('(display-mode: standalone)').matches
})
</script>
<template>
<span>
<slot v-if="isInstalled" name="app-installed"></slot>
<slot v-else name="app-not-installed"></slot>
<slot v-if="isInstalled" name="app-installed" />
<slot v-else name="app-not-installed" />
</span>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class AppInstalled extends Vue {
private isInstalled: boolean = false
private mounted() {
this.isInstalled =
window.matchMedia &&
window.matchMedia('(display-mode: standalone)').matches
}
}
</script>

View File

@@ -1,13 +1,17 @@
<script setup lang="ts">
import { COFFEE_LINK, BRAVE_LINK } from '@/utils/constants'
import cloudOffIcon from '@/assets/icons/baseline-cloud_off-24px.svg'
const coffee = COFFEE_LINK
const brave = BRAVE_LINK
</script>
<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 v-once class="app-resume">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 text-justify text-base md:text-lg">
<div class="p-6">
<div class="text-center text-4xl mb-2">
<img :src="cloudOffIcon" alt="offline" class="inline-block w-10 h-10" />
</div>
<p>
Vos données sont hors-lignes. Utilisez Vaquant vous voulez, seul ou
@@ -15,12 +19,12 @@
connexion est disponible afin de les synchroniser avec vos amis.
</p>
</div>
<div class="column is-one-third">
<div class="main-icon">
<vaquant-icon icon="users"></vaquant-icon>
<div class="p-6">
<div class="text-center text-4xl mb-2">
<vaquant-icon icon="users" />
</div>
<p>Choisissez de rendre le compte privé ou public :</p>
<ul>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>
Ainsi, seules les personnes du compte privé accéderont aux dépenses.
</li>
@@ -30,17 +34,8 @@
</li>
</ul>
</div>
<!-- <div class="column is-one-third">
<div class="main-icon">
<vaquant-icon icon="user-shield"/>
</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">
<div class="p-6">
<div class="text-center text-4xl mb-2">
<vaquant-icon icon="currency-euro" />&nbsp;
<vaquant-icon icon="arrows-exchange" />&nbsp;
<vaquant-icon icon="currency-dollar" />
@@ -51,8 +46,8 @@
le jour.
</p>
</div>
<div class="column is-one-third">
<div class="main-icon">
<div class="p-6">
<div class="text-center text-4xl mb-2">
<vaquant-icon icon="refresh" />
</div>
<p>
@@ -60,8 +55,8 @@
nouveaux comptes et dépenses sans même vous en rendre compte.
</p>
</div>
<div class="column is-one-third">
<div class="main-icon">
<div class="p-6">
<div class="text-center text-4xl mb-2">
<vaquant-icon icon="device-mobile" />
</div>
<p>
@@ -69,8 +64,8 @@
lien internet&nbsp;! Votre partenaire idéal pour vos voyages&nbsp;!
</p>
</div>
<div class="column is-one-third">
<div class="main-icon">
<div class="p-6">
<div class="text-center text-4xl mb-2">
<vaquant-icon icon="pig-money" />
</div>
<p>
@@ -87,39 +82,3 @@
</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

@@ -1,29 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import chartService from '@/services/ChartService'
import type IStat from '@/models/IStat'
const props = defineProps<{
stat: IStat
index: number
total: number
}>()
const point = computed(() =>
chartService.valueToPoint(props.stat.percent + 10, props.index, props.total)
)
</script>
<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

@@ -1,29 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import type IAccount from '@/models/IAccount'
import type ICurrency from '@/models/ICurrency'
import type IStat from '@/models/IStat'
import { findContrastColor } from '@/utils'
import { money } from '@/utils/format'
const props = defineProps<{
account: IAccount
stats: IStat[]
currency: ICurrency
}>()
const statOrdered = computed(() =>
[...props.stats].sort((a, b) => (a.balance.amount < b.balance.amount ? 1 : -1))
)
const totalCost = computed(() =>
props.stats.reduce((a, b) => a + b.value, 0)
)
const colorStyle = computed(() => {
if (!props.account) return {}
return {
backgroundColor: props.account.color ?? undefined,
color: findContrastColor(props.account.color ?? null) ?? undefined
}
})
const getColor = (amount: number): string =>
amount === 0 ? 'text-primary' : amount > 0 ? 'text-success' : 'text-error'
</script>
<template>
<div class="chart-balance">
<table class="table is-hoverable is-striped" v-if="stats.length">
<div class="chart-balance overflow-x-auto">
<table v-if="stats.length" class="table table-zebra mx-auto">
<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 class="numeric font-bold text-right">{{ money(stat.value, currency) }}</td>
<td
v-if="stat.balance.amount !== 0"
class="numeric"
class="numeric font-bold text-right"
:class="getColor(stat.balance.amount)"
>
{{ stat.balance.amount | money(currency) }}
{{ money(stat.balance.amount, currency) }}
</td>
<td v-else class="text-right" :class="getColor(stat.balance.amount)">
👌🏾
</td>
<td
v-else
class="conclude"
:class="getColor(stat.balance.amount)"
></td>
</tr>
</tbody>
<tfoot>
<tr :style="colorStyle">
<th :style="colorStyle" scope="row" class="total">total</th>
<th :style="colorStyle" class="numeric" colspan="2">
{{ totalCost | money(currency) }}
<th :style="colorStyle" scope="row" class="font-bold">total</th>
<th :style="colorStyle" class="numeric text-right" colspan="2">
{{ money(totalCost, currency) }}
</th>
</tr>
</tfoot>
@@ -31,76 +63,3 @@
<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'
import IAccount from '../models/IAccount'
import { findContrastColor } from '@/utils'
@Component({
components: {
'axis-label': () => import('@/components/AxisLabel.vue')
}
})
export default class ChartBalance extends Vue {
@Prop({ type: Object, required: true })
public account!: IAccount
@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 colorStyle() {
if (!this.account) {
return {}
}
return {
backgroundColor: this.account.color,
color: findContrastColor(this.account.color)
}
}
public get statOrdered(): IStat[] {
return [...this.stats].sort((a: IStat, b: IStat) =>
a.balance.amount < b.balance.amount ? 1 : -1
)
}
public get totalCost(): number {
return this.stats.reduce((a: number, b: IStat) => a + b.value, 0)
}
}
</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

@@ -1,85 +1,59 @@
<template>
<svg class="chart-pie" ref="chart" id="chart-pie" viewBox="-1 -1 2 2"></svg>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import type ISlice from '@/models/ISlice'
<script lang="ts">
// https://codepen.io/soluhmin/pen/jexywg
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
import ISlice from '@/models/ISlice'
const props = defineProps<{ slices: ISlice[] }>()
const chart = ref<SVGSVGElement | null>(null)
@Component
export default class ChartPie extends Vue {
@Prop({ type: Array, default: () => [] })
public slices!: ISlice[]
public svgEl: Element | null = null
const getCoordinatesForPercent = (percent: number): [number, number] => [
Math.cos(2 * Math.PI * percent),
Math.sin(2 * Math.PI * percent)
]
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()
}
const clearSvg = () => {
const svg = chart.value
if (!svg) return
while (svg.lastChild) svg.removeChild(svg.lastChild)
}
const constructChart = () => {
const svg = chart.value
if (!svg) return
clearSvg()
let cumulativePercent = 0
props.slices.forEach((slice) => {
const [startX, startY] = getCoordinatesForPercent(cumulativePercent)
cumulativePercent += slice.percent
const [endX, endY] = getCoordinatesForPercent(cumulativePercent)
const largeArcFlag = slice.percent > 0.5 ? 1 : 0
const pathData = [
`M ${startX} ${startY}`,
`A 1 1 0 ${largeArcFlag} 1 ${endX} ${endY}`,
`L 0 0`
].join(' ')
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path')
pathEl.setAttribute('d', pathData)
pathEl.setAttribute('fill', slice.color)
svg.appendChild(pathEl)
})
}
onMounted(constructChart)
watch(() => props.slices, constructChart, { deep: true })
</script>
<template>
<svg
id="chart-pie"
ref="chart"
class="chart-pie"
viewBox="-1 -1 2 2"
/>
</template>
<style scoped>
.chart-pie {
transform: rotate(-90deg);
/* height: 200px; */
max-width: 400pt;
}
</style>

View File

@@ -1,36 +1,22 @@
<script setup lang="ts">
import type ISlice from '@/models/ISlice'
defineProps<{ slices: ISlice[] }>()
</script>
<template>
<div class="chart-progress">
<div class="chart-progress flex items-center justify-center mx-auto flex-1">
<div
v-for="slice in slices"
:key="slice.key"
class="chart-progress-bar"
:style="{ flex: slice.percent, backgroundColor: slice.color }"
></div>
/>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import ISlice from '@/models/ISlice'
@Component
export default class ChartProgress extends Vue {
@Prop({ type: Array, default: () => [] })
public slices!: ISlice[]
}
</script>
<style scoped lang="scss">
$percent-height: 1.2rem;
.chart-progress {
margin: auto;
display: flex;
flex: 1;
justify-content: center;
align-items: center;
div {
height: $percent-height;
}
<style scoped>
.chart-progress-bar {
height: 1.2rem;
}
</style>

View File

@@ -1,109 +1,80 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type IColor from '@/models/IColor'
import colors from '@/data/colors'
import { findContrastColor } from '@/utils'
const model = defineModel<string | null>({ default: null })
const emit = defineEmits<{ 'color-change': [] }>()
const localColor = ref<string | null>(model.value)
const colorLabel = ref<string | null>(null)
onMounted(() => {
localColor.value = model.value
if (model.value) {
const color = colors.find((c: IColor) => c.value === model.value)
if (color) colorLabel.value = color.label
} else {
localColor.value = colors[0].value ?? null
}
})
const select = (color: IColor) => {
localColor.value = color.value
colorLabel.value = color.label
}
const colorBackground = (color: IColor) => ({
border: !color.label ? '1px solid #95afc0' : '',
backgroundColor: color.value ?? ''
})
const colorStyle = computed(() => ({
color: findContrastColor(localColor.value) ?? ''
}))
watch(localColor, (value) => {
if (model.value !== value) {
model.value = value
emit('color-change')
}
})
</script>
<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)"
>
<vaquant-icon
:style="colorStyle"
v-if="color.value === localColor"
icon="check"
/>
</div>
<h3 class="text-2xl font-semibold mb-3">Couleur</h3>
<div class="flex flex-wrap justify-center gap-3">
<div
v-for="(color, k) in colors"
:key="k"
class="color-container"
:class="{ white: color.label }"
:style="colorBackground(color)"
@click.prevent="select(color)"
>
<vaquant-icon
v-if="color.value === localColor"
:style="colorStyle"
icon="check"
/>
</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'
import { findContrastColor } from '../utils'
@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
}
}
public get colorStyle() {
return {
color: findContrastColor(this.localColor)
}
}
@Watch('localColor')
public onColorChange(color: string | null, oldColor: string | null) {
if (this.color !== color) {
this.$emit('input', color)
this.$emit('color-change')
}
}
}
</script>
<style lang="scss" scoped>
<style scoped>
.color-container {
border-radius: 6px;
width: 50px;
height: 50px;
line-height: 50px;
font-size: 25px;
margin: auto;
&.white {
color: white;
}
&:hover {
cursor: pointer;
}
text-align: center;
cursor: pointer;
}
.input {
width: 100px;
}
.button.is-static {
width: 50px;
.color-container.white {
color: white;
}
</style>

View File

@@ -1,51 +1,46 @@
<script setup lang="ts">
import { ref, computed, useTemplateRef } from 'vue'
import { onClickOutside } from '@vueuse/core'
const emit = defineEmits<{ confirm: [done: () => void] }>()
const root = useTemplateRef<HTMLAnchorElement>('root')
const loading = ref(false)
const firstTap = ref(true)
const resetTap = () => {
firstTap.value = true
}
onClickOutside(root, resetTap)
const click = () => {
if (loading.value) return
if (firstTap.value) {
firstTap.value = false
return
}
loading.value = true
firstTap.value = true
emit('confirm', () => {
loading.value = false
})
}
const confirmMessage = computed(() => (firstTap.value ? '' : 'Confirmer pour valider'))
</script>
<template>
<a
ref="root"
href="#"
class="button"
:class="{ 'is-loading': loading }"
class="btn"
:class="{ 'btn-loading': loading }"
@click.prevent="click"
v-click-outside="resetTap"
>
<span v-show="confirmMessage">{{ confirmMessage }}</span>
<span v-show="!confirmMessage">
<slot></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,50 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useCurrencyInput } from 'vue-currency-input'
const props = withDefaults(
defineProps<{
modelValue: number | null
locale?: string
precision?: number
allowNegative?: boolean
valueRange?: { min?: number; max?: number }
placeholder?: string
}>(),
{
locale: 'fr-FR',
precision: 2,
allowNegative: false,
valueRange: () => ({ min: 0, max: 1_000_000 }),
placeholder: ''
}
)
defineEmits<{ 'update:modelValue': [value: number | null] }>()
const { inputRef, setValue } = useCurrencyInput({
currency: undefined as unknown as string,
locale: props.locale,
precision: props.precision,
hideCurrencySymbolOnFocus: false,
hideGroupingSeparatorOnFocus: false,
valueRange: props.valueRange,
autoDecimalDigits: false
})
watch(
() => props.modelValue,
(value) => {
setValue(value)
}
)
</script>
<template>
<input
ref="inputRef"
type="text"
class="input input-bordered w-full"
:placeholder="placeholder"
/>
</template>

View File

@@ -1,119 +1,92 @@
<template>
<section id="earth" class="earth-container"></section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import mapboxgl from 'mapbox-gl/dist/mapbox-gl'
<script setup lang="ts">
import { onMounted } from 'vue'
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { debounce } from 'lodash-es'
import ILocation from '@/models/ILocation'
import type ILocation from '@/models/ILocation'
@Component
export default class EarthMap extends Vue {
@Prop({ type: Array, default: () => [] })
private locations!: ILocation[]
@Prop({ type: Boolean, default: false })
private defineLocation!: boolean
private earth: any | null = null
private markerLocation: any | null = null
private emit = debounce((earthMap: EarthMap) => {
if (!earthMap.markerLocation) {
return
const props = withDefaults(
defineProps<{
locations?: ILocation[]
defineLocation?: boolean
}>(),
{ locations: () => [], defineLocation: false }
)
const emit = defineEmits<{ located: [location: { lat: number; lon: number }] }>()
let earth: mapboxgl.Map | null = null
let markerLocation: mapboxgl.Marker | null = null
const fireLocated = debounce(() => {
if (!markerLocation) return
const lnglat = markerLocation.getLngLat()
emit('located', { lat: lnglat.lat, lon: lnglat.lng })
}, 150)
const initEarthMap = () => {
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_KEY ?? ''
const latitudes = props.locations.map((l) => l.lat)
const longitudes = props.locations.map((l) => l.lon)
const bounds = props.locations.length
? ([[Math.min(...longitudes), Math.min(...latitudes)], [Math.max(...longitudes), Math.max(...latitudes)]] as [
[number, number],
[number, number]
])
: undefined
earth = new mapboxgl.Map({
container: 'earth',
style: 'mapbox://styles/mapbox/streets-v12',
bounds,
fitBoundsOptions: { padding: 30, maxZoom: 12 }
})
earth.addControl(new mapboxgl.NavigationControl())
const geolocControl = new mapboxgl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true
})
earth.addControl(geolocControl)
if (!props.defineLocation) return
geolocControl.trigger()
const el = document.createElement('i')
el.classList.add('ti', 'ti-map-pin', 'marker', 'marker-location')
markerLocation = new mapboxgl.Marker(el).setLngLat(earth.getCenter()).addTo(earth)
earth.on('move', () => {
if (earth && markerLocation) {
markerLocation.setLngLat(earth.getCenter())
fireLocated()
}
const lnglat = earthMap.markerLocation.getLngLat()
earthMap.$emit('located', {
lat: lnglat.lat,
lon: lnglat.lng
})
}, 150)
private mounted() {
this.initEarthMap()
if (!this.earth) {
return
}
const markers = this.locations.map((location) => [
location.lon,
location.lat
])
markers.forEach((marker) => {
const el = document.createElement('i')
el.classList.add('marker', 'gg-pin-alt')
new mapboxgl.Marker(el).setLngLat(marker).addTo(this.earth)
})
}
private initEarthMap() {
mapboxgl.accessToken = process.env.VUE_APP_MAPBOX_KEY
const latitudes = this.locations.map((location: ILocation) => location.lat)
const longitudes = this.locations.map((location: ILocation) => location.lon)
const minLat = Math.min(...latitudes)
const maxLat = Math.max(...latitudes)
const minLon = Math.min(...longitudes)
const maxLon = Math.max(...longitudes)
// [sw, ne]
const bounds = this.locations.length
? [
[minLon, minLat],
[maxLon, maxLat]
]
: undefined
this.earth = new mapboxgl.Map({
container: 'earth',
style: 'mapbox://styles/mapbox/streets-v11',
bounds,
fitBoundsOptions: {
padding: 30,
maxZoom: 12
}
})
this.earth.addControl(new mapboxgl.NavigationControl())
const geolocControl = new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true
})
this.earth.addControl(geolocControl)
if (!this.defineLocation) {
return
}
geolocControl.trigger()
const el = document.createElement('i')
el.classList.add('gg-pin-alt', 'marker', 'marker-location')
this.markerLocation = new mapboxgl.Marker(el)
.setLngLat(this.earth.getCenter())
.addTo(this.earth)
this.earth.on('move', (evt: any) => {
this.markerLocation.setLngLat(this.earth.getCenter())
this.emit(this)
})
}
})
}
onMounted(() => {
initEarthMap()
if (!earth) return
props.locations.forEach((loc) => {
const el = document.createElement('i')
el.classList.add('ti', 'ti-map-pin', 'marker')
new mapboxgl.Marker(el).setLngLat([loc.lon, loc.lat]).addTo(earth!)
})
})
</script>
<style lang="scss">
@import '../styles/earth-map.css';
<template>
<section id="earth" class="earth-container" />
</template>
<style scoped>
.earth-container {
min-height: 50vh;
height: 100%;
}
.marker-location {
color: var(--primary-color);
:global(.marker-location) {
color: var(--color-primary);
z-index: 1;
}
</style>
</style>

View File

@@ -1,133 +1,112 @@
<script setup lang="ts">
import { ref, computed, onMounted, useSlots } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { throttle } from 'lodash-es'
const props = withDefaults(
defineProps<{
margin?: boolean
to?: RouteLocationRaw | null
buttonStyle?: Record<string, string | number>
}>(),
{ margin: false, to: null, buttonStyle: () => ({}) }
)
const emit = defineEmits<{ valid: [done: () => void] }>()
const slots = useSlots()
const uniqueId = Date.now().toString()
const isLoading = ref(false)
const showFull = ref(true)
const buttonClass = computed(() => ({ 'btn-loading': isLoading.value }))
const valid = () => {
isLoading.value = true
emit('valid', () => {
isLoading.value = false
})
}
onMounted(() => {
if (!slots.fulltext) return
const main = document.getElementById('main')
const fabElm = document.getElementById(`fab-${uniqueId}`)
if (!main || !fabElm) return
document.addEventListener(
'scroll',
throttle(() => {
showFull.value = main.getBoundingClientRect().top > 0
fabElm.classList.toggle('fab-small', !showFull.value)
}, 150)
)
})
</script>
<template>
<div class="fab-button" :id="`fab-${uniqueId}`">
<div :id="`fab-${uniqueId}`" class="fab-button">
<router-link
v-if="to"
class="button is-primary is-large is-fab"
:class="getClass"
class="btn btn-primary btn-lg btn-circle fab-fixed"
:class="buttonClass"
:style="buttonStyle"
:to="to"
@click.native="valid"
@click="valid"
>
<span class="icon is-medium">
<span class="fab-icon">
<slot>
<vaquant-icon icon="plus" />
</slot>
</span>
<span class="fulltext" v-if="$slots.fulltext">
<slot name="fulltext"></slot>
<span v-if="slots.fulltext" class="fab-fulltext">
<slot name="fulltext" />
</span>
</router-link>
<button
v-else
type="submit"
class="button is-primary is-large is-fab"
:class="getClass"
class="btn btn-primary btn-lg btn-circle fab-fixed"
:class="buttonClass"
:style="buttonStyle"
@click="valid"
>
<span class="icon is-medium">
<span class="fab-icon">
<slot>
<vaquant-icon icon="plus" />
</slot>
</span>
<span class="fulltext" v-if="$slots.fulltext">
<slot name="fulltext"></slot>
<span v-if="slots.fulltext" class="fab-fulltext">
<slot name="fulltext" />
</span>
</button>
<div class="fab-margin" v-if="margin"></div>
<div v-if="margin" class="fab-margin" />
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import { throttle } from 'lodash-es'
@Component
export default class FabButton 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 {
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;
<style scoped>
.fab-fixed {
position: fixed;
right: $padding-fab;
z-index: 5;
border: 2px solid $white;
bottom: 24px;
right: 24px;
z-index: 50;
border: 2px solid white;
}
.fab-icon {
font-size: 1.25rem;
}
.fab-fulltext {
display: inline-block;
max-width: 450px;
margin-left: 0.5rem;
opacity: 1;
transition: max-width 0.3s, opacity 0.3s;
}
.fab-button.fab-small .fab-fulltext {
max-width: 0;
opacity: 0;
margin-left: 0;
}
.fab-margin {
margin: calc(#{$padding-fab} * 2 + 30px) 0 calc(#{$padding-fab} * 2);
margin: calc(24px * 2 + 30px) 0 calc(24px * 2);
}
</style>

View File

@@ -1,12 +0,0 @@
<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

@@ -1,72 +1,47 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { locale: storedLocale } = storeToRefs(userStore)
const { locale: i18nLocale } = useI18n()
const lang = ref(storedLocale.value)
const locales = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' }
]
watch(lang, (value) => {
if (value !== storedLocale.value) {
userStore.setLocale(value)
}
if (value !== i18nLocale.value) {
i18nLocale.value = value
}
})
watch(
storedLocale,
(value) => {
if (value !== lang.value) {
lang.value = value
}
},
{ immediate: true }
)
</script>
<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 class="lang-changer flex flex-row items-center gap-3">
<label class="label">Changer de langue</label>
<select v-model="lang" name="lang-changer" class="select select-bordered">
<option v-for="locale in locales" :key="locale.code" :value="locale.code">
{{ locale.label }}
</option>
</select>
</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

@@ -1,31 +1,30 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const emit = defineEmits<{ online: []; offline: [] }>()
const online = ref(navigator.onLine)
const onchange = () => {
online.value = navigator.onLine
if (online.value) emit('online')
else emit('offline')
}
onMounted(() => {
window.addEventListener('online', onchange)
window.addEventListener('offline', onchange)
onchange()
})
onBeforeUnmount(() => {
window.removeEventListener('online', onchange)
window.removeEventListener('offline', onchange)
})
</script>
<template>
<div class="online-view">
<slot v-if="online"></slot>
<slot v-else name="offline"></slot>
<slot v-if="online" />
<slot v-else name="offline" />
</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.onchange()
}
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

@@ -1,64 +0,0 @@
<template>
<div class="payment">
<stripe-checkout
ref="checkoutRef"
:pk="publishableKey"
:items="items"
:successUrl="successUrl"
:cancelUrl="cancelUrl"
:clientReferenceId="clientReferenceId"
:customerEmail="email"
>
<template slot="checkout-button">
<button class="button is-primary" @click="pay">
Payer par carte bleue
</button>
</template>
</stripe-checkout>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Action, Getter } from 'vuex-class'
import { StripeCheckout } from 'vue-stripe-checkout'
import IUser from '@/models/IUser'
import IPaymentIntent from '@/models/IPaymentIntent'
import IPayment from '@/models/IPayment'
@Component({
components: {
StripeCheckout
}
})
export default class Payment extends Vue {
@Getter
public user!: IUser | null
private publishableKey = 'pk_test_CO1FMasxNSwIX0P9FgzmDMyp'
private successUrl = window.location.href
private cancelUrl = window.location.href
private items = [
{
plan: 'plan_GmrqYW6obrGUE6',
quantity: 1
}
]
private checkoutRef: any = null
public async mounted() {
this.checkoutRef = this.$refs.checkoutRef
}
private async pay() {
this.checkoutRef.redirectToCheckout()
}
private get clientReferenceId() {
return this.user?.userId ?? ''
}
private get email() {
return this.user?.email ?? ''
}
}
</script>

View File

@@ -1,77 +0,0 @@
<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>2</span
>/mois
</div>
<div class="plan-items">
<div class="plan-item"><vaquant-icon icon="infinity" /> comptes</div>
<div class="plan-item"><vaquant-icon icon="infinity" /> amis/compte</div>
<div class="plan-item">
<vaquant-icon icon="infinity" /> dépenses/compte
</div>
<div class="plan-item">pièces jointes disponibles</div>
</div>
<div class="plan-footer">
<button class="button is-fullwidth" @click="choose('premium')">
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>
<style lang="scss">
@import '../styles/variables';
@import '~bulma-pricingtable';
</style>

View File

@@ -1,40 +1,39 @@
<template>
<a href="#" class="queue-notif" v-if="newVersion" @click.prevent="reloadApp">
nouvelle version disponible
</a>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import queueNotifService from '@/services/QueueNotifService'
import notif from '@/utils/notif'
@Component
export default class QueueNotif extends Vue {
private newVersion = queueNotifService.getNewVersion
const newVersion = ref(false)
private mounted() {
queueNotifService.subscribe((notification) => {
switch (notification.type) {
case 'success':
notif.success(notification.message)
break
case 'error':
notif.error(notification.message)
break
}
})
queueNotifService.subscribeToNewVersion((newVersion: boolean) => {
this.newVersion = newVersion
})
}
onMounted(() => {
queueNotifService.subscribe((notification) => {
if (notification.type === 'success') {
notif.success(notification.message)
} else if (notification.type === 'error') {
notif.error(notification.message)
}
})
queueNotifService.subscribeToNewVersion((hasNew: boolean) => {
newVersion.value = hasNew
})
})
private beforeDestroy() {
queueNotifService.unsubscribe()
}
onBeforeUnmount(() => {
queueNotifService.unsubscribe()
})
private reloadApp() {
location.reload(true)
}
const reloadApp = () => {
location.reload()
}
</script>
<template>
<a
v-if="newVersion"
href="#"
class="queue-notif alert alert-info"
@click.prevent="reloadApp"
>
nouvelle version disponible
</a>
</template>

View File

@@ -1,165 +1,95 @@
<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'
<script setup lang="ts">
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import bus, { SYNC } from '@/utils/bus-event'
import ClickOutside from 'vue-click-outside'
import { useUserStore } from '@/stores/user'
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'
import { money } from '@/utils/format'
import type IAccount from '@/models/IAccount'
import type IRefund from '@/models/IRefund'
import type ITransaction from '@/models/ITransaction'
import TransactionTag from '@/enums/TransactionTag'
import TransactionType from '@/enums/TransactionType'
import ConfirmButton from '@/components/ConfirmButton.vue'
@Component({
directives: {
ClickOutside
},
components: {
'confirm-button': () => import('@/components/ConfirmButton.vue')
}
const props = defineProps<{ refund: IRefund; account: IAccount }>()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const refunded = ref(false)
const canRefund = computed(() => {
if (!user.value || !props.account.admin) return true
return [props.account.admin.email, props.refund.from.email].includes(user.value.email)
})
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,
doctype: 'transaction',
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)
const fromMe = computed(
() => !!user.value && !!props.refund.from && props.refund.from.userId === user.value.userId
)
const toMe = computed(
() => !!user.value && !!props.refund.to && props.refund.to.userId === user.value.userId
)
const refundTo = async (done: () => void) => {
if (!props.account._id) {
done()
return
}
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)
const date = new Date()
const currencies = props.account.currencies.map((c) => c.code)
const transaction: ITransaction = {
accountId: props.account._id,
doctype: 'transaction',
name: 'Remboursement',
date,
tag: TransactionTag.None,
amount: props.refund.amount || 0,
payBy: props.refund.from.alias || '',
payFor: [{ alias: props.refund.to.alias || '', weight: 1 }],
userIds: props.account.userIds,
mainCurrency: props.account.mainCurrency,
currencies: props.account.currencies,
transactionType: TransactionType.refund,
exchange: await exchangeService.get(props.account.mainCurrency.code, date, currencies)
}
public get fromMe(): boolean {
if (!this.user || !this.refund.from) {
return false
}
return this.refund.from.userId === this.user.userId
}
public get toMe(): boolean {
if (!this.user || !this.refund.to) {
return false
}
return this.refund.to.userId === this.user.userId
const response = await transactionService.add(props.account._id, transaction)
if (response.ok) {
refunded.value = true
confirmation('Remboursement effectué avec succès')
}
bus.emit(SYNC)
done()
}
</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>
<template>
<div class="refund-transaction card bg-base-100 shadow my-2">
<div class="card-body">
<div v-if="refunded">Somme remboursée...</div>
<div v-else>
<span v-if="toMe">
<span class="font-bold">{{ refund.from.alias }}</span> vous doit
<span class="numeric">{{ money(refund.amount, account.mainCurrency) }}</span>
</span>
<span v-else-if="fromMe">
Vous devez
<span class="numeric">{{ money(refund.amount, account.mainCurrency) }}</span> à
<span class="font-bold">{{ refund.to.alias }}</span>
</span>
<span v-else>
<span class="font-bold">{{ refund.from.alias }}</span> doit
<span class="numeric">{{ money(refund.amount, account.mainCurrency) }}</span> à
<span class="font-bold">{{ refund.to.alias }}</span>
</span>
</div>
</div>
<div v-if="canRefund && !refunded" class="card-actions justify-end p-2 border-t">
<ConfirmButton class="btn-ghost btn-block" @confirm="refundTo">
Rembourser
</ConfirmButton>
</div>
</div>
</template>

View File

@@ -1,210 +1,131 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import colors from '@/data/colors'
import type IAccount from '@/models/IAccount'
import type ITransaction from '@/models/ITransaction'
import type ISlice from '@/models/ISlice'
import TransactionTag, { type ITagLabel, TransactionTagLabel } from '@/enums/TransactionTag'
import ChartProgress from '@/components/Charts/ChartProgress.vue'
import AccountTransactionList from '@/components/AccountTransactionList.vue'
interface ICategory {
[key: string]: { tag: ITagLabel; transactions: ITransaction[] }
}
const props = withDefaults(
defineProps<{ account: IAccount; transactions?: ITransaction[] }>(),
{ transactions: () => [] }
)
const { t } = useI18n()
const filterCategory = ref('')
const categories = computed<ICategory>(() =>
props.transactions.reduce<ICategory>((acc, t) => {
if (!acc[t.tag]) {
acc[t.tag] = {
tag: TransactionTagLabel[t.tag] ?? TransactionTagLabel[TransactionTag.None],
transactions: []
}
}
acc[t.tag].transactions.push(t)
return acc
}, {})
)
const filteredCategories = computed<ICategory>(() => {
if (filterCategory.value && filterCategory.value in categories.value) {
return { [filterCategory.value]: categories.value[filterCategory.value] }
}
return categories.value
})
const slices = computed<ISlice[]>(() => {
const tags = Object.keys(categories.value)
const total = props.transactions.reduce((t, b) => t + parseFloat((b.amount || 0).toString()), 0)
return tags
.map((tag, index) => ({
percent:
categories.value[tag].transactions.reduce((t, b) => t + b.amount, 0) /
(total || 1),
color: colors[index]?.value || '#ffffff',
darkColor: colors[index]?.darkValue || '#ffffff',
key: tag,
label: categories.value[tag].tag.label
}))
.sort((a, b) => (a.percent >= b.percent ? -1 : 1))
})
const legendStyle = (slice: ISlice) => ({ color: slice.color })
</script>
<template>
<div class="tag-list no-margin">
<div class="columns no-margin is-vcentered is-centered is-multiline">
<div class="column is-one-third">
<chart-progress :slices="slices" />
<div class="tag-list">
<div class="grid md:grid-cols-3 gap-4 items-center">
<div>
<ChartProgress :slices="slices" />
</div>
<div class="column legend-container is-12">
<table class="table is-fullwidth">
<tr
v-for="(slice, k) in slices"
:key="k"
:style="legendStyle(slice)"
class="legend-item"
>
<td>
<vaquant-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>
<div class="md:col-span-2 flex justify-center">
<table class="table max-w-md">
<tbody>
<tr
v-for="(slice, k) in slices"
:key="k"
:style="legendStyle(slice)"
>
<td>
<vaquant-icon :icon="TransactionTagLabel[slice.key as TransactionTag]?.icon || 'tag'" />
</td>
<td class="text-center">{{ slice.label }}</td>
<td class="numeric text-right">
{{ Math.round(slice.percent * 100) }}%
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<div class="field has-addons has-addons-centered is-narrow">
<div class="control" v-if="filterCategory">
<button type="submit" class="button" @click="filterCategory = ''">
<vaquant-icon icon="x" />
</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 class="mt-6">
<div class="join justify-center mb-4 mx-auto flex">
<button
v-if="filterCategory"
type="submit"
class="btn join-item"
@click="filterCategory = ''"
>
<vaquant-icon icon="x" />
</button>
<select v-model="filterCategory" class="select select-bordered join-item">
<option value="">Toutes</option>
<option v-for="(category, k) in categories" :key="k" :value="k">
{{ category.tag.label }}
</option>
</select>
</div>
<div class="columns clear-margin is-centered is-multiline">
<div class="grid md:grid-cols-2 gap-6">
<div
v-for="(category, k) in filteredCategories"
:key="k"
class="category-list-container column is-half no-padding"
class="category-list flex gap-4 border-b border-base-300 pb-4"
>
<div class="columns clear-margin category-list">
<div class="column is-2 tag-element account-color">
<div class="icon-container">
<vaquant-icon :icon="category.tag.icon" />
</div>
</div>
<div
class="column transaction-list no-padding"
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 class="text-4xl flex items-start sticky top-16">
<vaquant-icon :icon="category.tag.icon" />
</div>
<div v-if="category.transactions.length" class="flex-1">
<p class="mb-2 opacity-70">
{{ t('account.total', { count: category.transactions.length }, category.transactions.length) }}
</p>
<AccountTransactionList
:filter="false"
:head="false"
:foot="true"
:account="account"
:transactions="category.transactions"
/>
</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-progress': () => import('@/components/Charts/ChartProgress.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;
flex-flow: column;
font-size: 30pt;
}
.icon-container {
position: sticky;
top: calc(3.25rem + 15px);
}
.legend-container {
display: flex;
table {
max-width: 400px;
}
}
.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

@@ -1,6 +1,194 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, onBeforeMount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { format } from 'date-fns'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import type IAccount from '@/models/IAccount'
import type ICurrency from '@/models/ICurrency'
import type ITransaction from '@/models/ITransaction'
import type ISplit from '@/models/ISplit'
import type ILocation from '@/models/ILocation'
import TransactionType from '@/enums/TransactionType'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
import exchangeService from '@/services/ExchangeService'
import transactionService from '@/services/TransactionService'
import queueNotifService from '@/services/QueueNotifService'
import mapService from '@/services/MapService'
import { money } from '@/utils/format'
import { findContrastColor } from '@/utils'
import notif from '@/utils/notif'
import FabButton from '@/components/FabButton.vue'
import TransactionSplit from '@/components/TransactionSplit.vue'
import TransactionTagUpdate from '@/components/TransactionTagUpdate.vue'
import EarthMap from '@/components/EarthMap.vue'
import CurrencyInput from '@/components/CurrencyInput.vue'
const props = defineProps<{
transaction: ITransaction
account: IAccount
}>()
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
const { locale } = storeToRefs(userStore)
const formatToDate = (d: Date | string) => format(new Date(d), 'yyyy-MM-dd')
const todayDate = new Date()
const name = ref('')
const amount = ref<number | null>(null)
const tag = ref<TransactionTag>(TransactionTag.None)
const today = formatToDate(todayDate)
const date = ref(today)
const currency = ref<ICurrency | null>(null)
const payBy = ref('')
const payFor = reactive<ISplit[]>([])
const maxAmount = 1_000_000
const displayLocationModal = ref(false)
const isSetLocation = ref(false)
const location = ref<ILocation | null>(null)
const noTag = TransactionTag.None
onBeforeMount(() => {
setData(props.transaction)
})
const setData = (t: ITransaction) => {
name.value = t.name
date.value = formatToDate(t.date)
tag.value = t.tag
amount.value = t.amount || null
payBy.value = t.payBy
payFor.splice(0, payFor.length, ...t.payFor)
if (props.account.users) {
props.account.users.forEach((u) => {
if (!t.payFor.find((p) => p.alias === u.alias)) {
payFor.push({ alias: u.alias || '', weight: 0 })
}
})
}
currency.value = t.mainCurrency
}
const payForUsers = computed(() => payFor.filter((p) => p.weight > 0))
const backgroundColor = computed(() => {
if (!props.account || !props.account.color) return undefined
return {
backgroundColor: props.account.color,
color: findContrastColor(props.account.color) || 'black'
}
})
watch(payBy, (newValue, oldValue) => {
if (oldValue && payFor.length !== props.account.users.length) {
const old = payFor.find((p) => p.alias === oldValue)
if (old) old.weight = 0
}
if (newValue) {
const newPayBy = payFor.find((p) => p.alias === newValue)
if (newPayBy) newPayBy.weight = newPayBy.weight || 1
}
})
const selectAll = () => {
payFor.forEach((p) => (p.weight = p.weight || 1))
}
const located = (loc: ILocation) => {
location.value = loc
}
const validLocation = async () => {
try {
isSetLocation.value = true
if (location.value) {
const place = await mapService.getPlace(location.value)
props.transaction.location = { ...location.value, place }
}
} catch {
props.transaction.location = props.transaction.location
} finally {
displayLocationModal.value = false
isSetLocation.value = false
}
}
const validate = (): boolean => {
const el = document.querySelector(':focus') as HTMLInputElement | null
el?.blur()
if (!name.value || !amount.value || !payBy.value || (!currency.value && !date.value && payFor.length === 0)) {
if (amount.value === 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
} else {
queueNotifService.error('Tous les champs sont requis.')
}
return false
}
if (isNaN(amount.value) || amount.value > maxAmount) {
queueNotifService.error(`Veuillez saisir un montant numérique inférieur à ${money(maxAmount, currency.value)}.`)
return false
}
if (amount.value < 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
return false
}
return true
}
const submitTransaction = async (finish: () => void) => {
if (!validate() || !currency.value || !props.account) {
finish()
return
}
const currencies = props.account.currencies.map((c) => c.code)
const d = new Date(date.value)
d.setHours(todayDate.getHours(), todayDate.getMinutes())
const hasId = !!props.transaction._id
const numericAmount = amount.value ? parseFloat(amount.value.toString().replace(',', '.')) : 0
const transaction: ITransaction = {
_id: props.transaction._id,
_rev: props.transaction._rev,
doctype: props.transaction.doctype,
accountId: props.account._id || '',
name: name.value,
date: d,
tag: tag.value,
amount: numericAmount,
payBy: payBy.value,
payFor: payForUsers.value,
userIds: props.account.userIds,
mainCurrency: currency.value,
currencies: props.account.currencies,
transactionType: TransactionType.normal,
location: props.transaction.location,
exchange: await exchangeService.get(currency.value.code, d, currencies)
}
const response = hasId
? await transactionService.save(transaction)
: await transactionService.add(props.account._id || '', transaction)
if (response.ok) {
if (hasId) {
router.push({ name: 'transaction', params: { id: props.transaction._id || '' } })
notif.success('Dépense mise à jour.')
} else {
router.push({ name: 'account', params: { id: props.account._id || '' } })
notif.success('Dépense créée.')
}
} else {
console.warn(response)
notif.error(`Une erreur s'est produite à la création d'une transaction.`)
}
finish()
}
</script>
<template>
<div class="transaction-create">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="account">
<nav v-if="account" class="breadcrumbs text-sm mb-3" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: 'account', params: { id: account._id } }">
@@ -8,9 +196,9 @@
</router-link>
</li>
<li v-if="tag && tag !== noTag">
<a href="#" @click.prevent>{{ transactionTagLabel[tag].label }}</a>
<a href="#" @click.prevent>{{ TransactionTagLabel[tag].label }}</a>
</li>
<li class="is-active">
<li>
<a href="#" @click.prevent>
<span v-if="transaction._id">{{ name }}</span>
<span v-else>Nouvelle dépense</span>
@@ -18,476 +206,136 @@
</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 class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<div class="flex items-center gap-3">
<label class="label w-24">Nom</label>
<div class="flex-1">
<input
v-model.trim="name"
type="text"
maxlength="30"
placeholder="Nom de la dépense"
class="input input-bordered w-full"
/>
<p class="text-xs text-info mt-1">
{{ t('validation.max_char', { max: 30 - name.length }, 30 - name.length) }}
</p>
</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">
<currency-input
v-model="amount"
placeholder="Montant"
class="input"
:currency="null"
:locale="locale"
:precision="2"
:allow-negative="false"
:value-range="{ 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 class="flex items-center gap-3">
<label class="label w-24">Montant</label>
<div class="flex-1">
<div class="join w-full">
<CurrencyInput
v-model="amount"
:locale="locale"
:precision="2"
:allow-negative="false"
:value-range="{ min: 0, max: 1000000 }"
placeholder="Montant"
class="join-item flex-1"
/>
<span v-if="account.currencies.length > 1" class="join-item">
<select v-model="currency" name="currency" class="select select-bordered">
<option v-for="(c, k) in account.currencies" :key="k" :value="c">
{{ c.symbol || c.name }}
</option>
</select>
</span>
<span v-else class="btn btn-disabled join-item">
{{ account.mainCurrency.symbol || account.mainCurrency.name }}
</span>
</div>
<p class="text-xs text-info mt-1">
montant max : {{ money(maxAmount, currency) }}
</p>
</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"
:class="{ 'has-addons': !!transaction.location }"
>
<div class="control set-location-input">
<input
type="text"
readonly
class="input"
v-if="transaction.location"
v-model="transaction.location.place"
/>
</div>
<div class="control set-location">
<button
class="button is-primary"
@click="displayLocationModal = true"
>
définir
</button>
</div>
</div>
<div class="flex items-center gap-3">
<label class="label w-24">Localisation</label>
<div class="flex-1 flex gap-2">
<input
v-if="transaction.location"
:value="transaction.location.place"
type="text"
readonly
class="input input-bordered flex-1"
/>
<button class="btn btn-primary" @click="displayLocationModal = true">définir</button>
</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 class="flex items-center gap-3">
<label class="label w-24">Date</label>
<input
v-model="date"
type="date"
class="input input-bordered"
placeholder="Date"
:max="today"
/>
</div>
<transaction-tag-update v-model="tag" />
<TransactionTagUpdate v-model="tag" />
</div>
<div class="column" v-if="account.users.length > 1">
<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 v-if="account.users.length > 1" class="space-y-4">
<div class="flex items-center gap-3">
<label class="label w-24">Payé par</label>
<select v-model="payBy" name="pay-by" class="select select-bordered flex-1">
<option v-for="(u, k) in account.users" :key="k" :value="u.alias">
{{ u.alias }}
</option>
</select>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Pour</label>
<div>
<div class="flex items-center gap-3 mb-2">
<label class="label w-24">Pour</label>
<button
class="button is-primary is-small"
:class="{
'is-outlined': payForUsers.length !== account.users.length
}"
type="button"
class="btn btn-primary btn-sm"
:class="{ 'btn-outline': payForUsers.length !== account.users.length }"
@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 class="space-y-2">
<div v-for="(split, k) in payFor" :key="k" class="user-item">
<TransactionSplit :split="payFor[k]" />
<hr v-if="k !== payFor.length - 1" />
</div>
</div>
</div>
</div>
</div>
<div class="modal" :class="{ 'is-active': displayLocationModal }">
<div class="modal-background"></div>
<div class="modal-card">
<section>
<earth-map
v-if="displayLocationModal"
:locations="
transaction.location ? [transaction.location] : undefined
"
:define-location="true"
@located="located"
/>
</section>
<footer class="modal-card-foot">
<dialog class="modal" :class="{ 'modal-open': displayLocationModal }">
<div class="modal-box max-w-3xl">
<EarthMap
v-if="displayLocationModal"
:locations="transaction.location ? [transaction.location] : undefined"
:define-location="true"
@located="located"
/>
<div class="modal-action">
<button
class="button is-primary"
:class="{ 'is-loading': isSetLocation }"
class="btn btn-primary"
:class="{ 'btn-loading': isSetLocation }"
@click="validLocation"
>
valider
</button>
<button class="button" @click="displayLocationModal = false">
annuler
</button>
</footer>
<button class="btn" @click="displayLocationModal = false">annuler</button>
</div>
</div>
</div>
<fab-button
@valid="submitTransaction"
:margin="true"
:button-style="backgroundColor"
>
</dialog>
<FabButton :margin="true" :button-style="backgroundColor" @valid="submitTransaction">
<vaquant-icon icon="check" />
</fab-button>
</FabButton>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import { CurrencyInput } from 'vue-currency-input'
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 queueNotifService from '@/services/QueueNotifService'
import { money } from '@/utils/filters'
import { findContrastColor } from '@/utils'
import ILocation from '@/models/ILocation'
import notif from '@/utils/notif'
import MapService from '../services/MapService'
import { format } from 'date-fns'
const formatToDate = (date: Date) => format(new Date(date), 'yyyy-MM-dd')
const today: Date = new Date()
@Component({
components: {
'currency-input': CurrencyInput,
'earth-map': () => import('@/components/EarthMap.vue'),
'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 locale!: string
@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 = formatToDate(today)
public date: string = formatToDate(today)
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 displayLocationModal = false
public isSetLocation = false
public location: ILocation | null = null
public async created(): Promise<void> {
this.setData(this.transaction)
}
public setData(transaction: ITransaction): void {
this.name = transaction.name
this.date = formatToDate(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(finish: () => void): Promise<void> {
if (!this.validate()) {
finish()
return
}
if (!this.currency || !this.account) {
finish()
return
}
const currencies: string[] = this.account.currencies.map(
(c: ICurrency) => c.code
)
const date: Date = new Date(this.date)
date.setHours(today.getHours(), today.getMinutes())
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,
location: this.transaction.location,
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 || '' }
})
notif.success('Dépense mise à jour.')
} else {
this.$router.push({
name: 'account',
params: { id: this.account._id || '' }
})
notif.success('Dépense créée.')
}
} else {
// tslint:disable-next-line:no-console
console.warn(response)
notif.error(`Une erreur s'est produite à la création d'une transaction.`)
}
finish()
}
public selectAll(): void {
if (!this.account) {
return
}
this.payFor.forEach((p: ISplit) => (p.weight = p.weight || 1))
}
public located(location: ILocation) {
this.location = location
}
public async validLocation() {
try {
this.isSetLocation = true
if (this.location) {
const place = await MapService.getPlace(this.location)
const location = {
...this.location,
place
}
this.$set<ILocation>(this.transaction, 'location', location)
}
} catch (error) {
this.$set(this.transaction, 'location', this.transaction.location)
} finally {
this.displayLocationModal = false
this.isSetLocation = false
}
}
public get payForUsers(): ISplit[] {
return this.payFor.filter((p, index) => p.weight > 0)
}
public get backgroundColor(): any | null {
if (!this.account || !this.account.color) {
return null
}
return {
backgroundColor: this.account.color,
color: findContrastColor(this.account.color) || 'black'
}
}
@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 {
const activeElement = document.activeElement
if (activeElement) {
const input = activeElement as HTMLInputElement
if (input.blur) {
input.blur()
}
}
const el = document.querySelector(':focus')
if (el) {
const input = el as HTMLInputElement
if (input.blur) {
input.blur()
}
}
if (
!this.name ||
!this.amount ||
!this.payBy ||
(!this.currency && !this.date && this.payFor.length === 0)
) {
if (this.amount === 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
} else {
queueNotifService.error('Tous les champs sont requis.')
}
return false
}
if (isNaN(this.amount) || this.amount > this.maxAmount) {
const maxAmount = money(this.maxAmount, this.currency)
queueNotifService.error(
`Veuillez saisir un montant numérique inférieur à ${maxAmount}.`
)
return false
}
if (this.amount < 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
return false
}
return true
}
}
</script>
<style scoped>
.help {
text-align: left;
}
.set-location {
text-align: center;
}
.set-location-input {
flex: 1;
}
</style>

View File

@@ -1,81 +1,64 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type ISplit from '@/models/ISplit'
const props = defineProps<{ split: ISplit }>()
const { t } = useI18n()
const checked = ref(props.split.weight > 0)
const plus = () => {
props.split.weight++
}
const minus = () => {
props.split.weight = Math.max(props.split.weight - 1, 0)
}
watch(checked, (value) => {
if (value && props.split.weight === 0) {
props.split.weight = 1
} else if (!value && props.split.weight > 0) {
props.split.weight = 0
}
})
watch(
() => props.split.weight,
(weight) => {
if (weight === 0 && checked.value) {
checked.value = false
} else if (weight > 0 && !checked.value) {
checked.value = true
}
},
{ immediate: true }
)
</script>
<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">
<vaquant-icon icon="plus" />
</a>
</div>
<div class="control">
<a @click="minus" class="button is-warning">
<vaquant-icon icon="minus" />
</a>
</div>
</div>
</div>
<div class="transaction-split flex items-center gap-2 py-1">
<label
:for="`user-${split.alias}`"
class="flex items-center gap-2 cursor-pointer flex-1"
>
<input
:id="`user-${split.alias}`"
v-model="checked"
type="checkbox"
:name="`user-${split.alias}`"
class="checkbox checkbox-primary"
/>
<span>{{ split.alias }}, {{ t('transaction.split', { count: split.weight }, split.weight) }}</span>
</label>
<div class="join">
<button type="button" class="btn btn-primary btn-sm join-item" @click="plus">
<vaquant-icon icon="plus" />
</button>
<button type="button" class="btn btn-warning btn-sm join-item" @click="minus">
<vaquant-icon icon="minus" />
</button>
</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

@@ -1,46 +1,47 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
const model = defineModel<TransactionTag>({ required: true })
const tagLocale = ref<TransactionTag>(model.value)
watch(tagLocale, (value) => {
if (value !== model.value) {
model.value = value
}
})
watch(model, (value) => {
if (value !== tagLocale.value) {
tagLocale.value = value
}
})
</script>
<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]">
<vaquant-icon :icon="transactionTagLabel[tag].icon" />
</span>
</p>
</div>
<div class="transaction-tag-update flex flex-row items-center gap-3">
<label class="label" for="tag">Catégorie</label>
<div class="relative flex-1">
<select
id="tag"
v-model="tagLocale"
name="tag"
class="select select-bordered w-full pl-10"
>
<option
v-for="(transactionTag, k) in TransactionTagLabel"
:key="k"
:value="k"
>
{{ transactionTag.label }}
</option>
</select>
<span
v-if="TransactionTagLabel[model]"
class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
>
<vaquant-icon :icon="TransactionTagLabel[model].icon" />
</span>
</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

@@ -1,95 +1,75 @@
<template>
<div class="user-new box 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="userId" />
</p>
</div>
</div>
</div>
</div>
<div class="column is-6" 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"
:placeholder="newUser.userId"
/>
</p>
<p class="help is-primary">Nom utilisé sur ce compte</p>
<p
class="help is-primary"
>{{ $tc('validation.max_char', maxChar(newUser), { max: maxChar(newUser) }) }}</p>
</div>
</div>
</div>
</div>
<div class="column is-1" v-if="!isMainUser">
<button class="button is-danger" @click="$emit('remove')">
<vaquant-icon icon="trash" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import type IUser from '@/models/IUser'
import { useUserStore } from '@/stores/user'
<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'
const newUser = defineModel<IUser>({ required: true })
defineEmits<{ remove: [] }>()
const { t } = useI18n()
@Component
export default class UserNew extends Vue {
@Getter
public user!: IUser | null
@Model('input', { type: Object, required: true })
public newUser!: IUser
private localeUserId = (this.newUser.userId || '').toLowerCase()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
public maxChar(user: IUser): number {
return 20 - (user.alias ? user.alias.length : 0)
const localeUserId = ref<string>((newUser.value.userId || '').toLowerCase())
const userId = computed({
get: () => localeUserId.value,
set: (val: string) => {
localeUserId.value = (val || '').toLowerCase()
}
})
public get isMainUser(): boolean {
if (!this.newUser) {
return false
watch(
localeUserId,
(value) => {
if (newUser.value.userId !== value) {
newUser.value.userId = value
}
return !!this.user && this.user.userId === this.newUser.userId
}
},
{ immediate: true }
)
public set userId(newUserId: string) {
this.localeUserId = (newUserId || '').toLowerCase()
}
public get userId(): string {
return this.localeUserId
}
const isMainUser = computed(
() => !!user.value && !!newUser.value && user.value.userId === newUser.value.userId
)
@Watch('localeUserId', { immediate: true })
public onLocaleUserIdChange(userId: string) {
if (this.newUser.userId !== userId) {
this.newUser.userId = userId
}
}
}
const maxChar = (u: IUser): number => 20 - (u.alias ? u.alias.length : 0)
</script>
<style>
.user-new {
margin: 5px 0;
}
</style>
<template>
<div class="user-new card bg-base-100 shadow p-4 my-2 flex flex-col md:flex-row gap-3 items-end">
<div v-if="user" class="flex flex-col gap-1 flex-1">
<label class="label">{{ t('user.pseudo') }}</label>
<input
v-model="userId"
:readonly="isMainUser"
type="text"
class="input input-bordered w-full"
/>
</div>
<div v-if="newUser" class="flex flex-col gap-1 flex-1">
<label class="label">{{ t('user.alias') }}</label>
<input
v-model="newUser.alias"
required
type="text"
maxlength="20"
:placeholder="newUser.userId"
class="input input-bordered w-full"
/>
<p class="text-xs text-primary">Nom utilisé sur ce compte</p>
<p class="text-xs text-primary">
{{ t('validation.max_char', { max: maxChar(newUser) }, maxChar(newUser)) }}
</p>
</div>
<button
v-if="!isMainUser"
class="btn btn-error btn-square"
@click="$emit('remove')"
>
<vaquant-icon icon="trash" />
</button>
</div>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
defineProps<{ icon: string }>()
</script>
<template>
<i :class="`ti ti-${icon}`" />
</template>