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:
@@ -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">
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -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" /> 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" /> 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 où 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" />
|
||||
<vaquant-icon icon="arrows-exchange" />
|
||||
<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 ! Votre partenaire idéal pour vos voyages !
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
50
src/components/CurrencyInput.vue
Normal file
50
src/components/CurrencyInput.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
7
src/components/VaquantIcon.vue
Normal file
7
src/components/VaquantIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ icon: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i :class="`ti ti-${icon}`" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user