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:
250
src/App.vue
250
src/App.vue
@@ -1,167 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { throttle } from 'lodash-es'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { COFFEE_LINK } from '@/utils/constants'
|
||||
import OnlineView from '@/components/OnlineView.vue'
|
||||
import QueueNotif from '@/components/QueueNotif.vue'
|
||||
import logoUrl from '@/assets/vaquant-root-white.png'
|
||||
import cloudOffIcon from '@/assets/icons/baseline_cloud_off_white_48dp.png'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { user, title } = storeToRefs(userStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const username = computed(() => userStore.username)
|
||||
const menuOpen = ref(false)
|
||||
const shadow = ref(false)
|
||||
const coffee = COFFEE_LINK
|
||||
|
||||
const navRef = useTemplateRef<HTMLElement>('navRef')
|
||||
|
||||
onClickOutside(navRef, () => {
|
||||
menuOpen.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const main = document.getElementById('main')
|
||||
if (main) {
|
||||
shadow.value = main.getBoundingClientRect().top < 0
|
||||
document.addEventListener(
|
||||
'scroll',
|
||||
throttle(() => {
|
||||
shadow.value = main.getBoundingClientRect().top < 0
|
||||
}, 150)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMenu = () => {
|
||||
menuOpen.value = !menuOpen.value
|
||||
}
|
||||
|
||||
const hideMenu = () => {
|
||||
menuOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app" class="app">
|
||||
<nav
|
||||
class="nav navbar is-fixed-top is-primary"
|
||||
role="navigation"
|
||||
aria-label="main navigation"
|
||||
ref="navRef"
|
||||
class="navbar bg-primary text-primary-content fixed top-0 left-0 right-0 z-40 px-4"
|
||||
:class="{ shadow }"
|
||||
v-click-outside="hideMenu"
|
||||
aria-label="main navigation"
|
||||
>
|
||||
<div class="navbar-brand">
|
||||
<router-link class="navbar-item" to="/">
|
||||
<img src="./assets/vaquant-root-white.png" title="vaquant" />
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<img :src="logoUrl" alt="vaquant" class="h-8" />
|
||||
</router-link>
|
||||
<online-view class="online-view">
|
||||
<div slot="offline">
|
||||
<img
|
||||
class="icon"
|
||||
src="./assets/icons/baseline_cloud_off_white_48dp.png"
|
||||
alt="offline"
|
||||
/>
|
||||
</div>
|
||||
</online-view>
|
||||
<queue-notif class="nav-button navbar-item" />
|
||||
<span class="vaquant-title" :class="{ visible: shadow }">{{
|
||||
title
|
||||
}}</span>
|
||||
<div
|
||||
class="navbar-burger burger"
|
||||
data-target="navbar-main"
|
||||
@click="toggleMenu"
|
||||
:class="{ 'is-active': menuOpen }"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<OnlineView>
|
||||
<template #offline>
|
||||
<img :src="cloudOffIcon" class="h-5 w-auto" alt="offline" />
|
||||
</template>
|
||||
</OnlineView>
|
||||
<QueueNotif />
|
||||
<span class="vaquant-title ml-2" :class="{ 'opacity-100': shadow, 'opacity-0': !shadow }">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-none md:hidden">
|
||||
<button class="btn btn-ghost btn-square" @click="toggleMenu">
|
||||
<vaquant-icon icon="menu-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="navbar-main"
|
||||
class="navbar-menu"
|
||||
:class="{ 'is-active': menuOpen }"
|
||||
class="hidden md:flex flex-none gap-1"
|
||||
:class="{ '!flex flex-col absolute top-full right-0 bg-primary p-2': menuOpen }"
|
||||
@click="hideMenu"
|
||||
>
|
||||
<div class="navbar-end">
|
||||
<router-link
|
||||
class="nav-button navbar-item"
|
||||
:to="{ name: 'about' }"
|
||||
v-t="'title.about'"
|
||||
></router-link>
|
||||
<!-- <router-link class="navbar-item" :to="{ name: 'pricing' }" v-t="'title.pricing'"></router-link> -->
|
||||
<a
|
||||
class="nav-button navbar-item"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:href="coffee"
|
||||
>Supportez Vaquant avec un ☕ !</a
|
||||
>
|
||||
<router-link class="nav-button navbar-item" :to="{ name: 'user' }">{{
|
||||
username ? username : $t('user.loginsignup')
|
||||
}}</router-link>
|
||||
</div>
|
||||
<router-link class="btn btn-ghost btn-sm" :to="{ name: 'about' }">
|
||||
{{ t('title.about') }}
|
||||
</router-link>
|
||||
<a
|
||||
class="btn btn-ghost btn-sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:href="coffee"
|
||||
>
|
||||
Supportez Vaquant avec un ☕ !
|
||||
</a>
|
||||
<router-link class="btn btn-ghost btn-sm" :to="{ name: 'user' }">
|
||||
{{ username || t('user.loginsignup') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="main">
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
<main id="main" class="pt-16 min-h-screen">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
import { throttle } from 'lodash-es'
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import IUser from '@/models/IUser'
|
||||
import { COFFEE_LINK } from '@/utils/constants'
|
||||
|
||||
@Component({
|
||||
directives: {
|
||||
ClickOutside
|
||||
},
|
||||
components: {
|
||||
'online-view': () => import('@/components/OnlineView.vue'),
|
||||
'queue-notif': () => import('@/components/QueueNotif.vue')
|
||||
}
|
||||
})
|
||||
export default class App extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Getter
|
||||
public username!: string
|
||||
@Getter
|
||||
public title!: string | null
|
||||
public menuOpen: boolean = false
|
||||
public transitionName: string = ''
|
||||
public shadow: boolean = false
|
||||
public coffee: string = COFFEE_LINK
|
||||
|
||||
public mounted(): void {
|
||||
const main = document.getElementById('main')
|
||||
if (main) {
|
||||
this.shadow = main.getBoundingClientRect().top < 0
|
||||
document.addEventListener(
|
||||
'scroll',
|
||||
throttle((evt) => {
|
||||
this.shadow = main.getBoundingClientRect().top < 0
|
||||
}, 150)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public toggleMenu(): void {
|
||||
this.menuOpen = !this.menuOpen
|
||||
}
|
||||
|
||||
public hideMenu(): void {
|
||||
this.menuOpen = false
|
||||
}
|
||||
|
||||
public back(): void {
|
||||
window.history.back()
|
||||
}
|
||||
<style>
|
||||
.vaquant-title {
|
||||
line-height: 2rem;
|
||||
transition: opacity 0.25s ease-out;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$icon_size: 18px;
|
||||
|
||||
.navbar {
|
||||
color: red;
|
||||
.vaquant-title {
|
||||
opacity: 0;
|
||||
line-height: 52px;
|
||||
transition: opacity 0.25s ease-out;
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.online-view {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
left: calc(#{$icon_size} * 2);
|
||||
width: $icon_size;
|
||||
height: auto;
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
transition: opacity 0.25s ease-out;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
box-shadow: inset 0 5px 6px -3px rgba(0, 0, 0, 0.842);
|
||||
will-change: opacity;
|
||||
}
|
||||
&.shadow::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import IUser from '@/models/IUser'
|
||||
import { findContrastColor } from '@/utils'
|
||||
|
||||
@Component
|
||||
export default class BaseAccount extends Vue {
|
||||
@Getter public user!: IUser | null
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public account: IAccount | null = null
|
||||
public removing: boolean = false
|
||||
|
||||
public async mounted(): Promise<void> {
|
||||
bus.$on(SYNC, this.getData)
|
||||
await this.getData()
|
||||
const anchor = this.$router.currentRoute.hash
|
||||
if (anchor && document.querySelector(anchor)) {
|
||||
this.$nextTick(() => {
|
||||
location.href = anchor
|
||||
// add scroll to manage fixed header
|
||||
window.scrollBy(0, -60)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public colorStyle(display: boolean = true) {
|
||||
if (!this.account || !display) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
backgroundColor: this.account.color,
|
||||
borderColor: this.account.color,
|
||||
color: findContrastColor(this.account.color)
|
||||
}
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
bus.$off(SYNC, this.getData)
|
||||
}
|
||||
|
||||
public async getData(docIds?: string[]): Promise<void> {
|
||||
throw new Error(
|
||||
`abstract method needs to be overrided ${docIds && docIds.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
37
src/composables/useBaseAccount.ts
Normal file
37
src/composables/useBaseAccount.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import type IAccount from '@/models/IAccount'
|
||||
import { findContrastColor } from '@/utils'
|
||||
|
||||
export const useBaseAccount = (getData: (docIds?: string[]) => Promise<void>) => {
|
||||
const account = ref<IAccount | null>(null)
|
||||
const removing = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const colorStyle = (display = true): Record<string, string> => {
|
||||
if (!account.value || !display) return {}
|
||||
return {
|
||||
backgroundColor: account.value.color ?? '',
|
||||
borderColor: account.value.color ?? '',
|
||||
color: findContrastColor(account.value.color ?? null) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
bus.on(SYNC, getData)
|
||||
await getData()
|
||||
const anchor = route.hash
|
||||
if (anchor && document.querySelector(anchor)) {
|
||||
await nextTick()
|
||||
location.href = anchor
|
||||
window.scrollBy(0, -60)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
bus.off(SYNC, getData)
|
||||
})
|
||||
|
||||
return { account, removing, colorStyle }
|
||||
}
|
||||
@@ -72,16 +72,17 @@ export default [
|
||||
}
|
||||
].map(
|
||||
(color: IColor): IColor => {
|
||||
const darkValue = lightness(color.value, dark).toLowerCase()
|
||||
const lightValue = lightness(color.value, light).toLowerCase()
|
||||
const slightLightValue = lightness(color.value, 10).toLowerCase()
|
||||
const value = color.value ?? ''
|
||||
const darkValue = lightness(value, dark).toLowerCase()
|
||||
const lightValue = lightness(value, light).toLowerCase()
|
||||
const slightLightValue = lightness(value, 10).toLowerCase()
|
||||
return {
|
||||
...color,
|
||||
darkValue,
|
||||
lightValue,
|
||||
slightLightValue,
|
||||
constrastColor:
|
||||
fontColorContrast(color.value) === '#000000' ? darkValue : lightValue
|
||||
fontColorContrast(value) === '#000000' ? darkValue : lightValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
11
src/i18n/index.ts
Normal file
11
src/i18n/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import messages from '@/messages'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'en',
|
||||
messages
|
||||
})
|
||||
|
||||
export default i18n
|
||||
53
src/main.ts
53
src/main.ts
@@ -1,37 +1,30 @@
|
||||
import Vue from 'vue'
|
||||
import VueI18n from 'vue-i18n'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import './styles/index.scss'
|
||||
import 'bulma-checkradio'
|
||||
import './registerServiceWorker'
|
||||
import filters from './utils/filters'
|
||||
import './utils/icons'
|
||||
import messages from './messages'
|
||||
import i18n from './i18n'
|
||||
import { useUserStore } from './stores/user'
|
||||
import { registerIcons } from './utils/icons'
|
||||
import couchService from './services/CouchService'
|
||||
import './styles/index.css'
|
||||
|
||||
Vue.use(VueI18n)
|
||||
const bootstrap = async () => {
|
||||
const app = createApp(App)
|
||||
|
||||
Vue.config.productionTip = false
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
|
||||
for (const filter of Object.keys(filters)) {
|
||||
Vue.filter(filter, filters[filter])
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
registerIcons(app)
|
||||
|
||||
const userStore = useUserStore()
|
||||
couchService.start(() => userStore.publicAccountIds)
|
||||
await userStore.retrieveUser()
|
||||
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
const i18n = new VueI18n({
|
||||
locale: 'fr',
|
||||
fallbackLocale: 'en',
|
||||
messages
|
||||
})
|
||||
|
||||
const app = async () => {
|
||||
await store.dispatch('retrieveUser')
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
i18n,
|
||||
render: (h) => h(App)
|
||||
}).$mount('#app')
|
||||
}
|
||||
|
||||
app()
|
||||
bootstrap()
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/* tslint:disable:no-console */
|
||||
|
||||
import { register } from 'register-service-worker'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
registrationOptions: {},
|
||||
ready() {
|
||||
console.log('App is being served from cache by a service worker.')
|
||||
},
|
||||
cached() {
|
||||
console.log('Content has been cached for offline use.')
|
||||
queueNotifService.success('Application mise à jour !')
|
||||
},
|
||||
updated() {
|
||||
console.log('New content is available; please refresh.')
|
||||
queueNotifService.hasNewVersion()
|
||||
},
|
||||
offline() {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
)
|
||||
},
|
||||
error(error) {
|
||||
console.error('Error during service worker registration:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
112
src/router.ts
112
src/router.ts
@@ -1,112 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import Home from './views/Home.vue'
|
||||
import store from './store'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
const router: Router = new Router({
|
||||
mode: 'history',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "about" */ './views/About.vue'),
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
name: 'pricing',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "pricing" */ './views/Pricing.vue'),
|
||||
},
|
||||
{
|
||||
path: '/security',
|
||||
name: 'security',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "pricing" */ './views/Security.vue'),
|
||||
},
|
||||
{
|
||||
path: '/account/new',
|
||||
name: 'account-new',
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "account-new" */ './views/accounts/AccountNew.vue'
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/account/:id',
|
||||
name: 'account',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "account" */ './views/accounts/AccountItem.vue'
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/account/:id/public',
|
||||
name: 'account-public',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "account-public" */ './views/accounts/AccountPublic.vue'
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/account/:id/transaction/new',
|
||||
name: 'transaction-new',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "transaction-new" */ './views/transactions/TransactionNew.vue'
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/transaction/:id/update',
|
||||
name: 'transaction-update',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "transaction-update" */ './views/transactions/TransactionUpdate.vue'
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/transaction/:id',
|
||||
name: 'transaction',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "transaction" */ './views/transactions/TransactionItem.vue'
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'user',
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "user" */ './views/User.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user/:premail',
|
||||
name: 'user-premail',
|
||||
props: true,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "user" */ './views/User.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// tslint:disable-next-line:variable-name
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const accountRoutes: string[] = ['account', 'transaction']
|
||||
if (!accountRoutes.includes(to.name || '')) {
|
||||
store.dispatch('setTitle', null)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
75
src/router/index.ts
Normal file
75
src/router/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '@/views/Home.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('@/views/About.vue')
|
||||
},
|
||||
{
|
||||
path: '/security',
|
||||
name: 'security',
|
||||
component: () => import('@/views/Security.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/new',
|
||||
name: 'account-new',
|
||||
component: () => import('@/views/accounts/AccountNew.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/:id',
|
||||
name: 'account',
|
||||
props: true,
|
||||
component: () => import('@/views/accounts/AccountItem.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/:id/public',
|
||||
name: 'account-public',
|
||||
props: true,
|
||||
component: () => import('@/views/accounts/AccountPublic.vue')
|
||||
},
|
||||
{
|
||||
path: '/account/:id/transaction/new',
|
||||
name: 'transaction-new',
|
||||
props: true,
|
||||
component: () => import('@/views/transactions/TransactionNew.vue')
|
||||
},
|
||||
{
|
||||
path: '/transaction/:id/update',
|
||||
name: 'transaction-update',
|
||||
props: true,
|
||||
component: () => import('@/views/transactions/TransactionUpdate.vue')
|
||||
},
|
||||
{
|
||||
path: '/transaction/:id',
|
||||
name: 'transaction',
|
||||
props: true,
|
||||
component: () => import('@/views/transactions/TransactionItem.vue')
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'user',
|
||||
component: () => import('@/views/User.vue')
|
||||
},
|
||||
{
|
||||
path: '/user/:premail',
|
||||
name: 'user-premail',
|
||||
props: true,
|
||||
component: () => import('@/views/User.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const accountRoutes = ['account', 'transaction']
|
||||
if (!accountRoutes.includes(String(to.name || ''))) {
|
||||
useUserStore().setTitle(null)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,31 +0,0 @@
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import IExchange from '@/models/IExchange'
|
||||
|
||||
type Doc = IAccount | ITransaction | IExchange
|
||||
|
||||
interface IUserContext {
|
||||
db: string
|
||||
name: string | null
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// tslint:disable-next-line: variable-name only-arrow-functions
|
||||
const validate_doc_update = function(
|
||||
newDoc: Doc,
|
||||
oldDoc: Doc,
|
||||
userCtx: IUserContext
|
||||
) {
|
||||
if (!userCtx.name) {
|
||||
throw { forbidden: 'You need to be logged in to update accounts' }
|
||||
}
|
||||
|
||||
if (newDoc.doctype === 'account' && oldDoc.doctype === 'account') {
|
||||
if (true) {
|
||||
throw { forbidden: 'You do not have rights to update this account' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-console
|
||||
console.info(validate_doc_update.toString().replace(/\n/g, ''))
|
||||
@@ -6,7 +6,7 @@ import { slug } from '@/utils'
|
||||
|
||||
class AccountService {
|
||||
public async get(id: string): Promise<IAccount | null> {
|
||||
const account: IAccount = await couchService.get(id)
|
||||
const account = await couchService.get<IAccount>(id)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class AccountService {
|
||||
}
|
||||
|
||||
public async getRemote(id: string): Promise<IAccount | null> {
|
||||
const account: IAccount = await couchService.getRemote(id)
|
||||
const account = await couchService.getRemote<IAccount>(id)
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
@@ -43,11 +43,12 @@ class AccountService {
|
||||
public async active(id: string): Promise<boolean> {
|
||||
try {
|
||||
const account = await this.get(id)
|
||||
if (account) {
|
||||
account.archive = false
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
account.archive = false
|
||||
const { ok } = await couchService.save(account)
|
||||
return ok
|
||||
return ok ?? false
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.info('erreur dans la cloture du compte', {
|
||||
@@ -60,11 +61,12 @@ class AccountService {
|
||||
public async archive(id: string): Promise<boolean> {
|
||||
try {
|
||||
const account = await this.get(id)
|
||||
if (account) {
|
||||
account.archive = true
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
account.archive = true
|
||||
const { ok } = await couchService.save(account)
|
||||
return ok
|
||||
return ok ?? false
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.info('erreur dans la cloture du compte', {
|
||||
@@ -96,7 +98,7 @@ class AccountService {
|
||||
try {
|
||||
const response = await couchService.getByPrefix('acc')
|
||||
const accounts = response.rows.map((row: any) => row.doc) as IAccount[]
|
||||
const result = []
|
||||
const result: IAccount[] = []
|
||||
for (const account of accounts) {
|
||||
if ((archived && account.archive) || (!archived && !account.archive)) {
|
||||
result.push(account)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import IPoint from '@/models/IPoint'
|
||||
import { money } from '@/utils/filters'
|
||||
import { money } from '@/utils/format'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
|
||||
class ChartService {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import PouchDb from 'pouchdb-browser'
|
||||
import PouchDbAuthentication from 'pouchdb-authentication'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import IUser from '@/models/IUser'
|
||||
import { debounce } from 'lodash-es'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import type IUser from '@/models/IUser'
|
||||
import { confirmation } from '@/utils'
|
||||
import store from '@/store'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
|
||||
PouchDb.plugin(PouchDbAuthentication)
|
||||
|
||||
const emit = debounce((ev: string, arr?: string[]) => bus.$emit(ev, arr), 150)
|
||||
const emit = debounce((ev: typeof SYNC, arr?: string[]) => bus.emit(ev, arr), 150)
|
||||
|
||||
const remoteConfig = {
|
||||
skip_setup: true,
|
||||
@@ -19,26 +18,33 @@ const remoteConfig = {
|
||||
withCredentials: true
|
||||
}
|
||||
}
|
||||
const LOCALE_DB: string = 'VAQUANT_LOCALE_DB'
|
||||
const REMOTE: string = process.env.VUE_APP_COUCHDB || ''
|
||||
const LOCALE_DB = 'VAQUANT_LOCALE_DB'
|
||||
const REMOTE: string = import.meta.env.VITE_COUCHDB || ''
|
||||
|
||||
type PublicAccountIdsProvider = () => string[]
|
||||
|
||||
class CouchService {
|
||||
public db: PouchDB.Database | null = null
|
||||
private innerRemote: PouchDB.Database | null = null
|
||||
private user: IUser | null = null
|
||||
private sync: PouchDB.Replication.Sync<{}> | null = null
|
||||
private userSync: PouchDB.Replication.Sync<{}> | null = null
|
||||
private eventListened: boolean = false
|
||||
private sync: PouchDB.Replication.Sync<object> | null = null
|
||||
private userSync: PouchDB.Replication.Sync<object> | null = null
|
||||
private eventListened = false
|
||||
private publicAccountIds: PublicAccountIdsProvider = () => []
|
||||
|
||||
public get remote(): PouchDB.Database {
|
||||
if (!this.innerRemote) {
|
||||
this.innerRemote = new PouchDb(`${REMOTE}/vaquant`, remoteConfig)
|
||||
}
|
||||
return this.innerRemote
|
||||
return this.innerRemote as PouchDB.Database
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.initDb()
|
||||
}
|
||||
|
||||
public start(publicAccountIds: PublicAccountIdsProvider): void {
|
||||
this.publicAccountIds = publicAccountIds
|
||||
if (!this.eventListened) {
|
||||
this.eventListened = true
|
||||
window.addEventListener('online', () => {
|
||||
@@ -50,7 +56,6 @@ class CouchService {
|
||||
this.cancelSync()
|
||||
})
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
this.initLive()
|
||||
}
|
||||
@@ -60,7 +65,7 @@ class CouchService {
|
||||
if (!this.db) {
|
||||
return false
|
||||
}
|
||||
await this.cancelSync()
|
||||
this.cancelSync()
|
||||
|
||||
this.sync = this.db
|
||||
.sync(this.remote, {
|
||||
@@ -69,13 +74,13 @@ class CouchService {
|
||||
filter: 'account/by_user',
|
||||
query_params: {
|
||||
userId: (this.user && this.user.userId) || '',
|
||||
accountIds: [...store.getters.publicAccountIds]
|
||||
accountIds: [...this.publicAccountIds()]
|
||||
}
|
||||
})
|
||||
.on('change', (result: PouchDB.Replication.SyncResult<any>) => {
|
||||
.on('change', (result: PouchDB.Replication.SyncResult<{ doctype?: string }>) => {
|
||||
if (result.direction === 'pull') {
|
||||
const hasDocOtherThanExchange = result.change.docs.some(
|
||||
(doc: any) => doc.doctype !== 'exchange'
|
||||
(doc) => (doc as { doctype?: string }).doctype !== 'exchange'
|
||||
)
|
||||
if (hasDocOtherThanExchange) {
|
||||
emit(SYNC)
|
||||
@@ -85,10 +90,9 @@ class CouchService {
|
||||
emit(SYNC)
|
||||
}
|
||||
})
|
||||
.on('error', (error: any) => {
|
||||
// tslint:disable-next-line:no-console
|
||||
.on('error', (error: unknown) => {
|
||||
console.warn('on error', { error })
|
||||
if (error.name !== 'unauthorized') {
|
||||
if ((error as { name?: string })?.name !== 'unauthorized') {
|
||||
queueNotifService.error(`une erreur s'est produite`)
|
||||
}
|
||||
})
|
||||
@@ -131,21 +135,25 @@ class CouchService {
|
||||
return navigator.onLine
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<any | null> {
|
||||
public async get<T extends object = Record<string, unknown>>(
|
||||
id: string
|
||||
): Promise<T | null> {
|
||||
if (!this.db) {
|
||||
return null
|
||||
}
|
||||
return await this.db.get(id)
|
||||
return (await this.db.get(id)) as T
|
||||
}
|
||||
|
||||
public async getRemote(id: string): Promise<any | null> {
|
||||
public async getRemote<T extends object = Record<string, unknown>>(
|
||||
id: string
|
||||
): Promise<T | null> {
|
||||
if (!this.remote) {
|
||||
return null
|
||||
}
|
||||
return await this.remote.get(id)
|
||||
return (await this.remote.get(id)) as T
|
||||
}
|
||||
|
||||
public async getByPrefix<T>(
|
||||
public async getByPrefix<T extends object>(
|
||||
prefix: string
|
||||
): Promise<PouchDB.Core.AllDocsResponse<T>> {
|
||||
if (!this.db) {
|
||||
@@ -158,40 +166,34 @@ class CouchService {
|
||||
return (await this.db.allDocs({
|
||||
include_docs: true,
|
||||
startkey: prefix,
|
||||
endkey: `${prefix}\ufff0`
|
||||
endkey: `${prefix}`
|
||||
})) as PouchDB.Core.AllDocsResponse<T>
|
||||
}
|
||||
|
||||
public async query(query: string): Promise<any> {
|
||||
public async query(query: string): Promise<unknown[]> {
|
||||
if (!this.db) {
|
||||
return []
|
||||
}
|
||||
const response = await this.db.query(query, {
|
||||
include_docs: true
|
||||
})
|
||||
return response.rows.map((row: any) => row.doc)
|
||||
return response.rows.map((row) => row.doc)
|
||||
}
|
||||
|
||||
public async save(doc: any): Promise<PouchDB.Core.Response> {
|
||||
public async save(doc: object): Promise<PouchDB.Core.Response> {
|
||||
if (!this.db) {
|
||||
return {
|
||||
id: '',
|
||||
rev: '',
|
||||
ok: false
|
||||
}
|
||||
return { id: '', rev: '', ok: false }
|
||||
}
|
||||
const response = await this.db.put(doc)
|
||||
return response
|
||||
return await this.db.put(doc as PouchDB.Core.PutDocument<object>)
|
||||
}
|
||||
|
||||
public async multipleSave(
|
||||
docs: any[]
|
||||
docs: object[]
|
||||
): Promise<Array<PouchDB.Core.Response | PouchDB.Core.Error>> {
|
||||
if (!this.db) {
|
||||
return []
|
||||
}
|
||||
const response = await this.db.bulkDocs(docs)
|
||||
return response
|
||||
return await this.db.bulkDocs(docs as PouchDB.Core.PutDocument<object>[])
|
||||
}
|
||||
|
||||
public async remove(id: string): Promise<boolean> {
|
||||
@@ -200,17 +202,13 @@ class CouchService {
|
||||
}
|
||||
const doc = await this.db.get(id)
|
||||
if (doc) {
|
||||
const response = await this.save({
|
||||
...doc,
|
||||
_deleted: true
|
||||
})
|
||||
|
||||
return response.ok
|
||||
const response = await this.save({ ...doc, _deleted: true })
|
||||
return response.ok ?? false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public newId(prefix: string, withUuid: boolean = false): string {
|
||||
public newId(prefix: string, withUuid = false): string {
|
||||
return `${prefix}${Date.now().toString()}${withUuid ? uuid() : ''}`
|
||||
}
|
||||
|
||||
@@ -220,12 +218,11 @@ class CouchService {
|
||||
await this.db.destroy()
|
||||
this.db = null
|
||||
}
|
||||
|
||||
this.setUser(this.user, false)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelSync(): any {
|
||||
private cancelSync(): void {
|
||||
if (this.sync) {
|
||||
this.sync.cancel()
|
||||
this.sync = null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
declare const Email: any
|
||||
import '@/utils/email'
|
||||
declare const Email: { send: (...args: unknown[]) => unknown }
|
||||
import '@/utils/smtp'
|
||||
import IUser from '@/models/IUser'
|
||||
|
||||
class EmailService {
|
||||
|
||||
@@ -2,7 +2,7 @@ import ILocation from '@/models/ILocation'
|
||||
import ILocationQuery from '@/models/ILocationQuery'
|
||||
|
||||
class MapService {
|
||||
private key: string = process.env.VUE_APP_MAP_KEY || ''
|
||||
private key: string = import.meta.env.VITE_MAP_KEY || ''
|
||||
|
||||
public async getPosition(): Promise<ILocation | null> {
|
||||
const position = await this.getCurrentPosition()
|
||||
@@ -44,12 +44,13 @@ class MapService {
|
||||
return `https://dev.virtualearth.net/REST/v1/Locations/${latitude},${longitude}?key=${this.key}`
|
||||
}
|
||||
|
||||
private getCurrentPosition(): Promise<Position | null> {
|
||||
private getCurrentPosition(): Promise<GeolocationPosition | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
return navigator.geolocation.getCurrentPosition(resolve, reject)
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ class TransactionService {
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<ITransaction | null> {
|
||||
const transaction: ITransaction = await couchService.get(id)
|
||||
return transaction
|
||||
return await couchService.get<ITransaction>(id)
|
||||
}
|
||||
|
||||
public async add(
|
||||
|
||||
13
src/shims-tsx.d.ts
vendored
13
src/shims-tsx.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
import Vue, { VNode } from 'vue'
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
// tslint:disable no-empty-interface
|
||||
interface Element extends VNode {}
|
||||
// tslint:disable no-empty-interface
|
||||
interface ElementClass extends Vue {}
|
||||
interface IntrinsicElements {
|
||||
[elem: string]: any
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/shims-vue.d.ts
vendored
12
src/shims-vue.d.ts
vendored
@@ -1,12 +0,0 @@
|
||||
declare module '*.vue' {
|
||||
import Vue from 'vue'
|
||||
export default Vue
|
||||
}
|
||||
declare module 'vue-click-outside'
|
||||
declare module 'lightness'
|
||||
declare module 'crypto-js/aes'
|
||||
declare module 'crypto-js/enc-utf8'
|
||||
declare module '@xkeshi/vue-qrcode'
|
||||
declare module 'font-color-contrast'
|
||||
declare module 'vue-stripe-checkout'
|
||||
declare module 'mapbox-gl/dist/mapbox-gl'
|
||||
13
src/shims.d.ts
vendored
Normal file
13
src/shims.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
/// <reference types="pouchdb" />
|
||||
/// <reference types="pouchdb-browser" />
|
||||
/// <reference types="pouchdb-replication" />
|
||||
/// <reference types="pouchdb-mapreduce" />
|
||||
|
||||
declare module 'lightness' {
|
||||
const lightness: (color: string, value: number) => string
|
||||
export default lightness
|
||||
}
|
||||
|
||||
import 'pinia-plugin-persistedstate'
|
||||
156
src/store.ts
156
src/store.ts
@@ -1,156 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import VuexPersistence from 'vuex-persist'
|
||||
import IUser from '@/models/IUser'
|
||||
import userService from '@/services/UserService'
|
||||
import couchService from '@/services/CouchService'
|
||||
import IResponse from '@/models/IResponse'
|
||||
import IUserPassword from '@/models/IUserPassword'
|
||||
import IIdPassword from '@/models/IEmailPassword'
|
||||
import { hasGoodNetwork } from '@/utils/network'
|
||||
import { toHex } from '@/utils'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
|
||||
const DEFAULT_LOCALE = 'fr'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
const SET_LOCALE: string = 'SET_LOCALE'
|
||||
const SET_TITLE: string = 'SET_TITLE'
|
||||
const SET_USER: string = 'SET_USER'
|
||||
const ADD_PUBLIC_ACCOUNT: string = 'ADD_PUBLIC_ACCOUNT'
|
||||
const REMOVE_PUBLIC_ACCOUNT: string = 'REMOVE_PUBLIC_ACCOUNT'
|
||||
|
||||
interface IState {
|
||||
locale: string
|
||||
user: IUser | null
|
||||
hexUser: string | null
|
||||
title: string | null
|
||||
publicAccountIds: string[]
|
||||
}
|
||||
|
||||
export default new Vuex.Store<IState>({
|
||||
state: {
|
||||
locale: DEFAULT_LOCALE,
|
||||
user: null,
|
||||
hexUser: null,
|
||||
title: null,
|
||||
publicAccountIds: []
|
||||
},
|
||||
getters: {
|
||||
locale: ({ locale }) => locale || DEFAULT_LOCALE,
|
||||
user: ({ user }) => user,
|
||||
title: ({ title }) => title,
|
||||
username: (state: IState) =>
|
||||
state.user
|
||||
? state.user.firstname && state.user.lastname
|
||||
? `${state.user.firstname} ${state.user.lastname}`.trim()
|
||||
: state.user.email
|
||||
: '',
|
||||
isAppAdmin: (state: IState) =>
|
||||
state.user &&
|
||||
state.user.roles &&
|
||||
state.user.roles.find((role: string) => role === 'vaquant-admin'),
|
||||
publicAccountIds: ({ publicAccountIds }) => publicAccountIds
|
||||
},
|
||||
mutations: {
|
||||
[SET_LOCALE](store, locale: string) {
|
||||
store.locale = locale
|
||||
},
|
||||
[SET_TITLE](store, title: string | null) {
|
||||
store.title = title
|
||||
},
|
||||
[SET_USER](
|
||||
store,
|
||||
{ user, replicate }: { user: IUser; replicate?: boolean }
|
||||
) {
|
||||
store.user = user
|
||||
store.hexUser = user ? toHex(user.userId) : null
|
||||
couchService.setUser(user, replicate || false)
|
||||
},
|
||||
[ADD_PUBLIC_ACCOUNT](store, { accountId }: { accountId: string }) {
|
||||
if (!accountId) {
|
||||
return
|
||||
}
|
||||
store.publicAccountIds = [
|
||||
...new Set([...store.publicAccountIds, accountId])
|
||||
]
|
||||
},
|
||||
[REMOVE_PUBLIC_ACCOUNT](store, { accountId }: { accountId: string }) {
|
||||
if (!accountId) {
|
||||
return
|
||||
}
|
||||
store.publicAccountIds = store.publicAccountIds.filter(
|
||||
(id: string) => id !== accountId && id !== null
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setLocale({ commit }, locale: string) {
|
||||
commit(SET_LOCALE, locale)
|
||||
},
|
||||
setTitle({ commit }, title: string) {
|
||||
commit(SET_TITLE, title)
|
||||
},
|
||||
async retrieveUser({ commit, state }) {
|
||||
let user = state.user
|
||||
if (user) {
|
||||
commit(SET_USER, { user, replicate: false })
|
||||
}
|
||||
// Update user if present and available online.
|
||||
if (navigator.onLine && hasGoodNetwork()) {
|
||||
user = await userService.getUser(user)
|
||||
}
|
||||
if (user) {
|
||||
commit(SET_USER, { user, replicate: false })
|
||||
}
|
||||
},
|
||||
async signup({ commit }, { user, password, replicate }: IUserPassword) {
|
||||
const response: IResponse = await userService.signup(user, password)
|
||||
if (response.ok) {
|
||||
commit(SET_USER, { user, replicate })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
},
|
||||
async login({ commit, state }, { userId, password }: IIdPassword) {
|
||||
const response: IResponse = await userService.login(userId, password)
|
||||
if (response.ok) {
|
||||
const user: IUser | null = await userService.getUser(state.user)
|
||||
commit(SET_USER, { user })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
},
|
||||
async logout({ commit }) {
|
||||
const response = await userService.logout()
|
||||
if (response.ok) {
|
||||
commit(SET_USER, { user: null })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
},
|
||||
async remove({ commit, state }) {
|
||||
if (state.user) {
|
||||
const response = await userService.deleteUser(state.user.userId)
|
||||
if (response.ok) {
|
||||
commit(SET_USER, { user: null })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
}
|
||||
},
|
||||
addAccountId({ commit }, { accountId }: { accountId: string }) {
|
||||
commit(ADD_PUBLIC_ACCOUNT, { accountId })
|
||||
},
|
||||
removeAccountId({ commit }, { accountId }: { accountId: string }) {
|
||||
commit(REMOVE_PUBLIC_ACCOUNT, { accountId })
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new VuexPersistence<any>({
|
||||
key: 'vaquant',
|
||||
storage: window.localStorage
|
||||
}).plugin
|
||||
]
|
||||
})
|
||||
133
src/stores/user.ts
Normal file
133
src/stores/user.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type IUser from '@/models/IUser'
|
||||
import type IUserPassword from '@/models/IUserPassword'
|
||||
import type IEmailPassword from '@/models/IEmailPassword'
|
||||
import type IResponse from '@/models/IResponse'
|
||||
import userService from '@/services/UserService'
|
||||
import couchService from '@/services/CouchService'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import { hasGoodNetwork } from '@/utils/network'
|
||||
import { toHex } from '@/utils'
|
||||
|
||||
const DEFAULT_LOCALE = 'fr'
|
||||
|
||||
interface UserState {
|
||||
locale: string
|
||||
user: IUser | null
|
||||
hexUser: string | null
|
||||
title: string | null
|
||||
publicAccountIds: string[]
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: (): UserState => ({
|
||||
locale: DEFAULT_LOCALE,
|
||||
user: null,
|
||||
hexUser: null,
|
||||
title: null,
|
||||
publicAccountIds: []
|
||||
}),
|
||||
|
||||
getters: {
|
||||
currentLocale: (state) => state.locale || DEFAULT_LOCALE,
|
||||
username: (state): string =>
|
||||
state.user
|
||||
? state.user.firstname && state.user.lastname
|
||||
? `${state.user.firstname} ${state.user.lastname}`.trim()
|
||||
: state.user.email
|
||||
: '',
|
||||
isAppAdmin: (state): boolean =>
|
||||
!!state.user?.roles?.some((role: string) => role === 'vaquant-admin')
|
||||
},
|
||||
|
||||
actions: {
|
||||
setLocale(locale: string) {
|
||||
this.locale = locale
|
||||
},
|
||||
|
||||
setTitle(title: string | null) {
|
||||
this.title = title
|
||||
},
|
||||
|
||||
setUser({ user, replicate }: { user: IUser | null; replicate?: boolean }) {
|
||||
this.user = user
|
||||
this.hexUser = user ? toHex(user.userId) : null
|
||||
couchService.setUser(user, replicate || false)
|
||||
},
|
||||
|
||||
addAccountId(accountId: string) {
|
||||
if (!accountId) {
|
||||
return
|
||||
}
|
||||
this.publicAccountIds = Array.from(
|
||||
new Set([...this.publicAccountIds, accountId])
|
||||
)
|
||||
},
|
||||
|
||||
removeAccountId(accountId: string) {
|
||||
if (!accountId) {
|
||||
return
|
||||
}
|
||||
this.publicAccountIds = this.publicAccountIds.filter(
|
||||
(id) => id !== accountId && id !== null
|
||||
)
|
||||
},
|
||||
|
||||
async retrieveUser() {
|
||||
let user = this.user
|
||||
if (user) {
|
||||
this.setUser({ user, replicate: false })
|
||||
}
|
||||
if (navigator.onLine && hasGoodNetwork()) {
|
||||
user = await userService.getUser(user)
|
||||
}
|
||||
if (user) {
|
||||
this.setUser({ user, replicate: false })
|
||||
}
|
||||
},
|
||||
|
||||
async signup({ user, password, replicate }: IUserPassword) {
|
||||
const response: IResponse = await userService.signup(user, password)
|
||||
if (response.ok) {
|
||||
this.setUser({ user, replicate })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
},
|
||||
|
||||
async login({ userId, password }: IEmailPassword) {
|
||||
const response: IResponse = await userService.login(userId, password)
|
||||
if (response.ok) {
|
||||
const user: IUser | null = await userService.getUser(this.user)
|
||||
this.setUser({ user })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
const response = await userService.logout()
|
||||
if (response.ok) {
|
||||
this.setUser({ user: null })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
},
|
||||
|
||||
async remove() {
|
||||
if (this.user) {
|
||||
const response = await userService.deleteUser(this.user.userId)
|
||||
if (response.ok) {
|
||||
this.setUser({ user: null })
|
||||
} else {
|
||||
queueNotifService.error(response.message || '')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
persist: {
|
||||
key: 'vaquant',
|
||||
pick: ['locale', 'user', 'hexUser', 'publicAccountIds']
|
||||
}
|
||||
})
|
||||
@@ -1,688 +0,0 @@
|
||||
.mapboxgl-map {
|
||||
font: 12px/20px Helvetica Neue, Arial, Helvetica, sans-serif;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
text-align: left;
|
||||
}
|
||||
.mapboxgl-map:-webkit-full-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.mapboxgl-canary {
|
||||
background-color: salmon;
|
||||
}
|
||||
.mapboxgl-canvas-container.mapboxgl-interactive,
|
||||
.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.mapboxgl-canvas-container.mapboxgl-interactive:active,
|
||||
.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active {
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,
|
||||
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas {
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,
|
||||
.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas {
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,
|
||||
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan
|
||||
.mapboxgl-canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-left,
|
||||
.mapboxgl-ctrl-bottom-right,
|
||||
.mapboxgl-ctrl-top-left,
|
||||
.mapboxgl-ctrl-top-right {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.mapboxgl-ctrl-top-left {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.mapboxgl-ctrl-top-right {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-left {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-right {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.mapboxgl-ctrl {
|
||||
clear: both;
|
||||
pointer-events: auto;
|
||||
transform: translate(0);
|
||||
}
|
||||
.mapboxgl-ctrl-top-left .mapboxgl-ctrl {
|
||||
margin: 10px 0 0 10px;
|
||||
float: left;
|
||||
}
|
||||
.mapboxgl-ctrl-top-right .mapboxgl-ctrl {
|
||||
margin: 10px 10px 0 0;
|
||||
float: right;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl {
|
||||
margin: 0 0 10px 10px;
|
||||
float: left;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl {
|
||||
margin: 0 10px 10px 0;
|
||||
float: right;
|
||||
}
|
||||
.mapboxgl-ctrl-group {
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.mapboxgl-ctrl-group:not(:empty) {
|
||||
-moz-box-shadow: 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
-webkit-box-shadow: 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@media (-ms-high-contrast: active) {
|
||||
.mapboxgl-ctrl-group:not(:empty) {
|
||||
box-shadow: 0 0 0 2px ButtonText;
|
||||
}
|
||||
}
|
||||
.mapboxgl-ctrl-group button {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
display: block;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mapboxgl-ctrl-group button + button {
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.mapboxgl-ctrl button .mapboxgl-ctrl-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50%;
|
||||
}
|
||||
@media (-ms-high-contrast: active) {
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-color: transparent;
|
||||
}
|
||||
.mapboxgl-ctrl-group button + button {
|
||||
border-top: 1px solid ButtonText;
|
||||
}
|
||||
}
|
||||
.mapboxgl-ctrl button::-moz-focus-inner {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.mapboxgl-ctrl-group button:focus {
|
||||
box-shadow: 0 0 2px 2px #0096ff;
|
||||
}
|
||||
.mapboxgl-ctrl button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.mapboxgl-ctrl button:not(:disabled):hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.mapboxgl-ctrl-group button:focus:focus-visible {
|
||||
box-shadow: 0 0 2px 2px #0096ff;
|
||||
}
|
||||
.mapboxgl-ctrl-group button:focus:not(:focus-visible) {
|
||||
box-shadow: none;
|
||||
}
|
||||
.mapboxgl-ctrl-group button:focus:first-child {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.mapboxgl-ctrl-group button:focus:last-child {
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.mapboxgl-ctrl-group button:focus:only-child {
|
||||
border-radius: inherit;
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
@media (-ms-high-contrast: active) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
@media (-ms-high-contrast: black-on-white) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
@media (-ms-high-contrast: active) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
@media (-ms-high-contrast: black-on-white) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E");
|
||||
}
|
||||
@media (-ms-high-contrast: active) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
@media (-ms-high-contrast: black-on-white) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting
|
||||
.mapboxgl-ctrl-icon {
|
||||
-webkit-animation: mapboxgl-spin 2s linear infinite;
|
||||
-moz-animation: mapboxgl-spin 2s infinite linear;
|
||||
-o-animation: mapboxgl-spin 2s infinite linear;
|
||||
-ms-animation: mapboxgl-spin 2s infinite linear;
|
||||
animation: mapboxgl-spin 2s linear infinite;
|
||||
}
|
||||
@media (-ms-high-contrast: active) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl
|
||||
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error
|
||||
.mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
@media (-ms-high-contrast: black-on-white) {
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes mapboxgl-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-webkit-transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@-moz-keyframes mapboxgl-spin {
|
||||
0% {
|
||||
-moz-transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-moz-transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@-o-keyframes mapboxgl-spin {
|
||||
0% {
|
||||
-o-transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-o-transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@-ms-keyframes mapboxgl-spin {
|
||||
0% {
|
||||
-ms-transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-ms-transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
@keyframes mapboxgl-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
a.mapboxgl-ctrl-logo {
|
||||
width: 88px;
|
||||
height: 23px;
|
||||
margin: 0 0 -4px -4px;
|
||||
display: block;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg opacity='.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cg opacity='.9' fill='%23fff'%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
a.mapboxgl-ctrl-logo.mapboxgl-compact {
|
||||
width: 23px;
|
||||
}
|
||||
@media (-ms-high-contrast: active) {
|
||||
a.mapboxgl-ctrl-logo {
|
||||
background-color: transparent;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cg fill='%23fff'%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
@media (-ms-high-contrast: black-on-white) {
|
||||
a.mapboxgl-ctrl-logo {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
.mapboxgl-ctrl.mapboxgl-ctrl-attrib {
|
||||
padding: 0 5px;
|
||||
background-color: hsla(0, 0%, 100%, 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
@media screen {
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact {
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
margin: 10px;
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
border-radius: 3px 12px 12px 3px;
|
||||
}
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact:hover {
|
||||
padding: 2px 24px 2px 4px;
|
||||
visibility: visible;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:hover,
|
||||
.mapboxgl-ctrl-top-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:hover {
|
||||
padding: 2px 4px 2px 24px;
|
||||
border-radius: 12px 3px 3px 12px;
|
||||
}
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner {
|
||||
display: none;
|
||||
}
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact:hover .mapboxgl-ctrl-attrib-inner {
|
||||
display: block;
|
||||
}
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact:after {
|
||||
content: '';
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E");
|
||||
background-color: hsla(0, 0%, 100%, 0.5);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-right > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
.mapboxgl-ctrl-top-right > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
.mapboxgl-ctrl-top-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.mapboxgl-ctrl-bottom-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
@media screen and (-ms-high-contrast: active) {
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact:after {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
@media screen and (-ms-high-contrast: black-on-white) {
|
||||
.mapboxgl-ctrl-attrib.mapboxgl-compact:after {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
.mapboxgl-ctrl-attrib a {
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
text-decoration: none;
|
||||
}
|
||||
.mapboxgl-ctrl-attrib a:hover {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mapboxgl-ctrl-attrib .mapbox-improve-map {
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.mapboxgl-attrib-empty {
|
||||
display: none;
|
||||
}
|
||||
.mapboxgl-ctrl-scale {
|
||||
background-color: hsla(0, 0%, 100%, 0.75);
|
||||
font-size: 10px;
|
||||
border: 2px solid #333;
|
||||
border-top: #333;
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mapboxgl-popup {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mapboxgl-popup-anchor-top,
|
||||
.mapboxgl-popup-anchor-top-left,
|
||||
.mapboxgl-popup-anchor-top-right {
|
||||
-webkit-flex-direction: column;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mapboxgl-popup-anchor-bottom,
|
||||
.mapboxgl-popup-anchor-bottom-left,
|
||||
.mapboxgl-popup-anchor-bottom-right {
|
||||
-webkit-flex-direction: column-reverse;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.mapboxgl-popup-anchor-left {
|
||||
-webkit-flex-direction: row;
|
||||
flex-direction: row;
|
||||
}
|
||||
.mapboxgl-popup-anchor-right {
|
||||
-webkit-flex-direction: row-reverse;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.mapboxgl-popup-tip {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 10px solid transparent;
|
||||
z-index: 1;
|
||||
}
|
||||
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
|
||||
-webkit-align-self: center;
|
||||
align-self: center;
|
||||
border-top: none;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip {
|
||||
-webkit-align-self: flex-start;
|
||||
align-self: flex-start;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip {
|
||||
-webkit-align-self: flex-end;
|
||||
align-self: flex-end;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
|
||||
-webkit-align-self: center;
|
||||
align-self: center;
|
||||
border-bottom: none;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip {
|
||||
-webkit-align-self: flex-start;
|
||||
align-self: flex-start;
|
||||
border-bottom: none;
|
||||
border-left: none;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip {
|
||||
-webkit-align-self: flex-end;
|
||||
align-self: flex-end;
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
|
||||
-webkit-align-self: center;
|
||||
align-self: center;
|
||||
border-left: none;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
|
||||
-webkit-align-self: center;
|
||||
align-self: center;
|
||||
border-right: none;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.mapboxgl-popup-close-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
border: 0;
|
||||
border-radius: 0 3px 0 0;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
}
|
||||
.mapboxgl-popup-close-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.mapboxgl-popup-content {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
padding: 10px 10px 15px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content {
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content {
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content {
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.mapboxgl-popup-track-pointer {
|
||||
display: none;
|
||||
}
|
||||
.mapboxgl-popup-track-pointer * {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.mapboxgl-map:hover .mapboxgl-popup-track-pointer {
|
||||
display: flex;
|
||||
}
|
||||
.mapboxgl-map:active .mapboxgl-popup-track-pointer {
|
||||
display: none;
|
||||
}
|
||||
.mapboxgl-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.mapboxgl-user-location-dot,
|
||||
.mapboxgl-user-location-dot:before {
|
||||
background-color: #1da1f2;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.mapboxgl-user-location-dot:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
-webkit-animation: mapboxgl-user-location-dot-pulse 2s infinite;
|
||||
-moz-animation: mapboxgl-user-location-dot-pulse 2s infinite;
|
||||
-ms-animation: mapboxgl-user-location-dot-pulse 2s infinite;
|
||||
animation: mapboxgl-user-location-dot-pulse 2s infinite;
|
||||
}
|
||||
.mapboxgl-user-location-dot:after {
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
content: '';
|
||||
height: 19px;
|
||||
left: -2px;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 19px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
@-webkit-keyframes mapboxgl-user-location-dot-pulse {
|
||||
0% {
|
||||
-webkit-transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
-webkit-transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
-webkit-transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@-ms-keyframes mapboxgl-user-location-dot-pulse {
|
||||
0% {
|
||||
-ms-transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
-ms-transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
-ms-transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes mapboxgl-user-location-dot-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.mapboxgl-user-location-dot-stale {
|
||||
background-color: #aaa;
|
||||
}
|
||||
.mapboxgl-user-location-dot-stale:after {
|
||||
display: none;
|
||||
}
|
||||
.mapboxgl-user-location-accuracy-circle {
|
||||
background-color: rgba(29, 161, 242, 0.2);
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
.mapboxgl-crosshair,
|
||||
.mapboxgl-crosshair .mapboxgl-interactive,
|
||||
.mapboxgl-crosshair .mapboxgl-interactive:active {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.mapboxgl-boxzoom {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: #fff;
|
||||
border: 2px dotted #202020;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@media print {
|
||||
.mapbox-improve-map {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
@import './variables';
|
||||
@import '~bulma/sass/base/_all.sass';
|
||||
@import '~bulma/sass/components/navbar.sass';
|
||||
@import '~bulma/sass/components/breadcrumb.sass';
|
||||
@import '~bulma/sass/components/card.sass';
|
||||
@import '~bulma/sass/components/message.sass';
|
||||
@import '~bulma/sass/components/tabs.sass';
|
||||
@import '~bulma/sass/components/modal.sass';
|
||||
@import '~bulma/sass/elements/button.sass';
|
||||
@import '~bulma/sass/elements/box.sass';
|
||||
@import '~bulma/sass/elements/container.sass';
|
||||
@import '~bulma/sass/elements/table.sass';
|
||||
@import '~bulma/sass/elements/title.sass';
|
||||
@import '~bulma/sass/elements/progress.sass';
|
||||
@import '~bulma/sass/elements/other.sass';
|
||||
@import '~bulma/sass/form/_all.sass';
|
||||
@import '~bulma/sass/layout/_all.sass';
|
||||
@import '~bulma/sass/grid/columns.sass';
|
||||
23
src/styles/index.css
Normal file
23
src/styles/index.css
Normal file
@@ -0,0 +1,23 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: vaquant --default, light, dark;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'vaquant';
|
||||
default: true;
|
||||
color-scheme: light;
|
||||
--color-primary: #3f4fa6;
|
||||
--color-primary-content: #ffffff;
|
||||
--color-secondary: #2c3e50;
|
||||
--color-secondary-content: #ffffff;
|
||||
--color-success: oklch(67% 0.17 145);
|
||||
--color-error: oklch(60% 0.21 25);
|
||||
--color-base-100: #ffffff;
|
||||
--color-base-200: #f5f5f7;
|
||||
--color-base-300: #e5e5ea;
|
||||
--color-base-content: #1c1c1e;
|
||||
}
|
||||
|
||||
@import '@tabler/icons-webfont/dist/tabler-icons.min.css';
|
||||
@@ -1,155 +0,0 @@
|
||||
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
|
||||
@charset 'utf-8';
|
||||
@import url('https://fonts.googleapis.com/css?family=Nunito|Raleway|Open+Sans&display=swap');
|
||||
@import url('https://css.gg/css?=|pin-alt');
|
||||
@import './framework';
|
||||
@import './transitions';
|
||||
:root {
|
||||
--primary-color: #{$primary};
|
||||
--primary-font-color: #{$main};
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.app {
|
||||
color: $main;
|
||||
text-align: center;
|
||||
|
||||
.field-body {
|
||||
flex-grow: 3;
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
&.nav {
|
||||
a {
|
||||
color: $main;
|
||||
font-weight: bold;
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app,
|
||||
a,
|
||||
button {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
}
|
||||
|
||||
.no-margin {
|
||||
margin: 0 0.75rem;
|
||||
}
|
||||
|
||||
.clear-margin {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-padding {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.numeric {
|
||||
font-family: 'Cutive Mono', monospace;
|
||||
}
|
||||
|
||||
$navbar-color: #eaeaea4d;
|
||||
|
||||
.navbar-item.nav-button {
|
||||
border: 2px solid $navbar-color;
|
||||
border-radius: 10px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.title,
|
||||
.subtitle,
|
||||
tr {
|
||||
color: $main;
|
||||
font-family: 'Raleway', sans-serif;
|
||||
}
|
||||
|
||||
img {
|
||||
&.logo {
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
width: 180pt;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: auto;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.icon.is-left {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 16pt;
|
||||
text-align: center;
|
||||
|
||||
ul {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Service Worker update
|
||||
.notify-worker {
|
||||
background-color: $main;
|
||||
border-radius: 2px;
|
||||
bottom: 30px;
|
||||
color: $white;
|
||||
margin: auto;
|
||||
max-width: 450px;
|
||||
min-width: 250px;
|
||||
padding: 16px;
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
visibility: hidden;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
|
||||
&.show {
|
||||
animation: fadein 0.5s;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
.total {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: 30px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// sass-lint:disable no-transition-all final-newline
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition-duration: .1s;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
|
||||
$primary: #3f4fa6;
|
||||
$main: #2c3e50;
|
||||
$green: hsl(141, 63%, 44%);
|
||||
$red: hsl(348, 54%, 52%);
|
||||
$white: #fff;
|
||||
$progress-border-radius: 0;
|
||||
|
||||
@import '~bulma/sass/utilities/_all.sass';
|
||||
@@ -1,7 +1,14 @@
|
||||
import Vue from 'vue'
|
||||
import mitt from 'mitt'
|
||||
|
||||
export default new Vue()
|
||||
export const ONLINE = 'ONLINE'
|
||||
export const OFFLINE = 'OFFLINE'
|
||||
export const SYNC = 'SYNC'
|
||||
|
||||
export const ONLINE: string = 'ONLINE'
|
||||
export const OFFLINE: string = 'OFFLINE'
|
||||
export const SYNC: string = 'SYNC'
|
||||
type Events = {
|
||||
[ONLINE]: undefined
|
||||
[OFFLINE]: undefined
|
||||
[SYNC]: string[] | undefined
|
||||
}
|
||||
|
||||
export const bus = mitt<Events>()
|
||||
export default bus
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import type ICurrency from '@/models/ICurrency'
|
||||
|
||||
export const money = (
|
||||
value: number,
|
||||
currency: ICurrency | null | undefined,
|
||||
country: string = 'fr-FR'
|
||||
country = 'fr-FR'
|
||||
): string => {
|
||||
if (!currency) {
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(country, {
|
||||
style: 'currency',
|
||||
currency: currency.code,
|
||||
@@ -19,36 +18,24 @@ export const money = (
|
||||
export const moneypad = (
|
||||
value: number,
|
||||
currency?: ICurrency | null,
|
||||
withPadEnd: boolean = true
|
||||
withPadEnd = true
|
||||
): string => {
|
||||
const m = money(value, currency)
|
||||
const s: string[] = m.split('\xa0')
|
||||
let cur: string | undefined = s.pop()
|
||||
|
||||
if (withPadEnd) {
|
||||
cur = (cur || '').padEnd(3, '\xa0')
|
||||
}
|
||||
|
||||
const result = `${s.join('\xa0')}\xa0${cur || ''}`
|
||||
return result
|
||||
return `${s.join('\xa0')}\xa0${cur || ''}`
|
||||
}
|
||||
|
||||
export const date = (value: string, country: string = 'fr-FR') => {
|
||||
return new Intl.DateTimeFormat(country).format(new Date(value))
|
||||
}
|
||||
export const date = (value: string, country = 'fr-FR') =>
|
||||
new Intl.DateTimeFormat(country).format(new Date(value))
|
||||
|
||||
export const fulldate = (value: string, country: string = 'fr-FR') => {
|
||||
return new Intl.DateTimeFormat(country, {
|
||||
export const fulldate = (value: string, country = 'fr-FR') =>
|
||||
new Intl.DateTimeFormat(country, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
export default {
|
||||
money,
|
||||
moneypad,
|
||||
date,
|
||||
fulldate
|
||||
} as { [key: string]: (...args: any[]) => any }
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Component } from 'vue-property-decorator'
|
||||
|
||||
Component.registerHooks(['beforeRouteEnter', 'beforeRouteUpdate'])
|
||||
@@ -1,15 +1,6 @@
|
||||
import Vue from 'vue'
|
||||
import '@tabler/icons-webfont/dist/tabler-icons.min.css'
|
||||
import type { App } from 'vue'
|
||||
import VaquantIcon from '@/components/VaquantIcon.vue'
|
||||
|
||||
Vue.component('vaquant-icon', {
|
||||
functional: true,
|
||||
props: {
|
||||
icon: { type: String, required: true }
|
||||
},
|
||||
render(h, { props, data }) {
|
||||
return h('i', {
|
||||
...data,
|
||||
class: [`ti ti-${props.icon}`, data.class, data.staticClass]
|
||||
})
|
||||
}
|
||||
})
|
||||
export const registerIcons = (app: App): void => {
|
||||
app.component('VaquantIcon', VaquantIcon)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
declare var navigator: any
|
||||
interface NetworkConnection {
|
||||
effectiveType?: string
|
||||
metered?: boolean
|
||||
bandwidth?: number
|
||||
}
|
||||
|
||||
interface NavigatorWithConnection {
|
||||
connection?: NetworkConnection
|
||||
mozConnection?: NetworkConnection
|
||||
webkitConnection?: NetworkConnection
|
||||
}
|
||||
|
||||
export const hasGoodNetwork = (): boolean => {
|
||||
const nav = navigator as Navigator & NavigatorWithConnection
|
||||
const connection =
|
||||
navigator.connection ||
|
||||
navigator.mozConnection ||
|
||||
navigator.webkitConnection
|
||||
nav.connection || nav.mozConnection || nav.webkitConnection
|
||||
|
||||
if (connection) {
|
||||
if (connection.effectiveType) {
|
||||
const goodNetworks: string[] = ['3g', '4g']
|
||||
const goodNetworks = ['3g', '4g']
|
||||
return goodNetworks.includes(connection.effectiveType)
|
||||
} else {
|
||||
const highBandwidth: boolean =
|
||||
connection.metered && (connection.bandwidth || 0) > 2
|
||||
return highBandwidth
|
||||
}
|
||||
return !!(connection.metered && (connection.bandwidth || 0) > 2)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const ROOT: string =
|
||||
process.env.NODE_ENV === 'production'
|
||||
import.meta.env.MODE === 'production'
|
||||
? 'https://vaquant.azurewebsites.net/api'
|
||||
: 'http://localhost:7071/api'
|
||||
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
<template>
|
||||
<div class="about no-margin">
|
||||
<img class="logo" src="../assets/logo.svg" title="logo Vaquant" />
|
||||
<p class="simple-description" v-t="'about.description'"></p>
|
||||
<hr />
|
||||
<app-resume />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppResume from '@/components/AppResume.vue'
|
||||
import logoUrl from '@/assets/logo.svg'
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'app-resume': () => import('@/components/AppResume.vue')
|
||||
}
|
||||
})
|
||||
export default class About extends Vue {}
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.simple-description {
|
||||
max-width: 450pt;
|
||||
margin: auto;
|
||||
font-size: 16pt;
|
||||
text-align: justify;
|
||||
text-align-last: center;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="about p-6 text-center">
|
||||
<img :src="logoUrl" class="mx-auto max-w-xs" alt="logo Vaquant" />
|
||||
<p class="simple-description mx-auto mt-6 text-justify text-lg max-w-2xl">
|
||||
{{ t('about.description') }}
|
||||
</p>
|
||||
<hr class="my-8" />
|
||||
<AppResume />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,70 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeMount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import accountService from '@/services/AccountService'
|
||||
import AccountList from '@/components/AccountList.vue'
|
||||
import AppResume from '@/components/AppResume.vue'
|
||||
import FabButton from '@/components/FabButton.vue'
|
||||
import logoUrl from '@/assets/logo.svg'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
const { t } = useI18n()
|
||||
const showAccounts = ref(false)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
const accounts = await accountService.getAll()
|
||||
showAccounts.value = !!accounts.length
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<div v-if="user">
|
||||
<div class="account-list-container">
|
||||
<account-list />
|
||||
</div>
|
||||
<div v-if="user" class="mt-4">
|
||||
<AccountList />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div>
|
||||
<img class="logo" src="../assets/logo.svg" title="logo Vaquant" />
|
||||
<h2 class="title is-2">
|
||||
Gérer vos budgets de groupe en toute simplicité !
|
||||
</h2>
|
||||
<div v-else class="text-center py-6">
|
||||
<img :src="logoUrl" class="mx-auto max-w-xs" alt="logo Vaquant" />
|
||||
<h2 class="text-3xl font-bold mt-4 max-w-2xl mx-auto">
|
||||
Gérer vos budgets de groupe en toute simplicité !
|
||||
</h2>
|
||||
<router-link class="btn btn-primary mt-6" :to="{ name: 'user' }">
|
||||
{{ t('user.loginsignup') }}
|
||||
</router-link>
|
||||
<div v-if="showAccounts" class="mt-4">
|
||||
<AccountList />
|
||||
</div>
|
||||
<br />
|
||||
<router-link
|
||||
class="button is-primary"
|
||||
:to="{ name: 'user' }"
|
||||
v-t="'user.loginsignup'"
|
||||
></router-link>
|
||||
<div class="account-list-container" v-if="showAccounts">
|
||||
<account-list />
|
||||
</div>
|
||||
<fab-button v-else :to="{ name: 'account-new' }" :margin="true">
|
||||
<span slot="fulltext">créer un compte</span>
|
||||
</fab-button>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h3 class="title is-3">Fonctionnalités</h3>
|
||||
<app-resume />
|
||||
</div>
|
||||
</div>
|
||||
<FabButton v-else :to="{ name: 'account-new' }" :margin="true">
|
||||
<template #fulltext>créer un compte</template>
|
||||
</FabButton>
|
||||
<section class="mt-12 px-4">
|
||||
<h3 class="text-2xl font-bold mb-4">Fonctionnalités</h3>
|
||||
<AppResume />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IUser from '@/models/IUser'
|
||||
import AccountList from '@/components/AccountList.vue'
|
||||
import accountService from '@/services/AccountService'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'account-list': AccountList,
|
||||
'app-resume': () => import('@/components/AppResume.vue'),
|
||||
'fab-button': () => import('@/components/FabButton.vue')
|
||||
}
|
||||
})
|
||||
export default class Home extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
public showAccounts: boolean = false
|
||||
|
||||
public async created() {
|
||||
const accounts = await accountService.getAll()
|
||||
this.showAccounts = !!accounts.length
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-list-container {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<h1 class="is-1 title">Offres disponibles</h1>
|
||||
<h2 class="is-2 subtitle">
|
||||
Vaquant simplifie vos budgets.
|
||||
</h2>
|
||||
<pricing-table />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'pricing-table': () => import('@/components/PricingTable.vue')
|
||||
}
|
||||
})
|
||||
export default class Princing extends Vue {
|
||||
}
|
||||
</script>
|
||||
@@ -1,18 +1,6 @@
|
||||
<template>
|
||||
<div class="security is-centered" v-once>
|
||||
<h2 class="title is-2">
|
||||
Comment est fait le chiffrement des données ?
|
||||
</h2>
|
||||
<p>
|
||||
Le chiffrement de vos données
|
||||
</p>
|
||||
<div v-once class="security text-center p-6">
|
||||
<h2 class="text-3xl font-bold mb-4">Comment est fait le chiffrement des données ?</h2>
|
||||
<p>Le chiffrement de vos données</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
|
||||
@Component
|
||||
export default class Security extends Vue {
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,380 +1,263 @@
|
||||
<template>
|
||||
<div class="user no-margin">
|
||||
<div v-if="user">
|
||||
<h2 class="title is-2">{{ user.userId }}</h2>
|
||||
<div class="columns is-centered">
|
||||
<div class="column">
|
||||
<lang-changer />
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="buttons is-centered">
|
||||
<button
|
||||
class="button is-warning"
|
||||
type="button"
|
||||
@click="logout"
|
||||
v-t="'user.logout'"
|
||||
></button>
|
||||
<button
|
||||
class="button is-warning"
|
||||
type="button"
|
||||
@click="purge"
|
||||
v-t="'user.purge'"
|
||||
></button>
|
||||
<confirm-button class="is-danger" @confirm="remove">
|
||||
{{ $t('user.delete') }}
|
||||
</confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<account-list />
|
||||
<div>
|
||||
<h3 class="subtitle is-3">Comptes cloturés</h3>
|
||||
<account-list :archived="true" />
|
||||
</div>
|
||||
<pricing-table />
|
||||
<payment-checkout :email="user.email" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="tabs is-centered is-fullwidth">
|
||||
<ul>
|
||||
<li :class="{ 'is-active': activeTab === 'login' }">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="activeTab = 'login'"
|
||||
v-t="'user.login'"
|
||||
></a>
|
||||
</li>
|
||||
<li :class="{ 'is-active': activeTab === 'signup' }">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="activeTab = 'signup'"
|
||||
v-t="'user.signup'"
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="user-container">
|
||||
<div v-show="activeTab === 'login'">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">pseudo</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
name="login"
|
||||
id="login"
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="signin"
|
||||
v-model.trim="userId"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.password'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
name="password"
|
||||
id="password"
|
||||
class="input"
|
||||
type="password"
|
||||
@keyup.enter="signin"
|
||||
v-model="password"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="button is-primary"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@click="signin"
|
||||
v-t="'user.login'"
|
||||
></button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'signup'">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label">pseudo</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
required
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="register"
|
||||
v-model.trim="userId"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.password'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
required
|
||||
class="input"
|
||||
type="password"
|
||||
@keyup.enter="register"
|
||||
v-model="password"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.confirm_password'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
required
|
||||
class="input"
|
||||
type="password"
|
||||
@keyup.enter="register"
|
||||
v-model="confirmPassword"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.firstname'"></label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="register"
|
||||
v-model.trim="firstname"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal">
|
||||
<div class="field-label is-normal">
|
||||
<label class="label" v-t="'user.lastname'">nom</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
@keyup.enter="register"
|
||||
v-model.trim="lastname"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <pricing-table first-plan="free" @choose="plan => user.premium = plan === 'premium'"/> -->
|
||||
<hr />
|
||||
<button
|
||||
class="button is-primary"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@click="register(false)"
|
||||
v-t="'user.signup'"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal" :class="{ 'is-active': show }">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<section class="modal-card-body">
|
||||
Voulez-vous récupérer les comptes utilisés avant d'être inscrit ?
|
||||
</section>
|
||||
<footer class="modal-card-foot buttons has-addons is-centered">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-success"
|
||||
@click="toReplicate(true)"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button type="button" class="button" @click="toReplicate(false)">
|
||||
Non
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import { slug } from '@/utils'
|
||||
import IUser from '@/models/IUser'
|
||||
import LangChanger from '@/components/LangChanger.vue'
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type IUser from '@/models/IUser'
|
||||
import userService from '@/services/UserService'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import { slug } from '@/utils'
|
||||
import LangChanger from '@/components/LangChanger.vue'
|
||||
import AccountList from '@/components/AccountList.vue'
|
||||
import ConfirmButton from '@/components/ConfirmButton.vue'
|
||||
|
||||
const enableAnonymous: boolean = false
|
||||
const props = withDefaults(defineProps<{ premail?: string }>(), { premail: '' })
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LangChanger,
|
||||
'account-list': () => import('@/components/AccountList.vue'),
|
||||
'confirm-button': () => import('@/components/ConfirmButton.vue'),
|
||||
'pricing-table': () => import('@/components/PricingTable.vue'),
|
||||
'payment-checkout': () => import('@/components/Payment.vue')
|
||||
const enableAnonymous = false
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<'login' | 'signup'>('login')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const firstname = ref('')
|
||||
const lastname = ref('')
|
||||
const isLoading = ref(false)
|
||||
const show = ref(false)
|
||||
const replicate = ref(false)
|
||||
const localeUserId = ref('')
|
||||
|
||||
const userId = computed({
|
||||
get: () => localeUserId.value,
|
||||
set: (val: string) => {
|
||||
localeUserId.value = (val || '').toLowerCase()
|
||||
}
|
||||
})
|
||||
export default class User extends Vue {
|
||||
@Prop({ type: String, required: false, default: '' })
|
||||
public premail!: string
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Action
|
||||
public login!: any
|
||||
@Action
|
||||
public signup!: any
|
||||
@Action
|
||||
public logout!: any
|
||||
@Action
|
||||
public remove!: any
|
||||
public activeTab: string = 'login'
|
||||
public email: string = ''
|
||||
public password: string = ''
|
||||
public confirmPassword: string = ''
|
||||
public firstname: string = ''
|
||||
public lastname: string = ''
|
||||
public isLoading: boolean = false
|
||||
public show: boolean = false
|
||||
public replicate: boolean = false
|
||||
public confirmed: boolean = false
|
||||
private localeUserId: string = ''
|
||||
|
||||
public mounted(): void {
|
||||
if (this.premail) {
|
||||
this.email = this.premail
|
||||
}
|
||||
const slugEmail = computed(() => slug(email.value))
|
||||
|
||||
onMounted(() => {
|
||||
if (props.premail) {
|
||||
email.value = props.premail
|
||||
}
|
||||
})
|
||||
|
||||
public async signin(): Promise<void> {
|
||||
this.isLoading = true
|
||||
await this.login({
|
||||
userId: this.userId,
|
||||
password: this.password
|
||||
})
|
||||
this.isLoading = false
|
||||
const signin = async () => {
|
||||
isLoading.value = true
|
||||
await userStore.login({ userId: userId.value, password: password.value })
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const validateRegistration = (): boolean => {
|
||||
if (!userId.value) {
|
||||
queueNotifService.error('Un pseudo est obligatoire.')
|
||||
return false
|
||||
}
|
||||
|
||||
public toReplicate(replicate: boolean): void {
|
||||
this.replicate = replicate
|
||||
this.show = false
|
||||
this.register(true)
|
||||
if (password.value !== confirmPassword.value) {
|
||||
queueNotifService.error('Les mots de passe doivent être identique.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public async register(confirmed: boolean): Promise<void> {
|
||||
if (!this.validateRegistration()) {
|
||||
const register = async (confirmed: boolean) => {
|
||||
if (!validateRegistration()) return
|
||||
try {
|
||||
if (enableAnonymous && (await userService.hasAnonymousData()) && !confirmed) {
|
||||
if (!show.value) show.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (
|
||||
enableAnonymous &&
|
||||
(await userService.hasAnonymousData()) &&
|
||||
!confirmed
|
||||
) {
|
||||
if (!this.show) {
|
||||
this.show = true
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
queueNotifService.error(
|
||||
`Une erreur est survenue à la vérification d'un compte anonyme déjà créé.`
|
||||
)
|
||||
// tslint:disable-next-line
|
||||
console.warn({ error })
|
||||
} catch (error) {
|
||||
queueNotifService.error(
|
||||
`Une erreur est survenue à la vérification d'un compte anonyme déjà créé.`
|
||||
)
|
||||
console.warn({ error })
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const newUser: IUser = {
|
||||
userId: userId.value,
|
||||
email: email.value,
|
||||
premium: false,
|
||||
slugEmail: slugEmail.value,
|
||||
firstname: firstname.value,
|
||||
lastname: lastname.value
|
||||
}
|
||||
this.isLoading = true
|
||||
try {
|
||||
const user: IUser = {
|
||||
userId: this.userId,
|
||||
email: this.email,
|
||||
premium: false,
|
||||
slugEmail: this.slugEmail,
|
||||
firstname: this.firstname,
|
||||
lastname: this.lastname
|
||||
}
|
||||
await this.signup({
|
||||
user,
|
||||
password: this.password,
|
||||
replicate: this.replicate
|
||||
})
|
||||
} catch (error) {
|
||||
queueNotifService.error(
|
||||
`Une erreur est survenue à la création du compte utilisateur.`
|
||||
)
|
||||
// tslint:disable-next-line
|
||||
console.warn({ error })
|
||||
}
|
||||
this.isLoading = false
|
||||
await userStore.signup({
|
||||
user: newUser,
|
||||
password: password.value,
|
||||
replicate: replicate.value
|
||||
})
|
||||
} catch (error) {
|
||||
queueNotifService.error(
|
||||
`Une erreur est survenue à la création du compte utilisateur.`
|
||||
)
|
||||
console.warn({ error })
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
public validateRegistration(): boolean {
|
||||
if (!this.userId) {
|
||||
queueNotifService.error('Un pseudo est obligatoire.')
|
||||
return false
|
||||
}
|
||||
if (this.password !== this.confirmPassword) {
|
||||
queueNotifService.error('Les mots de passe doivent être identique.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const toReplicate = (rep: boolean) => {
|
||||
replicate.value = rep
|
||||
show.value = false
|
||||
register(true)
|
||||
}
|
||||
|
||||
public async purge(): Promise<void> {
|
||||
await userService.purge()
|
||||
queueNotifService.success(this.$t('user.purgeDone').toString())
|
||||
}
|
||||
const logout = () => userStore.logout()
|
||||
const remove = () => userStore.remove()
|
||||
|
||||
public get slugEmail(): string {
|
||||
return slug(this.email)
|
||||
}
|
||||
|
||||
public set userId(newUserId: string) {
|
||||
this.localeUserId = (newUserId || '').toLowerCase()
|
||||
}
|
||||
|
||||
public get userId() {
|
||||
return this.localeUserId
|
||||
}
|
||||
const purge = async () => {
|
||||
await userService.purge()
|
||||
queueNotifService.success(t('user.purgeDone'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user {
|
||||
padding: 15px 0;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="user p-4">
|
||||
<div v-if="user">
|
||||
<h2 class="text-3xl font-bold text-center mb-6">{{ user.userId }}</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
|
||||
<LangChanger />
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
<button class="btn btn-warning" type="button" @click="logout">
|
||||
{{ t('user.logout') }}
|
||||
</button>
|
||||
<button class="btn btn-warning" type="button" @click="purge">
|
||||
{{ t('user.purge') }}
|
||||
</button>
|
||||
<ConfirmButton class="btn-error" @confirm="remove">
|
||||
{{ t('user.delete') }}
|
||||
</ConfirmButton>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-6" />
|
||||
<AccountList />
|
||||
<div class="mt-8">
|
||||
<h3 class="text-2xl font-semibold text-center mb-4">Comptes cloturés</h3>
|
||||
<AccountList :archived="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="max-w-xl mx-auto">
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6">
|
||||
<a
|
||||
role="tab"
|
||||
href="#"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'login' }"
|
||||
@click.prevent="activeTab = 'login'"
|
||||
>
|
||||
{{ t('user.login') }}
|
||||
</a>
|
||||
<a
|
||||
role="tab"
|
||||
href="#"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'signup' }"
|
||||
@click.prevent="activeTab = 'signup'"
|
||||
>
|
||||
{{ t('user.signup') }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-show="activeTab === 'login'" class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="label w-24">pseudo</label>
|
||||
<input
|
||||
id="login"
|
||||
v-model.trim="userId"
|
||||
name="login"
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
@keyup.enter="signin"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="label w-24">{{ t('user.password') }}</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="input input-bordered flex-1"
|
||||
@keyup.enter="signin"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-loading': isLoading }"
|
||||
@click="signin"
|
||||
>
|
||||
{{ t('user.login') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="activeTab === 'signup'" class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="label w-32">pseudo</label>
|
||||
<input
|
||||
v-model.trim="userId"
|
||||
required
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
@keyup.enter="register(false)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="label w-32">{{ t('user.password') }}</label>
|
||||
<input
|
||||
v-model="password"
|
||||
required
|
||||
type="password"
|
||||
class="input input-bordered flex-1"
|
||||
@keyup.enter="register(false)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="label w-32">{{ t('user.confirm_password') }}</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
required
|
||||
type="password"
|
||||
class="input input-bordered flex-1"
|
||||
@keyup.enter="register(false)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="label w-32">{{ t('user.firstname') }}</label>
|
||||
<input
|
||||
v-model.trim="firstname"
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
@keyup.enter="register(false)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="label w-32">{{ t('user.lastname') }}</label>
|
||||
<input
|
||||
v-model.trim="lastname"
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
@keyup.enter="register(false)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-loading': isLoading }"
|
||||
@click="register(false)"
|
||||
>
|
||||
{{ t('user.signup') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<dialog class="modal" :class="{ 'modal-open': show }">
|
||||
<div class="modal-box">
|
||||
<p>Voulez-vous récupérer les comptes utilisés avant d'être inscrit ?</p>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-success" @click="toReplicate(true)">Oui</button>
|
||||
<button type="button" class="btn" @click="toReplicate(false)">Non</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,569 +1,323 @@
|
||||
<template>
|
||||
<section class="hero is-fullheight account-item" v-if="account">
|
||||
<account-encrypted v-if="isEncrypted" />
|
||||
<div class="account-item-container">
|
||||
<h2 class="title is-2 account-title">
|
||||
<span :style="colorStyle()">{{ account.name }}</span>
|
||||
</h2>
|
||||
<div class="columns is-centered no-margin" v-if="showShare">
|
||||
<div class="column is-one-third">
|
||||
<account-share :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
<article
|
||||
class="message is-warning"
|
||||
v-if="transactionWithoutExchange.length"
|
||||
>
|
||||
<div class="message-header">
|
||||
<p>Attention</p>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
Certaines dépenses ne sont pas comptées car en attente de récupérer
|
||||
les taux pratiqués à leur date respectives.
|
||||
<ul>
|
||||
<li v-for="(transaction, k) in transactionWithoutExchange" :key="k">
|
||||
-
|
||||
<router-link
|
||||
:to="{ name: 'transaction', params: { id: transaction._id } }"
|
||||
>{{ transaction.name }}</router-link
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<online-view @online="retrieveExchange">
|
||||
<button
|
||||
@click="retrieveExchange"
|
||||
class="button is-success is-large is-rounded"
|
||||
:class="{ 'is-loading': retrievingExchange }"
|
||||
>
|
||||
Récupérer les devises
|
||||
</button>
|
||||
</online-view>
|
||||
</div>
|
||||
</article>
|
||||
<div class="tabs is-toggle is-fullwidth">
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
:style="colorStyle(activeTab === 'transaction')"
|
||||
href="#"
|
||||
@click.prevent="toggleTab('transaction')"
|
||||
>
|
||||
<vaquant-icon icon="currency-dollar" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="transactions.length && locations.length">
|
||||
<a
|
||||
:style="colorStyle(activeTab === 'location')"
|
||||
href="#"
|
||||
@click.prevent="toggleTab('location')"
|
||||
>
|
||||
<vaquant-icon icon="world" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="transactions.length && tagCount > 1">
|
||||
<a
|
||||
:style="colorStyle(activeTab === 'tag')"
|
||||
href="#"
|
||||
@click.prevent="toggleTab('tag')"
|
||||
>
|
||||
<vaquant-icon icon="tag" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="transactions.length && isMultiUser">
|
||||
<a
|
||||
:style="colorStyle(activeTab === 'balance')"
|
||||
href="#"
|
||||
@click.prevent="toggleTab('balance')"
|
||||
>
|
||||
<vaquant-icon icon="scale" />
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="isAdmin">
|
||||
<a
|
||||
:style="colorStyle(activeTab === 'settings')"
|
||||
href="#"
|
||||
@click.prevent="toggleTab('settings')"
|
||||
>
|
||||
<vaquant-icon icon="settings" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
key="transaction"
|
||||
class="transaction-panel tab-content is-centered"
|
||||
v-if="activeTab === 'transaction'"
|
||||
>
|
||||
<account-transaction-list
|
||||
:account="account"
|
||||
:transactions="transactions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
key="location"
|
||||
class="location-panel tab-content is-centered"
|
||||
v-if="locations.length && activeTab === 'location'"
|
||||
>
|
||||
<earth-map :locations="locations" />
|
||||
</div>
|
||||
<div
|
||||
key="tag"
|
||||
class="tag-panel tab-content is-centered"
|
||||
v-if="activeTab === 'tag'"
|
||||
>
|
||||
<tag-list :transactions="transactionWithoutRefund" :account="account" />
|
||||
</div>
|
||||
<div
|
||||
key="balance"
|
||||
class="balance-panel tab-content is-centered"
|
||||
v-if="activeTab === 'balance'"
|
||||
>
|
||||
<chart-balance
|
||||
:account="account"
|
||||
:stats="userStats"
|
||||
:currency="account.mainCurrency"
|
||||
/>
|
||||
<div
|
||||
v-if="userRefunds.length"
|
||||
class="refund-container no-margin columns is-centered is-multiline"
|
||||
>
|
||||
<div
|
||||
class="column is-one-fifth"
|
||||
v-for="(refund, k) in userRefunds"
|
||||
:key="`${refund.from.alias}-${refund.to.alias}`"
|
||||
>
|
||||
<refund-transaction :refund="userRefunds[k]" :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="equilibrium no-margin">
|
||||
<br />
|
||||
Le compte est à l'équilibre !
|
||||
</div>
|
||||
</div>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useBaseAccount } from '@/composables/useBaseAccount'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import accountService from '@/services/AccountService'
|
||||
import balanceService from '@/services/BalanceService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import couchService from '@/services/CouchService'
|
||||
import type IBalance from '@/models/IBalance'
|
||||
import type ICurrency from '@/models/ICurrency'
|
||||
import type IRefund from '@/models/IRefund'
|
||||
import type IStat from '@/models/IStat'
|
||||
import type ITransaction from '@/models/ITransaction'
|
||||
import type IUser from '@/models/IUser'
|
||||
import type ILocation from '@/models/ILocation'
|
||||
import { primary, main, findContrastColor, findDarkValue } from '@/utils'
|
||||
|
||||
<div
|
||||
key="settings"
|
||||
class="settings-panel tab-content is-centered"
|
||||
v-if="activeTab === 'settings'"
|
||||
>
|
||||
<account-setting :account="account" />
|
||||
import AccountShare from '@/components/AccountShare.vue'
|
||||
import AccountTransactionList from '@/components/AccountTransactionList.vue'
|
||||
import EarthMap from '@/components/EarthMap.vue'
|
||||
import TagList from '@/components/TagList.vue'
|
||||
import ChartBalance from '@/components/ChartBalance.vue'
|
||||
import AccountSetting from '@/components/AccountSetting.vue'
|
||||
import OnlineView from '@/components/OnlineView.vue'
|
||||
import RefundTransaction from '@/components/RefundTransaction.vue'
|
||||
import FabButton from '@/components/FabButton.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
|
||||
const retrieveTransactions = ref(false)
|
||||
const transactions = ref<ITransaction[]>([])
|
||||
const activeTab = ref<'transaction' | 'location' | 'tag' | 'balance' | 'settings'>(
|
||||
'transaction'
|
||||
)
|
||||
const retrievingExchange = ref(false)
|
||||
|
||||
const getData = async (docIds?: string[]): Promise<void> => {
|
||||
if (docIds && !docIds.find((i) => i === props.id)) return
|
||||
try {
|
||||
if (props.id) {
|
||||
account.value = await accountService.get(props.id)
|
||||
if (!account.value) {
|
||||
queueNotifService.error(`le compte ${props.id} n'existe pas`)
|
||||
router.push({ name: 'home' })
|
||||
return
|
||||
}
|
||||
if (account.value.isPublic) {
|
||||
userStore.addAccountId(props.id)
|
||||
couchService.initLive()
|
||||
}
|
||||
transactions.value = await transactionService.getAllByAccountId(props.id)
|
||||
retrieveTransactions.value = true
|
||||
if (document.documentElement) {
|
||||
if (account.value.color) {
|
||||
document.documentElement.style.setProperty('--primary-color', account.value.color)
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-font-color',
|
||||
findDarkValue(account.value.color) || main
|
||||
)
|
||||
} else {
|
||||
document.documentElement.style.setProperty('--primary-color', primary)
|
||||
document.documentElement.style.setProperty('--primary-font-color', main)
|
||||
}
|
||||
}
|
||||
userStore.setTitle(account.value.name)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error({ error })
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
|
||||
const { account, colorStyle } = useBaseAccount(getData)
|
||||
|
||||
const toggleTab = (tab: typeof activeTab.value) => {
|
||||
activeTab.value = tab
|
||||
}
|
||||
|
||||
const retrieveExchange = async () => {
|
||||
if (!transactionWithoutExchange.value.length) return
|
||||
retrievingExchange.value = true
|
||||
try {
|
||||
const list = [...transactionWithoutExchange.value]
|
||||
for (const t of list) {
|
||||
t.exchange = await exchangeService.get(t.mainCurrency.code, t.date)
|
||||
}
|
||||
await transactionService.multipleSave(list)
|
||||
queueNotifService.success('Dépenses mises à jour.')
|
||||
} catch (error) {
|
||||
console.warn({ error })
|
||||
}
|
||||
retrievingExchange.value = false
|
||||
}
|
||||
|
||||
const transactionWithoutRefund = computed(() =>
|
||||
transactions.value.filter((t) => t.transactionType === TransactionType.normal)
|
||||
)
|
||||
const tagCount = computed(
|
||||
() => new Set(transactionWithoutRefund.value.map((t) => t.tag)).size
|
||||
)
|
||||
const transactionWithExchange = computed(() =>
|
||||
transactions.value.filter((t) => !!t.exchange)
|
||||
)
|
||||
const transactionWithoutExchange = computed(() =>
|
||||
transactions.value.filter((t) => !t.exchange)
|
||||
)
|
||||
|
||||
const userAlias = computed<string>(() => {
|
||||
if (!account.value || !user.value) return ''
|
||||
const mainUser = user.value
|
||||
const u = account.value.users.find((x) => x.slugEmail === mainUser.slugEmail)
|
||||
return u?.alias || ''
|
||||
})
|
||||
|
||||
const totalCost = computed(() => {
|
||||
if (!account.value) return 0
|
||||
return transactionService.getTotalCost(
|
||||
transactionWithExchange.value,
|
||||
account.value.mainCurrency.code
|
||||
)
|
||||
})
|
||||
|
||||
const userStats = computed<IStat[]>(() => {
|
||||
if (!account.value || totalCost.value === 0) return []
|
||||
const acc = account.value
|
||||
const list = transactionWithExchange.value
|
||||
const mainCurrency: ICurrency = acc.mainCurrency
|
||||
return acc.users.map((u: IUser) => {
|
||||
const userCost = transactionService.getTotalCost(list, acc.mainCurrency.code, u.alias)
|
||||
const percent = Math.round((userCost / totalCost.value) * 100)
|
||||
return {
|
||||
label: u.alias || '',
|
||||
value: userCost,
|
||||
percent,
|
||||
balance: balanceService.getBalanceByUser(u, mainCurrency, list)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const locations = computed<ILocation[]>(() =>
|
||||
transactions.value
|
||||
.filter((t) => t.location)
|
||||
.map((t) => t.location as ILocation)
|
||||
)
|
||||
|
||||
const userBalance = computed<IBalance[]>(() => {
|
||||
if (!account.value || !account.value.users) return []
|
||||
const mainCurrency: ICurrency = account.value.mainCurrency
|
||||
const list = transactionWithExchange.value
|
||||
return account.value.users.map((u) =>
|
||||
balanceService.getBalanceByUser(u, mainCurrency, list)
|
||||
)
|
||||
})
|
||||
|
||||
const userRefunds = computed<IRefund[]>(() =>
|
||||
account.value ? balanceService.getRefund(userBalance.value) : []
|
||||
)
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
if (!account.value || !account.value.admin || !user.value) return true
|
||||
return account.value.admin.slugEmail === user.value.slugEmail
|
||||
})
|
||||
|
||||
const isMultiUser = computed(() => !!account.value && account.value.users.length > 1)
|
||||
const isEncrypted = computed(
|
||||
() => !!(account.value && account.value.name.length > 30)
|
||||
)
|
||||
|
||||
const showShare = computed(
|
||||
() =>
|
||||
!!account.value &&
|
||||
account.value.users.length > 1 &&
|
||||
retrieveTransactions.value &&
|
||||
!transactions.value.length
|
||||
)
|
||||
|
||||
const backgroundColor = computed(() => {
|
||||
if (!account.value || !account.value.color) return undefined
|
||||
return {
|
||||
backgroundColor: account.value.color,
|
||||
color: findContrastColor(account.value.color) || 'black'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="account" class="account-item p-4">
|
||||
<div v-if="isEncrypted" class="alert alert-warning mb-4">
|
||||
<span>Compte chiffré</span>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-center mb-4 account-title">
|
||||
<span :style="colorStyle()">{{ account.name }}</span>
|
||||
</h2>
|
||||
<div v-if="showShare" class="flex justify-center mb-4">
|
||||
<div class="w-full md:w-1/3">
|
||||
<AccountShare :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
<fab-button
|
||||
<div v-if="transactionWithoutExchange.length" class="alert alert-warning mb-4">
|
||||
<div>
|
||||
<p class="font-bold">Attention</p>
|
||||
<p>
|
||||
Certaines dépenses ne sont pas comptées car en attente de récupérer
|
||||
les taux pratiqués à leur date respectives.
|
||||
</p>
|
||||
<ul class="list-disc list-inside">
|
||||
<li v-for="(t, k) in transactionWithoutExchange" :key="k">
|
||||
<router-link :to="{ name: 'transaction', params: { id: t._id } }">
|
||||
{{ t.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<OnlineView @online="retrieveExchange">
|
||||
<button
|
||||
class="btn btn-success btn-lg mt-2"
|
||||
:class="{ 'btn-loading': retrievingExchange }"
|
||||
@click="retrieveExchange"
|
||||
>
|
||||
Récupérer les devises
|
||||
</button>
|
||||
</OnlineView>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tablist" class="tabs tabs-boxed max-w-md mx-auto mb-4">
|
||||
<a
|
||||
role="tab"
|
||||
href="#"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'transaction' }"
|
||||
:style="colorStyle(activeTab === 'transaction')"
|
||||
@click.prevent="toggleTab('transaction')"
|
||||
>
|
||||
<vaquant-icon icon="currency-dollar" />
|
||||
</a>
|
||||
<a
|
||||
v-if="transactions.length && locations.length"
|
||||
role="tab"
|
||||
href="#"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'location' }"
|
||||
:style="colorStyle(activeTab === 'location')"
|
||||
@click.prevent="toggleTab('location')"
|
||||
>
|
||||
<vaquant-icon icon="world" />
|
||||
</a>
|
||||
<a
|
||||
v-if="transactions.length && tagCount > 1"
|
||||
role="tab"
|
||||
href="#"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'tag' }"
|
||||
:style="colorStyle(activeTab === 'tag')"
|
||||
@click.prevent="toggleTab('tag')"
|
||||
>
|
||||
<vaquant-icon icon="tag" />
|
||||
</a>
|
||||
<a
|
||||
v-if="transactions.length && isMultiUser"
|
||||
role="tab"
|
||||
href="#"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'balance' }"
|
||||
:style="colorStyle(activeTab === 'balance')"
|
||||
@click.prevent="toggleTab('balance')"
|
||||
>
|
||||
<vaquant-icon icon="scale" />
|
||||
</a>
|
||||
<a
|
||||
v-if="isAdmin"
|
||||
role="tab"
|
||||
href="#"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': activeTab === 'settings' }"
|
||||
:style="colorStyle(activeTab === 'settings')"
|
||||
@click.prevent="toggleTab('settings')"
|
||||
>
|
||||
<vaquant-icon icon="settings" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'transaction'" class="mt-4">
|
||||
<AccountTransactionList :account="account" :transactions="transactions" />
|
||||
</div>
|
||||
<div v-if="activeTab === 'location' && locations.length" class="mt-4">
|
||||
<EarthMap :locations="locations" />
|
||||
</div>
|
||||
<div v-if="activeTab === 'tag'" class="mt-4">
|
||||
<TagList :transactions="transactionWithoutRefund" :account="account" />
|
||||
</div>
|
||||
<div v-if="activeTab === 'balance'" class="mt-4">
|
||||
<ChartBalance :account="account" :stats="userStats" :currency="account.mainCurrency" />
|
||||
<div v-if="userRefunds.length" class="grid md:grid-cols-3 gap-4 mt-4">
|
||||
<div v-for="refund in userRefunds" :key="`${refund.from.alias}-${refund.to.alias}`">
|
||||
<RefundTransaction :refund="refund" :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-2xl mt-6">
|
||||
Le compte est à l'équilibre !
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeTab === 'settings'" class="mt-4">
|
||||
<AccountSetting :account="account" />
|
||||
</div>
|
||||
|
||||
<FabButton
|
||||
v-if="!account.archive"
|
||||
:to="{ name: 'transaction-new', params: { id: account._id } }"
|
||||
:button-style="backgroundColor"
|
||||
:margin="true"
|
||||
>
|
||||
<vaquant-icon icon="cash" />
|
||||
<span slot="fulltext">dépense</span>
|
||||
</fab-button>
|
||||
<template #fulltext>dépense</template>
|
||||
</FabButton>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Watch } from 'vue-property-decorator'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import BaseAccount from '@/base-components/BaseAccount'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import accountService from '@/services/AccountService'
|
||||
import chartService from '@/services/ChartService'
|
||||
import balanceService from '@/services/BalanceService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import IBalance from '@/models/IBalance'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import IRefund from '@/models/IRefund'
|
||||
import IStat from '@/models/IStat'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import IUser from '@/models/IUser'
|
||||
import { primary, main, findContrastColor, findDarkValue } from '@/utils'
|
||||
import couchService from '@/services/CouchService'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'account-share': () => import('@/components/AccountShare.vue'),
|
||||
'account-transaction-list': () =>
|
||||
import('@/components/AccountTransactionList.vue'),
|
||||
'earth-map': () => import('@/components/EarthMap.vue'),
|
||||
'tag-list': () => import('@/components/TagList.vue'),
|
||||
'chart-balance': () => import('@/components/ChartBalance.vue'),
|
||||
'account-setting': () => import('@/components/AccountSetting.vue'),
|
||||
'online-view': () => import('@/components/OnlineView.vue'),
|
||||
'refund-transaction': () => import('@/components/RefundTransaction.vue'),
|
||||
'fab-button': () => import('@/components/FabButton.vue')
|
||||
}
|
||||
})
|
||||
export default class AccountItem extends BaseAccount {
|
||||
@Action
|
||||
public setTitle!: any
|
||||
@Action
|
||||
public addAccountId!: any
|
||||
public retrieveTransactions: boolean = false
|
||||
public transactions: ITransaction[] = []
|
||||
public activeTab: string = 'transaction'
|
||||
public retrievingExchange: boolean = false
|
||||
|
||||
public async getData(docIds?: string[]): Promise<void> {
|
||||
if (docIds && !docIds.find((i: string) => i === this.id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (this.id) {
|
||||
this.account = await accountService.get(this.id)
|
||||
if (!this.account) {
|
||||
queueNotifService.error(`le compte ${this.id} n'existe pas`)
|
||||
this.$router.push({ name: 'home' })
|
||||
return
|
||||
}
|
||||
|
||||
if (this.account.isPublic) {
|
||||
this.addAccountId({ accountId: this.id })
|
||||
couchService.initLive()
|
||||
}
|
||||
this.transactions = await transactionService.getAllByAccountId(this.id)
|
||||
this.retrieveTransactions = true
|
||||
if (document && document.documentElement) {
|
||||
if (this.account.color) {
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-color',
|
||||
this.account.color
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-font-color',
|
||||
findDarkValue(this.account.color) || main
|
||||
)
|
||||
} else {
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-color',
|
||||
primary
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--primary-font-color',
|
||||
main
|
||||
)
|
||||
}
|
||||
}
|
||||
this.setTitle(this.account.name)
|
||||
}
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line
|
||||
console.error({ error })
|
||||
this.$router.push({ name: 'home' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
public toggleTab(tab: string): void {
|
||||
this.activeTab = tab
|
||||
}
|
||||
|
||||
public async retrieveExchange(): Promise<void> {
|
||||
if (!this.transactionWithoutExchange.length) {
|
||||
return
|
||||
}
|
||||
this.retrievingExchange = true
|
||||
try {
|
||||
const transactions: ITransaction[] = [...this.transactionWithoutExchange]
|
||||
for (const transaction of transactions) {
|
||||
transaction.exchange = await exchangeService.get(
|
||||
transaction.mainCurrency.code,
|
||||
transaction.date
|
||||
)
|
||||
}
|
||||
await transactionService.multipleSave(transactions)
|
||||
queueNotifService.success('Dépenses mises à jour.')
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line
|
||||
console.warn({ error })
|
||||
}
|
||||
this.retrievingExchange = false
|
||||
}
|
||||
|
||||
public totalCostByUserAlias(alias: string): number {
|
||||
if (!this.account) {
|
||||
return 0
|
||||
}
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return transactionService.getTotalCost(
|
||||
transactions,
|
||||
this.account.mainCurrency.code,
|
||||
alias
|
||||
)
|
||||
}
|
||||
|
||||
public get totalUserCost(): Array<{ alias: string; total: number }> {
|
||||
if (!this.account) {
|
||||
return []
|
||||
}
|
||||
return this.account.users
|
||||
.map((user: IUser) => ({
|
||||
alias: user.alias || '',
|
||||
total: this.totalCostByUserAlias(user.alias || '')
|
||||
}))
|
||||
.filter((user) => user.total > 0)
|
||||
}
|
||||
|
||||
public get showShare(): boolean {
|
||||
return !!(
|
||||
this.account &&
|
||||
this.account.users.length > 1 &&
|
||||
this.retrieveTransactions &&
|
||||
!this.transactions.length
|
||||
)
|
||||
}
|
||||
|
||||
public get transactionWithoutRefund(): ITransaction[] {
|
||||
if (!this.transactions) {
|
||||
return []
|
||||
}
|
||||
return this.transactions.filter(
|
||||
(transaction: ITransaction) =>
|
||||
transaction.transactionType === TransactionType.normal
|
||||
)
|
||||
}
|
||||
|
||||
public get tagCount(): number {
|
||||
const tags = new Set(
|
||||
this.transactionWithoutRefund.map(
|
||||
(transaction: ITransaction) => transaction.tag
|
||||
)
|
||||
)
|
||||
return tags.size
|
||||
}
|
||||
|
||||
public get transactionWithExchange(): ITransaction[] {
|
||||
if (!this.transactions) {
|
||||
return []
|
||||
}
|
||||
return this.transactions.filter(
|
||||
(transaction: ITransaction) => !!transaction.exchange
|
||||
)
|
||||
}
|
||||
|
||||
public get transactionWithoutExchange(): ITransaction[] {
|
||||
if (!this.transactions) {
|
||||
return []
|
||||
}
|
||||
return this.transactions.filter(
|
||||
(transaction: ITransaction) => !transaction.exchange
|
||||
)
|
||||
}
|
||||
|
||||
public get userStats(): IStat[] {
|
||||
const totalCost: number = this.totalCost
|
||||
if (!this.account || totalCost === 0) {
|
||||
return []
|
||||
}
|
||||
const account: IAccount = this.account
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
const mainCurrency: ICurrency = account.mainCurrency
|
||||
return this.account.users.map((user: IUser) => {
|
||||
const userCost: number = transactionService.getTotalCost(
|
||||
transactions,
|
||||
account.mainCurrency.code,
|
||||
user.alias
|
||||
)
|
||||
const percent: number = Math.round((userCost / totalCost) * 100)
|
||||
return {
|
||||
label: user.alias || '',
|
||||
value: userCost,
|
||||
percent,
|
||||
balance: balanceService.getBalanceByUser(
|
||||
user,
|
||||
mainCurrency,
|
||||
transactions
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public get locations() {
|
||||
return this.transactions
|
||||
.filter((transaction) => transaction.location)
|
||||
.map((transaction) => transaction.location)
|
||||
}
|
||||
|
||||
public get myTotalCost(): number {
|
||||
if (!this.account) {
|
||||
return 0
|
||||
}
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return transactionService.getTotalCost(
|
||||
transactions,
|
||||
this.account.mainCurrency.code,
|
||||
this.userAlias
|
||||
)
|
||||
}
|
||||
|
||||
public get totalCost(): number {
|
||||
if (!this.account) {
|
||||
return 0
|
||||
}
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return transactionService.getTotalCost(
|
||||
transactions,
|
||||
this.account.mainCurrency.code
|
||||
)
|
||||
}
|
||||
|
||||
public get userBalance(): IBalance[] {
|
||||
if (!this.account || !this.account.users) {
|
||||
return []
|
||||
}
|
||||
const mainCurrency: ICurrency = this.account.mainCurrency
|
||||
const transactions: ITransaction[] = this.transactionWithExchange
|
||||
return this.account.users.map((user: IUser) =>
|
||||
balanceService.getBalanceByUser(user, mainCurrency, transactions)
|
||||
)
|
||||
}
|
||||
|
||||
public get userRefunds(): IRefund[] {
|
||||
if (!this.account) {
|
||||
return []
|
||||
}
|
||||
return balanceService.getRefund(this.userBalance)
|
||||
}
|
||||
|
||||
public get isAdmin(): boolean {
|
||||
if (!this.account || !this.account.admin || !this.user) {
|
||||
return true
|
||||
}
|
||||
return this.account.admin.slugEmail === this.user.slugEmail
|
||||
}
|
||||
|
||||
public get userAlias(): string {
|
||||
if (!this.account || !this.user) {
|
||||
return ''
|
||||
}
|
||||
const mainUser = this.user
|
||||
const user = this.account.users.find(
|
||||
(u: IUser) => u.slugEmail === mainUser.slugEmail
|
||||
)
|
||||
return (user && user.alias) || ''
|
||||
}
|
||||
|
||||
public get isEncrypted(): boolean {
|
||||
return !!(this.account && this.account.name.length > 30)
|
||||
}
|
||||
|
||||
public get backgroundColor(): any | null {
|
||||
if (!this.account || !this.account.color) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
backgroundColor: this.account.color,
|
||||
color: findContrastColor(this.account.color) || 'black'
|
||||
}
|
||||
}
|
||||
|
||||
public get isMultiUser(): boolean {
|
||||
return !!this.account && this.account.users.length > 1
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../styles/variables';
|
||||
|
||||
.account-item {
|
||||
font-size: 14pt;
|
||||
|
||||
h2.title,
|
||||
a {
|
||||
color: var(--primary-font-color);
|
||||
}
|
||||
.account-title {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.account-title {
|
||||
span,
|
||||
a {
|
||||
padding: 0 10px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs.is-toggle {
|
||||
max-width: 400pt;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.tabs.is-toggle {
|
||||
a {
|
||||
height: 35pt;
|
||||
width: 35pt;
|
||||
border-radius: 50%;
|
||||
margin: auto;
|
||||
border-width: 2px;
|
||||
font-size: 14pt;
|
||||
}
|
||||
li:first-child,
|
||||
li:last-child {
|
||||
a {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.hero {
|
||||
&.is-fullheight {
|
||||
min-height: calc(100vh - 52px);
|
||||
}
|
||||
}
|
||||
.equilibrium {
|
||||
font-size: 25pt;
|
||||
color: var(--primary-font-color);
|
||||
}
|
||||
table.table {
|
||||
margin: auto;
|
||||
}
|
||||
th,
|
||||
.pay-by {
|
||||
text-align: center;
|
||||
}
|
||||
.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.total-panel {
|
||||
font-size: 16pt;
|
||||
}
|
||||
}
|
||||
.no-transaction {
|
||||
font-size: 16pt;
|
||||
}
|
||||
.tab-content {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
&.columns {
|
||||
&:last-child {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.slide-left-enter,
|
||||
.slide-right-leave-active {
|
||||
opacity: 0;
|
||||
transform: translate(15px, 0);
|
||||
}
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter {
|
||||
opacity: 0;
|
||||
transform: translate(-15px, 0);
|
||||
}
|
||||
.table {
|
||||
th,
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.account-item-container {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,305 +1,226 @@
|
||||
<template>
|
||||
<div class="account-new no-margin">
|
||||
<h1 class="title is-1" v-t="'account.create'"></h1>
|
||||
<form @submit.prevent="submitAccount">
|
||||
<div class="columns is-multiline is-centered">
|
||||
<div class="column is-two-thirds">
|
||||
<h3 class="subtitle is-3">Nom</h3>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
maxlength="30"
|
||||
v-model="name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p class="help is-primary">
|
||||
{{
|
||||
$tc('validation.max_char', 30 - name.length, {
|
||||
max: 30 - name.length
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-two-thirds">
|
||||
<h3 class="subtitle is-3" v-t="'account.main_currency'"></h3>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
name="main-currency"
|
||||
id="main-currency"
|
||||
v-model="mainCurrency"
|
||||
>
|
||||
<option v-for="(cur, k) in currencies" :key="k" :value="cur"
|
||||
>{{ cur.name }} {{ cur.symbol }}</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-two-thirds">
|
||||
<h3 class="subtitle is-3">Compte privé / public</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
id="is-public"
|
||||
type="checkbox"
|
||||
name="is-public"
|
||||
class="switch"
|
||||
v-model="isPublic"
|
||||
/>
|
||||
<label for="is-public">{{ isPublic ? 'public' : 'privé' }}</label>
|
||||
<p
|
||||
class="help is-primary"
|
||||
v-t="
|
||||
isPublic
|
||||
? 'account.publicInformation'
|
||||
: 'account.privateInformation'
|
||||
"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-two-thirds">
|
||||
<color-picker v-model="color" />
|
||||
</div>
|
||||
<div class="column is-two-thirds">
|
||||
<account-user-new v-model="users" />
|
||||
</div>
|
||||
<div class="column is-two-thirds">
|
||||
<h3 class="subtitle is-3 title-currency" @click="toggleCurrencyShow">
|
||||
<div class="icon-container">
|
||||
<vaquant-icon
|
||||
icon="chevron-right"
|
||||
:class="{ rotated: currencyShow }"
|
||||
/>
|
||||
</div>
|
||||
{{
|
||||
$tc('account.used_currencies', accountCurrencies.length, {
|
||||
count: accountCurrencies.length
|
||||
})
|
||||
}}
|
||||
</h3>
|
||||
<transition name="fade">
|
||||
<div class="currency-list" v-if="currencyShow">
|
||||
<div class="field">
|
||||
<div class="columns no-margin is-multiline">
|
||||
<div
|
||||
class="column is-half"
|
||||
v-for="(currency, k) in currencies"
|
||||
:key="k"
|
||||
>
|
||||
<input
|
||||
class="is-checkradio is-block is-medium"
|
||||
type="checkbox"
|
||||
:name="`cur-${currency.code}`"
|
||||
:id="`cur-${currency.code}`"
|
||||
v-model="accountCurrencies"
|
||||
:value="currency"
|
||||
:disabled="currency.code === mainCurrency.code"
|
||||
/>
|
||||
<label :for="`cur-${currency.code}`"
|
||||
>{{ currency.name }} ({{
|
||||
currency.symbol || currency.code
|
||||
}})</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-two-thirds">
|
||||
<button type="submit" class="button is-primary is-fullwidth is-large">
|
||||
<vaquant-icon icon="check" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from 'vue-property-decorator'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import currencies from '@/data/currencies'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ICurrency from '@/models/ICurrency'
|
||||
import type IAccount from '@/models/IAccount'
|
||||
import type ICurrency from '@/models/ICurrency'
|
||||
import type IUser from '@/models/IUser'
|
||||
import accountService from '@/services/AccountService'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import IUser from '@/models/IUser'
|
||||
import { slug } from '@/utils'
|
||||
import ColorPicker from '@/components/ColorPicker.vue'
|
||||
import AccountUserNew from '@/components/AccountUserNew.vue'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'color-picker': () => import('@/components/ColorPicker.vue'),
|
||||
'account-user-new': () => import('@/components/AccountUserNew.vue')
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const name = ref('')
|
||||
const mainCurrency = ref<ICurrency>(currencies[0])
|
||||
const accountCurrencies = ref<ICurrency[]>([currencies[0]])
|
||||
const color = ref<string | null>(null)
|
||||
const users = ref<IUser[]>([])
|
||||
const currencyShow = ref(false)
|
||||
const isPublic = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (user.value && !user.value.alias) {
|
||||
user.value.alias = user.value.userId
|
||||
}
|
||||
})
|
||||
export default class AccountNew extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
public name: string = ''
|
||||
public currencies: ICurrency[] = currencies
|
||||
public mainCurrency: ICurrency = currencies[0]
|
||||
public accountCurrencies: ICurrency[] = [currencies[0]]
|
||||
public color: string | null = null
|
||||
public users: IUser[] = []
|
||||
public currencyShow: boolean = false
|
||||
public isPublic: boolean = false
|
||||
|
||||
public mounted(): void {
|
||||
if (this.user && !this.user.alias) {
|
||||
this.user.alias = this.user.userId
|
||||
}
|
||||
}
|
||||
const toggleCurrencyShow = () => {
|
||||
currencyShow.value = !currencyShow.value
|
||||
}
|
||||
|
||||
public toggleCurrencyShow(): void {
|
||||
this.currencyShow = !this.currencyShow
|
||||
}
|
||||
|
||||
public validate(users: IUser[]): boolean {
|
||||
if (!this.name) {
|
||||
queueNotifService.error('Le nom doit être rempli.')
|
||||
return false
|
||||
}
|
||||
const aliases: string[] = users.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
|
||||
}
|
||||
|
||||
if (this.user) {
|
||||
const userIds: string[] = users.map((user: IUser) => user.userId)
|
||||
if (userIds.length !== new Set(userIds).size) {
|
||||
queueNotifService.error('Les identifiants doivent être uniques.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public async submitAccount(): Promise<void> {
|
||||
let users: IUser[] = this.user ? [this.user, ...this.users] : this.users
|
||||
if (!this.validate(users)) {
|
||||
return
|
||||
}
|
||||
users = users.map((u: IUser) => ({
|
||||
userId: u.userId,
|
||||
email: u.email,
|
||||
slugEmail: u.slugEmail,
|
||||
premium: u.premium,
|
||||
alias: u.alias,
|
||||
firstname: u.firstname,
|
||||
lastname: u.lastname
|
||||
}))
|
||||
const userIds: string[] = users.map((u: IUser) => slug(u.userId))
|
||||
const newAccount: IAccount = {
|
||||
doctype: 'account',
|
||||
name: this.name,
|
||||
admin: this.user
|
||||
? {
|
||||
userId: this.user.userId,
|
||||
email: this.user.email,
|
||||
slugEmail: this.user.slugEmail,
|
||||
premium: this.user.premium,
|
||||
alias: this.user.alias,
|
||||
firstname: this.user.firstname,
|
||||
lastname: this.user.lastname
|
||||
}
|
||||
: null,
|
||||
color: this.color,
|
||||
users,
|
||||
userIds,
|
||||
mainCurrency: this.mainCurrency,
|
||||
currencies: this.accountCurrencies,
|
||||
isPublic: this.isPublic
|
||||
}
|
||||
const response: PouchDB.Core.Response = await accountService.add(newAccount)
|
||||
if (response.ok) {
|
||||
this.$router.push({ name: 'account', params: { id: response.id } })
|
||||
} else {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.warn(response)
|
||||
queueNotifService.error(
|
||||
`Une erreur s'est produite à la création d'un compte.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('mainCurrency', { immediate: true })
|
||||
public onMainCurrencyChange(
|
||||
currency: ICurrency,
|
||||
oldCurrency: ICurrency
|
||||
): void {
|
||||
watch(
|
||||
mainCurrency,
|
||||
(currency, oldCurrency) => {
|
||||
if (oldCurrency) {
|
||||
this.accountCurrencies = this.accountCurrencies.filter(
|
||||
(c: ICurrency) => c.code !== oldCurrency.code
|
||||
accountCurrencies.value = accountCurrencies.value.filter(
|
||||
(c) => c.code !== oldCurrency.code
|
||||
)
|
||||
}
|
||||
const cur: ICurrency | undefined = this.currencies.find(
|
||||
(c: ICurrency) => c.code === currency.code
|
||||
)
|
||||
const cur = currencies.find((c) => c.code === currency.code)
|
||||
if (cur) {
|
||||
const already: ICurrency | undefined = this.accountCurrencies.find(
|
||||
(c: ICurrency) => c.code === cur.code
|
||||
)
|
||||
if (!already) {
|
||||
this.accountCurrencies.push(cur)
|
||||
}
|
||||
const already = accountCurrencies.value.find((c) => c.code === cur.code)
|
||||
if (!already) accountCurrencies.value.push(cur)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const validate = (allUsers: IUser[]): boolean => {
|
||||
if (!name.value) {
|
||||
queueNotifService.error('Le nom doit être rempli.')
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
if (user.value) {
|
||||
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 submitAccount = async () => {
|
||||
let allUsers: IUser[] = user.value ? [user.value, ...users.value] : users.value
|
||||
if (!validate(allUsers)) return
|
||||
allUsers = allUsers.map((u) => ({
|
||||
userId: u.userId,
|
||||
email: u.email,
|
||||
slugEmail: u.slugEmail,
|
||||
premium: u.premium,
|
||||
alias: u.alias,
|
||||
firstname: u.firstname,
|
||||
lastname: u.lastname
|
||||
}))
|
||||
const userIds = allUsers.map((u) => slug(u.userId))
|
||||
const newAccount: IAccount = {
|
||||
doctype: 'account',
|
||||
name: name.value,
|
||||
admin: user.value
|
||||
? {
|
||||
userId: user.value.userId,
|
||||
email: user.value.email,
|
||||
slugEmail: user.value.slugEmail,
|
||||
premium: user.value.premium,
|
||||
alias: user.value.alias,
|
||||
firstname: user.value.firstname,
|
||||
lastname: user.value.lastname
|
||||
}
|
||||
: null,
|
||||
color: color.value,
|
||||
users: allUsers,
|
||||
userIds,
|
||||
mainCurrency: mainCurrency.value,
|
||||
currencies: accountCurrencies.value,
|
||||
isPublic: isPublic.value
|
||||
}
|
||||
const response = await accountService.add(newAccount)
|
||||
if (response.ok) {
|
||||
router.push({ name: 'account', params: { id: response.id } })
|
||||
} else {
|
||||
console.warn(response)
|
||||
queueNotifService.error(`Une erreur s'est produite à la création d'un compte.`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~bulma-switch';
|
||||
<template>
|
||||
<div class="account-new p-4 max-w-3xl mx-auto">
|
||||
<h1 class="text-4xl font-bold text-center mb-6">{{ t('account.create') }}</h1>
|
||||
<form class="space-y-6" @submit.prevent="submitAccount">
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold mb-2">Nom</h3>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
maxlength="30"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-primary mt-1">
|
||||
{{ t('validation.max_char', { max: 30 - name.length }, 30 - name.length) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold mb-2">{{ t('account.main_currency') }}</h3>
|
||||
<select
|
||||
id="main-currency"
|
||||
v-model="mainCurrency"
|
||||
name="main-currency"
|
||||
class="select select-bordered w-full"
|
||||
>
|
||||
<option v-for="(cur, k) in currencies" :key="k" :value="cur">
|
||||
{{ cur.name }} {{ cur.symbol }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold mb-2">Compte privé / public</h3>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
id="is-public"
|
||||
v-model="isPublic"
|
||||
type="checkbox"
|
||||
name="is-public"
|
||||
class="toggle toggle-primary"
|
||||
/>
|
||||
<span>{{ isPublic ? 'public' : 'privé' }}</span>
|
||||
</label>
|
||||
<p class="text-xs text-primary mt-1">
|
||||
{{ isPublic ? t('account.publicInformation') : t('account.privateInformation') }}
|
||||
</p>
|
||||
</div>
|
||||
<ColorPicker v-model="color" />
|
||||
<AccountUserNew v-model="users" />
|
||||
<div>
|
||||
<h3
|
||||
class="text-2xl font-semibold mb-2 flex items-center gap-2 cursor-pointer"
|
||||
@click="toggleCurrencyShow"
|
||||
>
|
||||
<vaquant-icon
|
||||
icon="chevron-right"
|
||||
:class="{ rotated: currencyShow }"
|
||||
class="transition-transform"
|
||||
/>
|
||||
{{ t('account.used_currencies', { count: accountCurrencies.length }, accountCurrencies.length) }}
|
||||
</h3>
|
||||
<transition name="fade">
|
||||
<div v-if="currencyShow" class="currency-list max-h-[50vh] overflow-y-auto">
|
||||
<div class="grid md:grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="(currency, k) in currencies"
|
||||
:key="k"
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
:id="`cur-${currency.code}`"
|
||||
v-model="accountCurrencies"
|
||||
type="checkbox"
|
||||
:name="`cur-${currency.code}`"
|
||||
:value="currency"
|
||||
:disabled="currency.code === mainCurrency.code"
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<span>{{ currency.name }} ({{ currency.symbol || currency.code }})</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block btn-lg">
|
||||
<vaquant-icon icon="check" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
h3 {
|
||||
max-width: 400px;
|
||||
margin: auto;
|
||||
}
|
||||
.icon-container {
|
||||
float: left;
|
||||
|
||||
.ti {
|
||||
display: inline-block;
|
||||
transition: transform 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
.title-currency {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.currency-list {
|
||||
margin-top: 10px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
<style scoped>
|
||||
.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s cubic-bezier(0.55, 0, 0.1, 1),
|
||||
transform 0.3s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
|
||||
@@ -1,77 +1,59 @@
|
||||
<template>
|
||||
<div class="account-public">Récupération du compte public...</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue'
|
||||
import { Action, Getter } from 'vuex-class'
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import IUser from '@/models/IUser'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import type IAccount from '@/models/IAccount'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import accountService from '@/services/AccountService'
|
||||
import couchService from '../../services/CouchService'
|
||||
import couchService from '@/services/CouchService'
|
||||
|
||||
@Component
|
||||
export default class AccountPublic extends Vue {
|
||||
@Getter public user!: IUser | null
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public account: IAccount | null = null
|
||||
@Action
|
||||
public addAccountId!: any
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
public async mounted(): Promise<void> {
|
||||
bus.$on(SYNC, this.goToAccount)
|
||||
try {
|
||||
if (this.id) {
|
||||
this.account = await accountService.get(this.id)
|
||||
if (this.account) {
|
||||
this.$router.push({ name: 'account', params: { id: this.id } })
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
this.account = await accountService.getRemote(this.id)
|
||||
const account = ref<IAccount | null>(null)
|
||||
|
||||
if (this.account && this.account.isPublic) {
|
||||
this.addAccountId({ accountId: this.id })
|
||||
await couchService.initLive()
|
||||
queueNotifService.success(
|
||||
`Récupération du compte public ${this.account.name} en cours...`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
queueNotifService.error(
|
||||
`Oups, le compte n'existe pas, êtes-vous sûr d'avoir récupérer le bon lien ?`
|
||||
)
|
||||
this.$router.push({
|
||||
name: 'account',
|
||||
params: { id: this.id }
|
||||
})
|
||||
const goToAccount = () => {
|
||||
router.push({ name: 'account', params: { id: props.id } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
bus.on(SYNC, goToAccount)
|
||||
try {
|
||||
if (props.id) {
|
||||
account.value = await accountService.get(props.id)
|
||||
if (account.value) {
|
||||
router.push({ name: 'account', params: { id: props.id } })
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
account.value = await accountService.getRemote(props.id)
|
||||
if (account.value && account.value.isPublic) {
|
||||
userStore.addAccountId(props.id)
|
||||
await couchService.initLive()
|
||||
queueNotifService.success(
|
||||
`Récupération du compte public ${account.value.name} en cours...`
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
queueNotifService.error(
|
||||
`Oups, le compte n'existe pas, êtes-vous sûr d'avoir récupérer le bon lien ?`
|
||||
)
|
||||
router.push({ name: 'account', params: { id: props.id } })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
public beforeDestroy() {
|
||||
bus.$off(SYNC, this.goToAccount)
|
||||
}
|
||||
|
||||
public goToAccount() {
|
||||
this.$router.push({
|
||||
name: 'account',
|
||||
params: { id: this.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
bus.off(SYNC, goToAccount)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.account-public {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="account-public flex justify-center p-8">
|
||||
Récupération du compte public...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import accountService from '@/services/AccountService'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import type IAccount from '@/models/IAccount'
|
||||
import type ICurrency from '@/models/ICurrency'
|
||||
import type ITransaction from '@/models/ITransaction'
|
||||
import { money, moneypad, fulldate } from '@/utils/format'
|
||||
import OnlineView from '@/components/OnlineView.vue'
|
||||
import EarthMap from '@/components/EarthMap.vue'
|
||||
import ConfirmButton from '@/components/ConfirmButton.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
|
||||
const transaction = ref<ITransaction | null>(null)
|
||||
const account = ref<IAccount | null>(null)
|
||||
const removing = ref(false)
|
||||
const noTag = TransactionTag.None
|
||||
|
||||
const accountId = computed(() => transaction.value?.accountId || '')
|
||||
|
||||
const getData = async (docIds?: string[]) => {
|
||||
if (docIds && !docIds.find((i) => i === props.id)) return
|
||||
try {
|
||||
if (removing.value) return
|
||||
transaction.value = await transactionService.get(props.id)
|
||||
if (transaction.value) {
|
||||
account.value = await accountService.get(accountId.value)
|
||||
} else {
|
||||
if (accountId.value) {
|
||||
router.push({ name: 'account', params: { id: accountId.value } })
|
||||
queueNotifService.error('Transaction supprimée.')
|
||||
} else {
|
||||
router.push({ name: 'home' })
|
||||
queueNotifService.error('Compte inexistant.')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn({ error })
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getData()
|
||||
bus.on(SYNC, getData)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
bus.off(SYNC, getData)
|
||||
})
|
||||
|
||||
const removeTransaction = async () => {
|
||||
if (!transaction.value) return
|
||||
removing.value = true
|
||||
const ok = await transactionService.remove(props.id)
|
||||
if (ok) {
|
||||
if (accountId.value) {
|
||||
router.push({ name: 'account', params: { id: accountId.value } })
|
||||
} else {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
removing.value = false
|
||||
}
|
||||
|
||||
const retrieveExchange = async () => {
|
||||
if (transaction.value && !transaction.value.exchange) {
|
||||
transaction.value.exchange = await exchangeService.get(
|
||||
transaction.value.mainCurrency.code,
|
||||
transaction.value.date
|
||||
)
|
||||
await transactionService.save(transaction.value)
|
||||
}
|
||||
}
|
||||
|
||||
const getRate = (code: string): number =>
|
||||
transaction.value?.exchange?.rates[code] || 1
|
||||
|
||||
const userAlias = computed<string | undefined>(() => {
|
||||
if (!account.value || !user.value) return ''
|
||||
const u = user.value
|
||||
return account.value.users.map((x) => x.slugEmail).find((email) => email === u.slugEmail)
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
if (!account.value || !user.value) return true
|
||||
return !account.value.admin || account.value.admin.slugEmail === user.value.slugEmail
|
||||
})
|
||||
|
||||
const canModify = computed(() => {
|
||||
if (!transaction.value) return false
|
||||
return (
|
||||
(transaction.value.transactionType === TransactionType.normal && isAdmin.value) ||
|
||||
transaction.value.payBy === userAlias.value
|
||||
)
|
||||
})
|
||||
|
||||
const payForLabel = computed<string[]>(() => {
|
||||
if (!transaction.value) return []
|
||||
return transaction.value.payFor.map((p) => {
|
||||
const part = p.weight > 1 ? `(${p.weight} ${t('transaction.weight', p.weight)})` : ''
|
||||
return `${p.alias} ${part}`.trim()
|
||||
})
|
||||
})
|
||||
|
||||
const uniqueUser = computed(() => {
|
||||
if (!transaction.value) return false
|
||||
return transaction.value.payBy === payForLabel.value.join(',')
|
||||
})
|
||||
|
||||
const currencies = computed<ICurrency[]>(() => {
|
||||
if (!transaction.value || !transaction.value.exchange) return []
|
||||
const exchange = transaction.value.exchange
|
||||
return transaction.value.currencies.filter(
|
||||
(c) => exchange && c.code !== exchange.base
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="transaction">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="account">
|
||||
<div v-if="transaction" class="transaction-item p-4">
|
||||
<nav v-if="account" class="breadcrumbs text-sm mb-4" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
@@ -9,335 +142,88 @@
|
||||
params: { id: account._id },
|
||||
hash: `#${transaction._id}`
|
||||
}"
|
||||
>{{ account.name }}</router-link
|
||||
>
|
||||
{{ account.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
class="is-active"
|
||||
v-if="transaction.tag && transaction.tag !== noTag"
|
||||
>
|
||||
<li v-if="transaction.tag && transaction.tag !== noTag">
|
||||
<a href="#" @click.prevent>{{
|
||||
transactionTagLabel[(transaction.tag || '').toLowerCase()].label
|
||||
TransactionTagLabel[(transaction.tag || '').toLowerCase() as TransactionTag]?.label
|
||||
}}</a>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<li>
|
||||
<a href="#" @click.prevent>{{ transaction.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="buttons is-centered" v-if="account && !account.archive">
|
||||
<div v-if="account && !account.archive" class="flex justify-center gap-2 mb-4">
|
||||
<router-link
|
||||
v-if="canModify"
|
||||
tag="button"
|
||||
class="button"
|
||||
:to="{ name: 'transaction-update', params: { id } }"
|
||||
class="btn"
|
||||
>
|
||||
modifier
|
||||
</router-link>
|
||||
<confirm-button class="is-danger" @confirm="removeTransaction">
|
||||
<ConfirmButton class="btn-error" @confirm="removeTransaction">
|
||||
supprimer
|
||||
</confirm-button>
|
||||
</ConfirmButton>
|
||||
</div>
|
||||
<div class="columns no-margin is-centered">
|
||||
<div class="column is-half">
|
||||
<h3 class="subtitle is-3">{{ transaction.date | fulldate }}</h3>
|
||||
<div class="resume">
|
||||
Dépense payée par
|
||||
<span class="pay-by">{{ transaction.payBy }}</span>
|
||||
<span v-if="!uniqueUser">
|
||||
pour
|
||||
<span class="pay-for">{{ payForLabel.join(', ') }}</span>
|
||||
</span>
|
||||
<span v-if="transaction.location">
|
||||
à {{ transaction.location.place }}
|
||||
</span>
|
||||
d'une somme de
|
||||
<span class="numeric">{{
|
||||
transaction.amount | money(transaction.mainCurrency)
|
||||
}}</span>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="transaction.exchange && currencies.length"
|
||||
class="exchange column is-half"
|
||||
>
|
||||
<h3 class="subtitle is-3">
|
||||
Taux d'échange
|
||||
<div class="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold mb-2">
|
||||
{{ fulldate(String(transaction.date)) }}
|
||||
</h3>
|
||||
<div class="columns is-centered">
|
||||
<div class="column">
|
||||
<table class="table is-fullwidth is-striped is-bordered">
|
||||
<caption>
|
||||
<span class="numeric">{{
|
||||
transaction.amount | money(transaction.mainCurrency)
|
||||
}}</span>
|
||||
</caption>
|
||||
<tbody>
|
||||
<tr v-for="(currency, k) in currencies" :key="k">
|
||||
<td class="numeric">
|
||||
{{
|
||||
(transaction && transaction.amount) ||
|
||||
(1 * getRate(currency.code)) | moneypad(currency)
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="column">
|
||||
<table class="table is-fullwidth is-striped is-bordered">
|
||||
<caption>
|
||||
<span class="numeric">{{
|
||||
1 | money(transaction.mainCurrency)
|
||||
}}</span>
|
||||
</caption>
|
||||
<tbody>
|
||||
<tr v-for="(currency, k) in currencies" :key="k">
|
||||
<td class="numeric">
|
||||
{{ getRate(currency.code) | moneypad(currency) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="column">
|
||||
<table
|
||||
class="table is-hoverable is-fullwidth is-striped is-bordered"
|
||||
>
|
||||
<caption>
|
||||
{{
|
||||
$tc('transaction.money', currencies.length)
|
||||
}}
|
||||
</caption>
|
||||
<tbody>
|
||||
<tr v-for="(currency, k) in currencies" :key="k">
|
||||
<td>{{ 1 | money(currency) }}</td>
|
||||
<td class="numeric">
|
||||
{{
|
||||
(1 / getRate(currency.code))
|
||||
| moneypad(transaction && transaction.mainCurrency)
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="resume text-lg">
|
||||
Dépense payée par <span class="font-bold">{{ transaction.payBy }}</span>
|
||||
<span v-if="!uniqueUser">
|
||||
pour <span class="font-bold">{{ payForLabel.join(', ') }}</span>
|
||||
</span>
|
||||
<span v-if="transaction.location">à {{ transaction.location.place }}</span>
|
||||
d'une somme de
|
||||
<span class="numeric">{{ money(transaction.amount, transaction.mainCurrency) }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="transaction.exchange && currencies.length">
|
||||
<h3 class="text-2xl font-semibold mb-2">Taux d'échange</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<table class="table table-zebra max-w-xs mx-auto">
|
||||
<caption class="font-bold">
|
||||
{{ money(transaction.amount, transaction.mainCurrency) }}
|
||||
</caption>
|
||||
<tbody>
|
||||
<tr v-for="(c, k) in currencies" :key="k">
|
||||
<td class="numeric text-right">
|
||||
{{ moneypad((transaction.amount || 1) * getRate(c.code), c) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table table-zebra max-w-xs mx-auto">
|
||||
<caption class="font-bold">{{ money(1, transaction.mainCurrency) }}</caption>
|
||||
<tbody>
|
||||
<tr v-for="(c, k) in currencies" :key="k">
|
||||
<td class="numeric text-right">{{ moneypad(getRate(c.code), c) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table table-zebra max-w-xs mx-auto">
|
||||
<caption class="font-bold">{{ t('transaction.money', currencies.length) }}</caption>
|
||||
<tbody>
|
||||
<tr v-for="(c, k) in currencies" :key="k">
|
||||
<td>{{ money(1, c) }}</td>
|
||||
<td class="numeric text-right">
|
||||
{{ moneypad(1 / getRate(c.code), transaction.mainCurrency) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="column is-half">
|
||||
<online-view @online="retrieveExchange" />
|
||||
<div v-else>
|
||||
<OnlineView @online="retrieveExchange" />
|
||||
Le taux d'échange n'a pas pu être récupéré.
|
||||
</div>
|
||||
</div>
|
||||
<earth-map
|
||||
v-if="transaction.location"
|
||||
:locations="[transaction.location]"
|
||||
/>
|
||||
<EarthMap v-if="transaction.location" :locations="[transaction.location]" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import TransactionType from '@/enums/TransactionType'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
|
||||
import accountService from '@/services/AccountService'
|
||||
import exchangeService from '@/services/ExchangeService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
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'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'online-view': () => import('@/components/OnlineView.vue'),
|
||||
'earth-map': () => import('@/components/EarthMap.vue'),
|
||||
'confirm-button': () => import('@/components/ConfirmButton.vue')
|
||||
}
|
||||
})
|
||||
export default class TransactionItem extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public transaction: ITransaction | null = null
|
||||
public account: IAccount | null = null
|
||||
public noTag: string = TransactionTag.None
|
||||
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
|
||||
public removing: boolean = false
|
||||
public earth: any | null = null
|
||||
|
||||
public created(): void {
|
||||
this.getData()
|
||||
bus.$on(SYNC, this.getData)
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
bus.$off(SYNC, this.getData)
|
||||
}
|
||||
|
||||
public async getData(docIds?: string[]): Promise<void> {
|
||||
if (docIds && !docIds.find((i: string) => i === this.id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (this.removing) {
|
||||
return
|
||||
}
|
||||
this.transaction = await transactionService.get(this.id)
|
||||
if (this.transaction) {
|
||||
this.account = await accountService.get(this.accountId)
|
||||
} else {
|
||||
if (this.accountId) {
|
||||
this.$router.push({ name: 'account', params: { id: this.accountId } })
|
||||
queueNotifService.error('Transaction supprimée.')
|
||||
} else {
|
||||
this.$router.push({ name: 'home' })
|
||||
queueNotifService.error('Compte inexistant.')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line
|
||||
console.warn({ error })
|
||||
this.$router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
|
||||
public async removeTransaction(): Promise<void> {
|
||||
if (!this.transaction) {
|
||||
return
|
||||
}
|
||||
this.removing = true
|
||||
const ok: boolean = await transactionService.remove(this.id)
|
||||
if (ok) {
|
||||
if (this.accountId) {
|
||||
this.$router.push({
|
||||
name: 'account',
|
||||
params: {
|
||||
id: this.accountId
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.$router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
this.removing = false
|
||||
}
|
||||
|
||||
public async retrieveExchange(): Promise<void> {
|
||||
if (this.transaction && !this.transaction.exchange) {
|
||||
this.transaction.exchange = await exchangeService.get(
|
||||
this.transaction.mainCurrency.code,
|
||||
this.transaction.date
|
||||
)
|
||||
await transactionService.save(this.transaction)
|
||||
}
|
||||
}
|
||||
|
||||
public getRate(code: string) {
|
||||
return (
|
||||
(this.transaction &&
|
||||
this.transaction.exchange &&
|
||||
this.transaction.exchange.rates[code]) ||
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
public get uniqueUser() {
|
||||
if (!this.transaction) {
|
||||
return false
|
||||
}
|
||||
return this.transaction.payBy === this.payForLabel.join(',')
|
||||
}
|
||||
|
||||
public get accountId(): string {
|
||||
return (this.transaction && this.transaction.accountId) || ''
|
||||
}
|
||||
|
||||
public get canModify(): boolean {
|
||||
if (!this.transaction) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
(this.transaction.transactionType === TransactionType.normal &&
|
||||
this.isAdmin) ||
|
||||
this.transaction.payBy === this.userAlias
|
||||
)
|
||||
}
|
||||
|
||||
public get userAlias(): string | undefined {
|
||||
if (!this.account || !this.user) {
|
||||
return ''
|
||||
}
|
||||
const user = this.user
|
||||
return this.account.users
|
||||
.map((u: IUser) => u.slugEmail)
|
||||
.find((email: string | undefined) => email === user.slugEmail)
|
||||
}
|
||||
|
||||
public get isAdmin(): boolean {
|
||||
if (!this.account || !this.user) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
!this.account.admin ||
|
||||
this.account.admin.slugEmail === this.user.slugEmail
|
||||
)
|
||||
}
|
||||
|
||||
public get payForLabel(): string[] {
|
||||
if (!this.transaction) {
|
||||
return []
|
||||
}
|
||||
return this.transaction.payFor.map((p: ISplit) => {
|
||||
const part =
|
||||
p.weight > 1
|
||||
? `(${p.weight} ${this.$tc('transaction.weight', p.weight)})`
|
||||
: ''
|
||||
return `${p.alias} ${part}`.trim()
|
||||
})
|
||||
}
|
||||
|
||||
public get currencies(): ICurrency[] {
|
||||
if (!this.transaction || !this.transaction.exchange) {
|
||||
return []
|
||||
}
|
||||
const transaction = this.transaction
|
||||
const exchange = transaction.exchange
|
||||
return transaction.currencies.filter(
|
||||
(currency: ICurrency) => exchange && currency.code !== exchange.base
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url('https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.css');
|
||||
|
||||
p.resume {
|
||||
font-size: 18pt;
|
||||
margin: 15px 0;
|
||||
}
|
||||
table.table {
|
||||
margin: 15px auto 0;
|
||||
max-width: 350px;
|
||||
}
|
||||
.main-cell {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.buttons {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.pay-by,
|
||||
.pay-for {
|
||||
font-weight: bold;
|
||||
}
|
||||
.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,72 +1,43 @@
|
||||
<template>
|
||||
<div class="transaction-new no-margin">
|
||||
<div v-if="!account">
|
||||
no account
|
||||
<br />
|
||||
{{ id }} - {{ account }}
|
||||
</div>
|
||||
<div v-else-if="transaction">
|
||||
<transaction-create :account="account" :transaction="transaction" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import IUser from '@/models/IUser'
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeMount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type IAccount from '@/models/IAccount'
|
||||
import type ITransaction from '@/models/ITransaction'
|
||||
import accountService from '@/services/AccountService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import mapService from '@/services/MapService'
|
||||
import TransactionCreate from '@/components/TransactionCreate.vue'
|
||||
|
||||
const today: Date = new Date()
|
||||
const props = defineProps<{ id: string }>()
|
||||
const userStore = useUserStore()
|
||||
const { user } = storeToRefs(userStore)
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'transaction-create': () => import('@/components/TransactionCreate.vue')
|
||||
const account = ref<IAccount | null>(null)
|
||||
const transaction = ref<ITransaction | null>(null)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
if (!props.id) return
|
||||
account.value = await accountService.get(props.id)
|
||||
if (account.value) {
|
||||
transaction.value = await transactionService.getNew(user.value, account.value)
|
||||
}
|
||||
try {
|
||||
if (!transaction.value) return
|
||||
const location = await mapService.getPosition()
|
||||
if (!location) return
|
||||
transaction.value.location = { ...location }
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
export default class TransactionNew extends Vue {
|
||||
@Getter
|
||||
public user!: IUser | null
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public account: IAccount | null = null
|
||||
public transaction: ITransaction | null = null
|
||||
|
||||
public async created(): Promise<void> {
|
||||
if (this.id) {
|
||||
this.account = await accountService.get(this.id)
|
||||
if (this.account) {
|
||||
this.transaction = await transactionService.getNew(
|
||||
this.user,
|
||||
this.account
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.transaction) {
|
||||
return
|
||||
}
|
||||
const location = await mapService.getPosition()
|
||||
|
||||
if (!location) {
|
||||
return
|
||||
}
|
||||
this.$set(this.transaction, 'location', { ...location })
|
||||
} catch (error) {
|
||||
// tslint:disable-next-line: no-console
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="transaction-new p-4">
|
||||
<div v-if="!account">no account<br />{{ id }} - {{ account }}</div>
|
||||
<div v-else-if="transaction">
|
||||
<TransactionCreate :account="account" :transaction="transaction" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
<template>
|
||||
<div class="transaction-update no-margin" v-if="account && transaction">
|
||||
<transaction-create :account="account" :transaction="transaction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator'
|
||||
import { Getter } from 'vuex-class'
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import bus, { SYNC } from '@/utils/bus-event'
|
||||
import IAccount from '@/models/IAccount'
|
||||
import ITransaction from '@/models/ITransaction'
|
||||
import type IAccount from '@/models/IAccount'
|
||||
import type ITransaction from '@/models/ITransaction'
|
||||
import accountService from '@/services/AccountService'
|
||||
import transactionService from '@/services/TransactionService'
|
||||
import queueNotifService from '@/services/QueueNotifService'
|
||||
import TransactionCreate from '@/components/TransactionCreate.vue'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
'transaction-create': () => import('@/components/TransactionCreate.vue')
|
||||
}
|
||||
})
|
||||
export default class TransactionUpdate extends Vue {
|
||||
@Prop({ type: String, required: true })
|
||||
public id!: string
|
||||
public transaction: ITransaction | null = null
|
||||
public account: IAccount | null = null
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
public created(): void {
|
||||
this.getData()
|
||||
bus.$on(SYNC, this.getData)
|
||||
}
|
||||
const account = ref<IAccount | null>(null)
|
||||
const transaction = ref<ITransaction | null>(null)
|
||||
|
||||
public async getData(docIds?: string[]): Promise<void> {
|
||||
if (docIds && !docIds.find((i: string) => i === this.id)) {
|
||||
return
|
||||
}
|
||||
this.transaction = await transactionService.get(this.id)
|
||||
if (!this.transaction) {
|
||||
this.$router.push({ name: 'home' })
|
||||
queueNotifService.error('Compte inexistant.')
|
||||
return
|
||||
}
|
||||
this.account = await accountService.get(this.transaction.accountId)
|
||||
const getData = async (docIds?: string[]) => {
|
||||
if (docIds && !docIds.find((i) => i === props.id)) return
|
||||
transaction.value = await transactionService.get(props.id)
|
||||
if (!transaction.value) {
|
||||
router.push({ name: 'home' })
|
||||
queueNotifService.error('Compte inexistant.')
|
||||
return
|
||||
}
|
||||
account.value = await accountService.get(transaction.value.accountId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getData()
|
||||
bus.on(SYNC, getData)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
bus.off(SYNC, getData)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="account && transaction" class="transaction-update p-4">
|
||||
<TransactionCreate :account="account" :transaction="transaction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
8
tests/README.md
Normal file
8
tests/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Tests
|
||||
|
||||
Vitest is wired up via `vitest.config.ts`. Run with `pnpm test`.
|
||||
|
||||
The test suite is intentionally empty after the Vite/Vue 3 migration — the
|
||||
sole legacy Jest spec (`HelloWorld.spec.ts`) was against scaffolding that no
|
||||
longer exists. Add new tests under `tests/**/*.spec.ts` or co-located as
|
||||
`src/**/*.spec.ts`.
|
||||
@@ -1,272 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Actions', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/actions')
|
||||
})
|
||||
|
||||
// https://on.cypress.io/interacting-with-elements
|
||||
|
||||
it('.type() - type into a DOM element', () => {
|
||||
// https://on.cypress.io/type
|
||||
cy.get('.action-email')
|
||||
.type('fake@email.com').should('have.value', 'fake@email.com')
|
||||
|
||||
// .type() with special character sequences
|
||||
.type('{leftarrow}{rightarrow}{uparrow}{downarrow}')
|
||||
.type('{del}{selectall}{backspace}')
|
||||
|
||||
// .type() with key modifiers
|
||||
.type('{alt}{option}') //these are equivalent
|
||||
.type('{ctrl}{control}') //these are equivalent
|
||||
.type('{meta}{command}{cmd}') //these are equivalent
|
||||
.type('{shift}')
|
||||
|
||||
// Delay each keypress by 0.1 sec
|
||||
.type('slow.typing@email.com', { delay: 100 })
|
||||
.should('have.value', 'slow.typing@email.com')
|
||||
|
||||
cy.get('.action-disabled')
|
||||
// Ignore error checking prior to type
|
||||
// like whether the input is visible or disabled
|
||||
.type('disabled error checking', { force: true })
|
||||
.should('have.value', 'disabled error checking')
|
||||
})
|
||||
|
||||
it('.focus() - focus on a DOM element', () => {
|
||||
// https://on.cypress.io/focus
|
||||
cy.get('.action-focus').focus()
|
||||
.should('have.class', 'focus')
|
||||
.prev().should('have.attr', 'style', 'color: orange;')
|
||||
})
|
||||
|
||||
it('.blur() - blur off a DOM element', () => {
|
||||
// https://on.cypress.io/blur
|
||||
cy.get('.action-blur').type('About to blur').blur()
|
||||
.should('have.class', 'error')
|
||||
.prev().should('have.attr', 'style', 'color: red;')
|
||||
})
|
||||
|
||||
it('.clear() - clears an input or textarea element', () => {
|
||||
// https://on.cypress.io/clear
|
||||
cy.get('.action-clear').type('Clear this text')
|
||||
.should('have.value', 'Clear this text')
|
||||
.clear()
|
||||
.should('have.value', '')
|
||||
})
|
||||
|
||||
it('.submit() - submit a form', () => {
|
||||
// https://on.cypress.io/submit
|
||||
cy.get('.action-form')
|
||||
.find('[type="text"]').type('HALFOFF')
|
||||
cy.get('.action-form').submit()
|
||||
.next().should('contain', 'Your form has been submitted!')
|
||||
})
|
||||
|
||||
it('.click() - click on a DOM element', () => {
|
||||
// https://on.cypress.io/click
|
||||
cy.get('.action-btn').click()
|
||||
|
||||
// You can click on 9 specific positions of an element:
|
||||
// -----------------------------------
|
||||
// | topLeft top topRight |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | left center right |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | bottomLeft bottom bottomRight |
|
||||
// -----------------------------------
|
||||
|
||||
// clicking in the center of the element is the default
|
||||
cy.get('#action-canvas').click()
|
||||
|
||||
cy.get('#action-canvas').click('topLeft')
|
||||
cy.get('#action-canvas').click('top')
|
||||
cy.get('#action-canvas').click('topRight')
|
||||
cy.get('#action-canvas').click('left')
|
||||
cy.get('#action-canvas').click('right')
|
||||
cy.get('#action-canvas').click('bottomLeft')
|
||||
cy.get('#action-canvas').click('bottom')
|
||||
cy.get('#action-canvas').click('bottomRight')
|
||||
|
||||
// .click() accepts an x and y coordinate
|
||||
// that controls where the click occurs :)
|
||||
|
||||
cy.get('#action-canvas')
|
||||
.click(80, 75) // click 80px on x coord and 75px on y coord
|
||||
.click(170, 75)
|
||||
.click(80, 165)
|
||||
.click(100, 185)
|
||||
.click(125, 190)
|
||||
.click(150, 185)
|
||||
.click(170, 165)
|
||||
|
||||
// click multiple elements by passing multiple: true
|
||||
cy.get('.action-labels>.label').click({ multiple: true })
|
||||
|
||||
// Ignore error checking prior to clicking
|
||||
cy.get('.action-opacity>.btn').click({ force: true })
|
||||
})
|
||||
|
||||
it('.dblclick() - double click on a DOM element', () => {
|
||||
// https://on.cypress.io/dblclick
|
||||
|
||||
// Our app has a listener on 'dblclick' event in our 'scripts.js'
|
||||
// that hides the div and shows an input on double click
|
||||
cy.get('.action-div').dblclick().should('not.be.visible')
|
||||
cy.get('.action-input-hidden').should('be.visible')
|
||||
})
|
||||
|
||||
it('.check() - check a checkbox or radio element', () => {
|
||||
// https://on.cypress.io/check
|
||||
|
||||
// By default, .check() will check all
|
||||
// matching checkbox or radio elements in succession, one after another
|
||||
cy.get('.action-checkboxes [type="checkbox"]').not('[disabled]')
|
||||
.check().should('be.checked')
|
||||
|
||||
cy.get('.action-radios [type="radio"]').not('[disabled]')
|
||||
.check().should('be.checked')
|
||||
|
||||
// .check() accepts a value argument
|
||||
cy.get('.action-radios [type="radio"]')
|
||||
.check('radio1').should('be.checked')
|
||||
|
||||
// .check() accepts an array of values
|
||||
cy.get('.action-multiple-checkboxes [type="checkbox"]')
|
||||
.check(['checkbox1', 'checkbox2']).should('be.checked')
|
||||
|
||||
// Ignore error checking prior to checking
|
||||
cy.get('.action-checkboxes [disabled]')
|
||||
.check({ force: true }).should('be.checked')
|
||||
|
||||
cy.get('.action-radios [type="radio"]')
|
||||
.check('radio3', { force: true }).should('be.checked')
|
||||
})
|
||||
|
||||
it('.uncheck() - uncheck a checkbox element', () => {
|
||||
// https://on.cypress.io/uncheck
|
||||
|
||||
// By default, .uncheck() will uncheck all matching
|
||||
// checkbox elements in succession, one after another
|
||||
cy.get('.action-check [type="checkbox"]')
|
||||
.not('[disabled]')
|
||||
.uncheck().should('not.be.checked')
|
||||
|
||||
// .uncheck() accepts a value argument
|
||||
cy.get('.action-check [type="checkbox"]')
|
||||
.check('checkbox1')
|
||||
.uncheck('checkbox1').should('not.be.checked')
|
||||
|
||||
// .uncheck() accepts an array of values
|
||||
cy.get('.action-check [type="checkbox"]')
|
||||
.check(['checkbox1', 'checkbox3'])
|
||||
.uncheck(['checkbox1', 'checkbox3']).should('not.be.checked')
|
||||
|
||||
// Ignore error checking prior to unchecking
|
||||
cy.get('.action-check [disabled]')
|
||||
.uncheck({ force: true }).should('not.be.checked')
|
||||
})
|
||||
|
||||
it('.select() - select an option in a <select> element', () => {
|
||||
// https://on.cypress.io/select
|
||||
|
||||
// Select option(s) with matching text content
|
||||
cy.get('.action-select').select('apples')
|
||||
|
||||
cy.get('.action-select-multiple')
|
||||
.select(['apples', 'oranges', 'bananas'])
|
||||
|
||||
// Select option(s) with matching value
|
||||
cy.get('.action-select').select('fr-bananas')
|
||||
|
||||
cy.get('.action-select-multiple')
|
||||
.select(['fr-apples', 'fr-oranges', 'fr-bananas'])
|
||||
})
|
||||
|
||||
it('.scrollIntoView() - scroll an element into view', () => {
|
||||
// https://on.cypress.io/scrollintoview
|
||||
|
||||
// normally all of these buttons are hidden,
|
||||
// because they're not within
|
||||
// the viewable area of their parent
|
||||
// (we need to scroll to see them)
|
||||
cy.get('#scroll-horizontal button')
|
||||
.should('not.be.visible')
|
||||
|
||||
// scroll the button into view, as if the user had scrolled
|
||||
cy.get('#scroll-horizontal button').scrollIntoView()
|
||||
.should('be.visible')
|
||||
|
||||
cy.get('#scroll-vertical button')
|
||||
.should('not.be.visible')
|
||||
|
||||
// Cypress handles the scroll direction needed
|
||||
cy.get('#scroll-vertical button').scrollIntoView()
|
||||
.should('be.visible')
|
||||
|
||||
cy.get('#scroll-both button')
|
||||
.should('not.be.visible')
|
||||
|
||||
// Cypress knows to scroll to the right and down
|
||||
cy.get('#scroll-both button').scrollIntoView()
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('cy.scrollTo() - scroll the window or element to a position', () => {
|
||||
|
||||
// https://on.cypress.io/scrollTo
|
||||
|
||||
// You can scroll to 9 specific positions of an element:
|
||||
// -----------------------------------
|
||||
// | topLeft top topRight |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | left center right |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | bottomLeft bottom bottomRight |
|
||||
// -----------------------------------
|
||||
|
||||
// if you chain .scrollTo() off of cy, we will
|
||||
// scroll the entire window
|
||||
cy.scrollTo('bottom')
|
||||
|
||||
cy.get('#scrollable-horizontal').scrollTo('right')
|
||||
|
||||
// or you can scroll to a specific coordinate:
|
||||
// (x axis, y axis) in pixels
|
||||
cy.get('#scrollable-vertical').scrollTo(250, 250)
|
||||
|
||||
// or you can scroll to a specific percentage
|
||||
// of the (width, height) of the element
|
||||
cy.get('#scrollable-both').scrollTo('75%', '25%')
|
||||
|
||||
// control the easing of the scroll (default is 'swing')
|
||||
cy.get('#scrollable-vertical').scrollTo('center', { easing: 'linear' })
|
||||
|
||||
// control the duration of the scroll (in ms)
|
||||
cy.get('#scrollable-both').scrollTo('center', { duration: 2000 })
|
||||
})
|
||||
|
||||
it('.trigger() - trigger an event on a DOM element', () => {
|
||||
// https://on.cypress.io/trigger
|
||||
|
||||
// To interact with a range input (slider)
|
||||
// we need to set its value & trigger the
|
||||
// event to signal it changed
|
||||
|
||||
// Here, we invoke jQuery's val() method to set
|
||||
// the value and trigger the 'change' event
|
||||
cy.get('.trigger-input-range')
|
||||
.invoke('val', 25)
|
||||
.trigger('change')
|
||||
.get('input[type=range]').siblings('p')
|
||||
.should('have.text', '25')
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Aliasing', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/aliasing')
|
||||
})
|
||||
|
||||
it('.as() - alias a DOM element for later use', () => {
|
||||
// https://on.cypress.io/as
|
||||
|
||||
// Alias a DOM element for use later
|
||||
// We don't have to traverse to the element
|
||||
// later in our code, we reference it with @
|
||||
|
||||
cy.get('.as-table').find('tbody>tr')
|
||||
.first().find('td').first()
|
||||
.find('button').as('firstBtn')
|
||||
|
||||
// when we reference the alias, we place an
|
||||
// @ in front of its name
|
||||
cy.get('@firstBtn').click()
|
||||
|
||||
cy.get('@firstBtn')
|
||||
.should('have.class', 'btn-success')
|
||||
.and('contain', 'Changed')
|
||||
})
|
||||
|
||||
it('.as() - alias a route for later use', () => {
|
||||
|
||||
// Alias the route to wait for its response
|
||||
cy.server()
|
||||
cy.route('GET', 'comments/*').as('getComment')
|
||||
|
||||
// we have code that gets a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.network-btn').click()
|
||||
|
||||
// https://on.cypress.io/wait
|
||||
cy.wait('@getComment').its('status').should('eq', 200)
|
||||
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Assertions', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/assertions')
|
||||
})
|
||||
|
||||
describe('Implicit Assertions', () => {
|
||||
|
||||
it('.should() - make an assertion about the current subject', () => {
|
||||
// https://on.cypress.io/should
|
||||
cy.get('.assertion-table')
|
||||
.find('tbody tr:last').should('have.class', 'success')
|
||||
})
|
||||
|
||||
it('.and() - chain multiple assertions together', () => {
|
||||
// https://on.cypress.io/and
|
||||
cy.get('.assertions-link')
|
||||
.should('have.class', 'active')
|
||||
.and('have.attr', 'href')
|
||||
.and('include', 'cypress.io')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Explicit Assertions', () => {
|
||||
// https://on.cypress.io/assertions
|
||||
it('expect - make an assertion about a specified subject', () => {
|
||||
// We can use Chai's BDD style assertions
|
||||
expect(true).to.be.true
|
||||
|
||||
// Pass a function to should that can have any number
|
||||
// of explicit assertions within it.
|
||||
cy.get('.assertions-p').find('p')
|
||||
.should(($p) => {
|
||||
// return an array of texts from all of the p's
|
||||
// @ts-ignore TS6133 unused variable
|
||||
const texts = $p.map((i, el) => // https://on.cypress.io/$
|
||||
Cypress.$(el).text())
|
||||
|
||||
// jquery map returns jquery object
|
||||
// and .get() convert this to simple array
|
||||
const paragraphs = texts.get()
|
||||
|
||||
// array should have length of 3
|
||||
expect(paragraphs).to.have.length(3)
|
||||
|
||||
// set this specific subject
|
||||
expect(paragraphs).to.deep.eq([
|
||||
'Some text from first p',
|
||||
'More text from second p',
|
||||
'And even more text from third p',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('assert - assert shape of an object', () => {
|
||||
const person = {
|
||||
name: 'Joe',
|
||||
age: 20,
|
||||
}
|
||||
assert.isObject(person, 'value is object')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Connectors', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/connectors')
|
||||
})
|
||||
|
||||
it('.each() - iterate over an array of elements', () => {
|
||||
// https://on.cypress.io/each
|
||||
cy.get('.connectors-each-ul>li')
|
||||
.each(($el, index, $list) => {
|
||||
console.log($el, index, $list)
|
||||
})
|
||||
})
|
||||
|
||||
it('.its() - get properties on the current subject', () => {
|
||||
// https://on.cypress.io/its
|
||||
cy.get('.connectors-its-ul>li')
|
||||
// calls the 'length' property yielding that value
|
||||
.its('length')
|
||||
.should('be.gt', 2)
|
||||
})
|
||||
|
||||
it('.invoke() - invoke a function on the current subject', () => {
|
||||
// our div is hidden in our script.js
|
||||
// $('.connectors-div').hide()
|
||||
|
||||
// https://on.cypress.io/invoke
|
||||
cy.get('.connectors-div').should('be.hidden')
|
||||
// call the jquery method 'show' on the 'div.container'
|
||||
.invoke('show')
|
||||
.should('be.visible')
|
||||
})
|
||||
|
||||
it('.spread() - spread an array as individual args to callback function', () => {
|
||||
// https://on.cypress.io/spread
|
||||
const arr = ['foo', 'bar', 'baz']
|
||||
|
||||
cy.wrap(arr).spread((foo, bar, baz) => {
|
||||
expect(foo).to.eq('foo')
|
||||
expect(bar).to.eq('bar')
|
||||
expect(baz).to.eq('baz')
|
||||
})
|
||||
})
|
||||
|
||||
it('.then() - invoke a callback function with the current subject', () => {
|
||||
// https://on.cypress.io/then
|
||||
cy.get('.connectors-list>li').then(($lis) => {
|
||||
expect($lis).to.have.length(3)
|
||||
expect($lis.eq(0)).to.contain('Walk the dog')
|
||||
expect($lis.eq(1)).to.contain('Feed the cat')
|
||||
expect($lis.eq(2)).to.contain('Write JavaScript')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Cookies', () => {
|
||||
beforeEach(() => {
|
||||
Cypress.Cookies.debug(true)
|
||||
|
||||
cy.visit('https://example.cypress.io/commands/cookies')
|
||||
|
||||
// clear cookies again after visiting to remove
|
||||
// any 3rd party cookies picked up such as cloudflare
|
||||
cy.clearCookies()
|
||||
})
|
||||
|
||||
it('cy.getCookie() - get a browser cookie', () => {
|
||||
// https://on.cypress.io/getcookie
|
||||
cy.get('#getCookie .set-a-cookie').click()
|
||||
|
||||
// cy.getCookie() yields a cookie object
|
||||
cy.getCookie('token').should('have.property', 'value', '123ABC')
|
||||
})
|
||||
|
||||
it('cy.getCookies() - get browser cookies', () => {
|
||||
// https://on.cypress.io/getcookies
|
||||
cy.getCookies().should('be.empty')
|
||||
|
||||
cy.get('#getCookies .set-a-cookie').click()
|
||||
|
||||
// cy.getCookies() yields an array of cookies
|
||||
cy.getCookies().should('have.length', 1).should((cookies) => {
|
||||
|
||||
// each cookie has these properties
|
||||
expect(cookies[0]).to.have.property('name', 'token')
|
||||
expect(cookies[0]).to.have.property('value', '123ABC')
|
||||
expect(cookies[0]).to.have.property('httpOnly', false)
|
||||
expect(cookies[0]).to.have.property('secure', false)
|
||||
expect(cookies[0]).to.have.property('domain')
|
||||
expect(cookies[0]).to.have.property('path')
|
||||
})
|
||||
})
|
||||
|
||||
it('cy.setCookie() - set a browser cookie', () => {
|
||||
// https://on.cypress.io/setcookie
|
||||
cy.getCookies().should('be.empty')
|
||||
|
||||
cy.setCookie('foo', 'bar')
|
||||
|
||||
// cy.getCookie() yields a cookie object
|
||||
cy.getCookie('foo').should('have.property', 'value', 'bar')
|
||||
})
|
||||
|
||||
it('cy.clearCookie() - clear a browser cookie', () => {
|
||||
// https://on.cypress.io/clearcookie
|
||||
cy.getCookie('token').should('be.null')
|
||||
|
||||
cy.get('#clearCookie .set-a-cookie').click()
|
||||
|
||||
cy.getCookie('token').should('have.property', 'value', '123ABC')
|
||||
|
||||
// cy.clearCookies() yields null
|
||||
cy.clearCookie('token').should('be.null')
|
||||
|
||||
cy.getCookie('token').should('be.null')
|
||||
})
|
||||
|
||||
it('cy.clearCookies() - clear browser cookies', () => {
|
||||
// https://on.cypress.io/clearcookies
|
||||
cy.getCookies().should('be.empty')
|
||||
|
||||
cy.get('#clearCookies .set-a-cookie').click()
|
||||
|
||||
cy.getCookies().should('have.length', 1)
|
||||
|
||||
// cy.clearCookies() yields null
|
||||
cy.clearCookies()
|
||||
|
||||
cy.getCookies().should('be.empty')
|
||||
})
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Cypress.Commands', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
// https://on.cypress.io/custom-commands
|
||||
|
||||
it('.add() - create a custom command', () => {
|
||||
Cypress.Commands.add('console', {
|
||||
prevSubject: true,
|
||||
}, (subject, method) => {
|
||||
// the previous subject is automatically received
|
||||
// and the commands arguments are shifted
|
||||
|
||||
// allow us to change the console method used
|
||||
method = method || 'log'
|
||||
|
||||
// log the subject to the console
|
||||
// @ts-ignore TS7017
|
||||
console[method]('The subject is', subject)
|
||||
|
||||
// whatever we return becomes the new subject
|
||||
// we don't want to change the subject so
|
||||
// we return whatever was passed in
|
||||
return subject
|
||||
})
|
||||
|
||||
// @ts-ignore TS2339
|
||||
cy.get('button').console('info').then(($button) => {
|
||||
// subject is still $button
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
context('Cypress.Cookies', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
// https://on.cypress.io/cookies
|
||||
it('.debug() - enable or disable debugging', () => {
|
||||
Cypress.Cookies.debug(true)
|
||||
|
||||
// Cypress will now log in the console when
|
||||
// cookies are set or cleared
|
||||
cy.setCookie('fakeCookie', '123ABC')
|
||||
cy.clearCookie('fakeCookie')
|
||||
cy.setCookie('fakeCookie', '123ABC')
|
||||
cy.clearCookie('fakeCookie')
|
||||
cy.setCookie('fakeCookie', '123ABC')
|
||||
})
|
||||
|
||||
it('.preserveOnce() - preserve cookies by key', () => {
|
||||
// normally cookies are reset after each test
|
||||
cy.getCookie('fakeCookie').should('not.be.ok')
|
||||
|
||||
// preserving a cookie will not clear it when
|
||||
// the next test starts
|
||||
cy.setCookie('lastCookie', '789XYZ')
|
||||
Cypress.Cookies.preserveOnce('lastCookie')
|
||||
})
|
||||
|
||||
it('.defaults() - set defaults for all cookies', () => {
|
||||
// now any cookie with the name 'session_id' will
|
||||
// not be cleared before each new test runs
|
||||
Cypress.Cookies.defaults({
|
||||
whitelist: 'session_id',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
context('Cypress.Server', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
// Permanently override server options for
|
||||
// all instances of cy.server()
|
||||
|
||||
// https://on.cypress.io/cypress-server
|
||||
it('.defaults() - change default config of server', () => {
|
||||
Cypress.Server.defaults({
|
||||
delay: 0,
|
||||
force404: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('Cypress.arch', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
it('Get CPU architecture name of underlying OS', () => {
|
||||
// https://on.cypress.io/arch
|
||||
expect(Cypress.arch).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
context('Cypress.config()', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
it('Get and set configuration options', () => {
|
||||
// https://on.cypress.io/config
|
||||
let myConfig = Cypress.config()
|
||||
|
||||
expect(myConfig).to.have.property('animationDistanceThreshold', 5)
|
||||
expect(myConfig).to.have.property('baseUrl', null)
|
||||
expect(myConfig).to.have.property('defaultCommandTimeout', 4000)
|
||||
expect(myConfig).to.have.property('requestTimeout', 5000)
|
||||
expect(myConfig).to.have.property('responseTimeout', 30000)
|
||||
expect(myConfig).to.have.property('viewportHeight', 660)
|
||||
expect(myConfig).to.have.property('viewportWidth', 1000)
|
||||
expect(myConfig).to.have.property('pageLoadTimeout', 60000)
|
||||
expect(myConfig).to.have.property('waitForAnimations', true)
|
||||
|
||||
expect(Cypress.config('pageLoadTimeout')).to.eq(60000)
|
||||
|
||||
// this will change the config for the rest of your tests!
|
||||
Cypress.config('pageLoadTimeout', 20000)
|
||||
|
||||
expect(Cypress.config('pageLoadTimeout')).to.eq(20000)
|
||||
|
||||
Cypress.config('pageLoadTimeout', 60000)
|
||||
})
|
||||
})
|
||||
|
||||
context('Cypress.dom', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
// https://on.cypress.io/dom
|
||||
it('.isHidden() - determine if a DOM element is hidden', () => {
|
||||
let hiddenP = Cypress.$('.dom-p p.hidden').get(0)
|
||||
let visibleP = Cypress.$('.dom-p p.visible').get(0)
|
||||
|
||||
// our first paragraph has css class 'hidden'
|
||||
expect(Cypress.dom.isHidden(hiddenP)).to.be.true
|
||||
expect(Cypress.dom.isHidden(visibleP)).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
context('Cypress.env()', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
// We can set environment variables for highly dynamic values
|
||||
|
||||
// https://on.cypress.io/environment-variables
|
||||
it('Get environment variables', () => {
|
||||
// https://on.cypress.io/env
|
||||
// set multiple environment variables
|
||||
Cypress.env({
|
||||
host: 'veronica.dev.local',
|
||||
api_server: 'http://localhost:8888/v1/',
|
||||
})
|
||||
|
||||
// get environment variable
|
||||
expect(Cypress.env('host')).to.eq('veronica.dev.local')
|
||||
|
||||
// set environment variable
|
||||
Cypress.env('api_server', 'http://localhost:8888/v2/')
|
||||
expect(Cypress.env('api_server')).to.eq('http://localhost:8888/v2/')
|
||||
|
||||
// get all environment variable
|
||||
expect(Cypress.env()).to.have.property('host', 'veronica.dev.local')
|
||||
expect(Cypress.env()).to.have.property('api_server', 'http://localhost:8888/v2/')
|
||||
})
|
||||
})
|
||||
|
||||
context('Cypress.log', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
it('Control what is printed to the Command Log', () => {
|
||||
// https://on.cypress.io/cypress-log
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
context('Cypress.platform', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
it('Get underlying OS name', () => {
|
||||
// https://on.cypress.io/platform
|
||||
expect(Cypress.platform).to.be.exist
|
||||
})
|
||||
})
|
||||
|
||||
context('Cypress.version', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/cypress-api')
|
||||
})
|
||||
|
||||
it('Get current version of Cypress being run', () => {
|
||||
// https://on.cypress.io/version
|
||||
expect(Cypress.version).to.be.exist
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Files', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/files')
|
||||
})
|
||||
it('cy.fixture() - load a fixture', () => {
|
||||
// https://on.cypress.io/fixture
|
||||
|
||||
// Instead of writing a response inline you can
|
||||
// use a fixture file's content.
|
||||
|
||||
cy.server()
|
||||
cy.fixture('example.json').as('comment')
|
||||
cy.route('GET', 'comments/*', '@comment').as('getComment')
|
||||
|
||||
// we have code that gets a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.fixture-btn').click()
|
||||
|
||||
cy.wait('@getComment').its('responseBody')
|
||||
.should('have.property', 'name')
|
||||
.and('include', 'Using fixtures to represent data')
|
||||
|
||||
// you can also just write the fixture in the route
|
||||
cy.route('GET', 'comments/*', 'fixture:example.json').as('getComment')
|
||||
|
||||
// we have code that gets a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.fixture-btn').click()
|
||||
|
||||
cy.wait('@getComment').its('responseBody')
|
||||
.should('have.property', 'name')
|
||||
.and('include', 'Using fixtures to represent data')
|
||||
|
||||
// or write fx to represent fixture
|
||||
// by default it assumes it's .json
|
||||
cy.route('GET', 'comments/*', 'fx:example').as('getComment')
|
||||
|
||||
// we have code that gets a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.fixture-btn').click()
|
||||
|
||||
cy.wait('@getComment').its('responseBody')
|
||||
.should('have.property', 'name')
|
||||
.and('include', 'Using fixtures to represent data')
|
||||
})
|
||||
|
||||
it('cy.readFile() - read a files contents', () => {
|
||||
// https://on.cypress.io/readfile
|
||||
|
||||
// You can read a file and yield its contents
|
||||
// The filePath is relative to your project's root.
|
||||
cy.readFile('cypress.json').then((json) => {
|
||||
expect(json).to.be.an('object')
|
||||
})
|
||||
})
|
||||
|
||||
it('cy.writeFile() - write to a file', () => {
|
||||
// https://on.cypress.io/writefile
|
||||
|
||||
// You can write to a file
|
||||
|
||||
// Use a response from a request to automatically
|
||||
// generate a fixture file for use later
|
||||
cy.request('https://jsonplaceholder.typicode.com/users')
|
||||
.then((response) => {
|
||||
cy.writeFile('cypress/fixtures/users.json', response.body)
|
||||
})
|
||||
cy.fixture('users').should((users) => {
|
||||
expect(users[0].name).to.exist
|
||||
})
|
||||
|
||||
// JavaScript arrays and objects are stringified
|
||||
// and formatted into text.
|
||||
cy.writeFile('cypress/fixtures/profile.json', {
|
||||
id: 8739,
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
})
|
||||
|
||||
cy.fixture('profile').should((profile) => {
|
||||
expect(profile.name).to.eq('Jane')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Local Storage', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/local-storage')
|
||||
})
|
||||
// Although local storage is automatically cleared
|
||||
// in between tests to maintain a clean state
|
||||
// sometimes we need to clear the local storage manually
|
||||
|
||||
it('cy.clearLocalStorage() - clear all data in local storage', () => {
|
||||
// https://on.cypress.io/clearlocalstorage
|
||||
cy.get('.ls-btn').click().should(() => {
|
||||
expect(localStorage.getItem('prop1')).to.eq('red')
|
||||
expect(localStorage.getItem('prop2')).to.eq('blue')
|
||||
expect(localStorage.getItem('prop3')).to.eq('magenta')
|
||||
})
|
||||
|
||||
// clearLocalStorage() yields the localStorage object
|
||||
cy.clearLocalStorage().should((ls) => {
|
||||
expect(ls.getItem('prop1')).to.be.null
|
||||
expect(ls.getItem('prop2')).to.be.null
|
||||
expect(ls.getItem('prop3')).to.be.null
|
||||
})
|
||||
|
||||
// Clear key matching string in Local Storage
|
||||
cy.get('.ls-btn').click().should(() => {
|
||||
expect(localStorage.getItem('prop1')).to.eq('red')
|
||||
expect(localStorage.getItem('prop2')).to.eq('blue')
|
||||
expect(localStorage.getItem('prop3')).to.eq('magenta')
|
||||
})
|
||||
|
||||
cy.clearLocalStorage('prop1').should((ls) => {
|
||||
expect(ls.getItem('prop1')).to.be.null
|
||||
expect(ls.getItem('prop2')).to.eq('blue')
|
||||
expect(ls.getItem('prop3')).to.eq('magenta')
|
||||
})
|
||||
|
||||
// Clear keys matching regex in Local Storage
|
||||
cy.get('.ls-btn').click().should(() => {
|
||||
expect(localStorage.getItem('prop1')).to.eq('red')
|
||||
expect(localStorage.getItem('prop2')).to.eq('blue')
|
||||
expect(localStorage.getItem('prop3')).to.eq('magenta')
|
||||
})
|
||||
|
||||
cy.clearLocalStorage(/prop1|2/).should((ls) => {
|
||||
expect(ls.getItem('prop1')).to.be.null
|
||||
expect(ls.getItem('prop2')).to.be.null
|
||||
expect(ls.getItem('prop3')).to.eq('magenta')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Location', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/location')
|
||||
})
|
||||
|
||||
it('cy.hash() - get the current URL hash', () => {
|
||||
// https://on.cypress.io/hash
|
||||
cy.hash().should('be.empty')
|
||||
})
|
||||
|
||||
it('cy.location() - get window.location', () => {
|
||||
// https://on.cypress.io/location
|
||||
cy.location().should((location) => {
|
||||
expect(location.hash).to.be.empty
|
||||
expect(location.href).to.eq('https://example.cypress.io/commands/location')
|
||||
expect(location.host).to.eq('example.cypress.io')
|
||||
expect(location.hostname).to.eq('example.cypress.io')
|
||||
expect(location.origin).to.eq('https://example.cypress.io')
|
||||
expect(location.pathname).to.eq('/commands/location')
|
||||
expect(location.port).to.eq('')
|
||||
expect(location.protocol).to.eq('https:')
|
||||
expect(location.search).to.be.empty
|
||||
})
|
||||
})
|
||||
|
||||
it('cy.url() - get the current URL', () => {
|
||||
// https://on.cypress.io/url
|
||||
cy.url().should('eq', 'https://example.cypress.io/commands/location')
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Misc', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/misc')
|
||||
})
|
||||
|
||||
it('.end() - end the command chain', () => {
|
||||
// https://on.cypress.io/end
|
||||
|
||||
// cy.end is useful when you want to end a chain of commands
|
||||
// and force Cypress to re-query from the root element
|
||||
cy.get('.misc-table').within(() => {
|
||||
// ends the current chain and yields null
|
||||
cy.contains('Cheryl').click().end()
|
||||
|
||||
// queries the entire table again
|
||||
cy.contains('Charles').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('cy.exec() - execute a system command', () => {
|
||||
// https://on.cypress.io/exec
|
||||
|
||||
// execute a system command.
|
||||
// so you can take actions necessary for
|
||||
// your test outside the scope of Cypress.
|
||||
cy.exec('echo Jane Lane')
|
||||
.its('stdout').should('contain', 'Jane Lane')
|
||||
|
||||
// we can use Cypress.platform string to
|
||||
// select appropriate command
|
||||
// https://on.cypress/io/platform
|
||||
cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`)
|
||||
|
||||
if (Cypress.platform === 'win32') {
|
||||
cy.exec('print cypress.json')
|
||||
.its('stderr').should('be.empty')
|
||||
} else {
|
||||
cy.exec('cat cypress.json')
|
||||
.its('stderr').should('be.empty')
|
||||
|
||||
cy.exec('pwd')
|
||||
.its('code').should('eq', 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('cy.focused() - get the DOM element that has focus', () => {
|
||||
// https://on.cypress.io/focused
|
||||
cy.get('.misc-form').find('#name').click()
|
||||
cy.focused().should('have.id', 'name')
|
||||
|
||||
cy.get('.misc-form').find('#description').click()
|
||||
cy.focused().should('have.id', 'description')
|
||||
})
|
||||
|
||||
context('Cypress.Screenshot', function () {
|
||||
it('cy.screenshot() - take a screenshot', () => {
|
||||
// https://on.cypress.io/screenshot
|
||||
cy.screenshot('my-image')
|
||||
})
|
||||
|
||||
it('Cypress.Screenshot.defaults() - change default config of screenshots', function () {
|
||||
Cypress.Screenshot.defaults({
|
||||
blackout: ['.foo'],
|
||||
capture: 'viewport',
|
||||
clip: { x: 0, y: 0, width: 200, height: 200 },
|
||||
scale: false,
|
||||
disableTimersAndAnimations: true,
|
||||
screenshotOnRunFailure: true,
|
||||
beforeScreenshot () { },
|
||||
afterScreenshot () { },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cy.wrap() - wrap an object', () => {
|
||||
// https://on.cypress.io/wrap
|
||||
cy.wrap({ foo: 'bar' })
|
||||
.should('have.property', 'foo')
|
||||
.and('include', 'bar')
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Navigation', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io')
|
||||
cy.get('.navbar-nav').contains('Commands').click()
|
||||
cy.get('.dropdown-menu').contains('Navigation').click()
|
||||
})
|
||||
|
||||
it('cy.go() - go back or forward in the browser\'s history', () => {
|
||||
// https://on.cypress.io/go
|
||||
|
||||
cy.location('pathname').should('include', 'navigation')
|
||||
|
||||
cy.go('back')
|
||||
cy.location('pathname').should('not.include', 'navigation')
|
||||
|
||||
cy.go('forward')
|
||||
cy.location('pathname').should('include', 'navigation')
|
||||
|
||||
// clicking back
|
||||
cy.go(-1)
|
||||
cy.location('pathname').should('not.include', 'navigation')
|
||||
|
||||
// clicking forward
|
||||
cy.go(1)
|
||||
cy.location('pathname').should('include', 'navigation')
|
||||
})
|
||||
|
||||
it('cy.reload() - reload the page', () => {
|
||||
// https://on.cypress.io/reload
|
||||
cy.reload()
|
||||
|
||||
// reload the page without using the cache
|
||||
cy.reload(true)
|
||||
})
|
||||
|
||||
it('cy.visit() - visit a remote url', () => {
|
||||
// https://on.cypress.io/visit
|
||||
|
||||
// Visit any sub-domain of your current domain
|
||||
|
||||
// Pass options to the visit
|
||||
cy.visit('https://example.cypress.io/commands/navigation', {
|
||||
timeout: 50000, // increase total time for the visit to resolve
|
||||
onBeforeLoad (contentWindow) {
|
||||
// contentWindow is the remote page's window object
|
||||
expect(typeof contentWindow === 'object').to.be.true
|
||||
},
|
||||
onLoad (contentWindow) {
|
||||
// contentWindow is the remote page's window object
|
||||
expect(typeof contentWindow === 'object').to.be.true
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Network Requests', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/network-requests')
|
||||
})
|
||||
|
||||
// Manage AJAX / XHR requests in your app
|
||||
|
||||
it('cy.server() - control behavior of network requests and responses', () => {
|
||||
// https://on.cypress.io/server
|
||||
|
||||
cy.server().should((server) => {
|
||||
// the default options on server
|
||||
// you can override any of these options
|
||||
expect(server.delay).to.eq(0)
|
||||
expect(server.method).to.eq('GET')
|
||||
expect(server.status).to.eq(200)
|
||||
expect(server.headers).to.be.null
|
||||
expect(server.response).to.be.null
|
||||
expect(server.onRequest).to.be.undefined
|
||||
expect(server.onResponse).to.be.undefined
|
||||
expect(server.onAbort).to.be.undefined
|
||||
|
||||
// These options control the server behavior
|
||||
// affecting all requests
|
||||
|
||||
// pass false to disable existing route stubs
|
||||
expect(server.enable).to.be.true
|
||||
// forces requests that don't match your routes to 404
|
||||
expect(server.force404).to.be.false
|
||||
// whitelists requests from ever being logged or stubbed
|
||||
expect(server.whitelist).to.be.a('function')
|
||||
})
|
||||
|
||||
cy.server({
|
||||
method: 'POST',
|
||||
delay: 1000,
|
||||
status: 422,
|
||||
response: {},
|
||||
})
|
||||
|
||||
// any route commands will now inherit the above options
|
||||
// from the server. anything we pass specifically
|
||||
// to route will override the defaults though.
|
||||
})
|
||||
|
||||
it('cy.request() - make an XHR request', () => {
|
||||
// https://on.cypress.io/request
|
||||
cy.request('https://jsonplaceholder.typicode.com/comments')
|
||||
.should((response) => {
|
||||
expect(response.status).to.eq(200)
|
||||
expect(response.body).to.have.length(500)
|
||||
expect(response).to.have.property('headers')
|
||||
expect(response).to.have.property('duration')
|
||||
})
|
||||
})
|
||||
|
||||
it('cy.route() - route responses to matching requests', () => {
|
||||
// https://on.cypress.io/route
|
||||
|
||||
let message = 'whoa, this comment does not exist'
|
||||
cy.server()
|
||||
|
||||
// Listen to GET to comments/1
|
||||
cy.route('GET', 'comments/*').as('getComment')
|
||||
|
||||
// we have code that gets a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.network-btn').click()
|
||||
|
||||
// https://on.cypress.io/wait
|
||||
cy.wait('@getComment').its('status').should('eq', 200)
|
||||
|
||||
// Listen to POST to comments
|
||||
cy.route('POST', '/comments').as('postComment')
|
||||
|
||||
// we have code that posts a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.network-post').click()
|
||||
cy.wait('@postComment')
|
||||
|
||||
// get the route
|
||||
cy.get('@postComment').should((xhr) => {
|
||||
expect(xhr.requestBody).to.include('email')
|
||||
expect(xhr.requestHeaders).to.have.property('Content-Type')
|
||||
expect(xhr.responseBody).to.have.property('name', 'Using POST in cy.route()')
|
||||
})
|
||||
|
||||
// Stub a response to PUT comments/ ****
|
||||
cy.route({
|
||||
method: 'PUT',
|
||||
url: 'comments/*',
|
||||
status: 404,
|
||||
response: { error: message },
|
||||
delay: 500,
|
||||
}).as('putComment')
|
||||
|
||||
// we have code that puts a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.network-put').click()
|
||||
|
||||
cy.wait('@putComment')
|
||||
|
||||
// our 404 statusCode logic in scripts.js executed
|
||||
cy.get('.network-put-comment').should('contain', message)
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Querying', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/querying')
|
||||
})
|
||||
|
||||
// The most commonly used query is 'cy.get()', you can
|
||||
// think of this like the '$' in jQuery
|
||||
|
||||
it('cy.get() - query DOM elements', () => {
|
||||
// https://on.cypress.io/get
|
||||
|
||||
cy.get('#query-btn').should('contain', 'Button')
|
||||
|
||||
cy.get('.query-btn').should('contain', 'Button')
|
||||
|
||||
cy.get('#querying .well>button:first').should('contain', 'Button')
|
||||
// ↲
|
||||
// Use CSS selectors just like jQuery
|
||||
})
|
||||
|
||||
it('cy.contains() - query DOM elements with matching content', () => {
|
||||
// https://on.cypress.io/contains
|
||||
cy.get('.query-list')
|
||||
.contains('bananas').should('have.class', 'third')
|
||||
|
||||
// we can pass a regexp to `.contains()`
|
||||
cy.get('.query-list')
|
||||
.contains(/^b\w+/).should('have.class', 'third')
|
||||
|
||||
cy.get('.query-list')
|
||||
.contains('apples').should('have.class', 'first')
|
||||
|
||||
// passing a selector to contains will
|
||||
// yield the selector containing the text
|
||||
cy.get('#querying')
|
||||
.contains('ul', 'oranges')
|
||||
.should('have.class', 'query-list')
|
||||
|
||||
cy.get('.query-button')
|
||||
.contains('Save Form')
|
||||
.should('have.class', 'btn')
|
||||
})
|
||||
|
||||
it('.within() - query DOM elements within a specific element', () => {
|
||||
// https://on.cypress.io/within
|
||||
cy.get('.query-form').within(() => {
|
||||
cy.get('input:first').should('have.attr', 'placeholder', 'Email')
|
||||
cy.get('input:last').should('have.attr', 'placeholder', 'Password')
|
||||
})
|
||||
})
|
||||
|
||||
it('cy.root() - query the root DOM element', () => {
|
||||
// https://on.cypress.io/root
|
||||
|
||||
// By default, root is the document
|
||||
cy.root().should('match', 'html')
|
||||
|
||||
cy.get('.query-ul').within(() => {
|
||||
// In this within, the root is now the ul DOM element
|
||||
cy.root().should('have.class', 'query-ul')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Spies, Stubs, and Clock', () => {
|
||||
it('cy.spy() - wrap a method in a spy', () => {
|
||||
// https://on.cypress.io/spy
|
||||
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
|
||||
|
||||
let obj = {
|
||||
foo() {}
|
||||
}
|
||||
|
||||
let spy = cy.spy(obj, 'foo').as('anyArgs')
|
||||
|
||||
obj.foo()
|
||||
|
||||
expect(spy).to.be.called
|
||||
})
|
||||
|
||||
it('cy.stub() - create a stub and/or replace a function with stub', () => {
|
||||
// https://on.cypress.io/stub
|
||||
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
|
||||
|
||||
let obj = {
|
||||
/**
|
||||
* prints both arguments to the console
|
||||
* @param {string} a a
|
||||
* @param {string} b b
|
||||
*/
|
||||
foo(a, b) {
|
||||
console.log('a', a, 'b', b)
|
||||
}
|
||||
}
|
||||
|
||||
let stub = cy.stub(obj, 'foo').as('foo')
|
||||
|
||||
obj.foo('foo', 'bar')
|
||||
|
||||
expect(stub).to.be.called
|
||||
})
|
||||
|
||||
it('cy.clock() - control time in the browser', () => {
|
||||
// https://on.cypress.io/clock
|
||||
|
||||
// create the date in UTC so its always the same
|
||||
// no matter what local timezone the browser is running in
|
||||
let now = new Date(Date.UTC(2017, 2, 14)).getTime()
|
||||
|
||||
cy.clock(now)
|
||||
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
|
||||
cy.get('#clock-div').click()
|
||||
.should('have.text', '1489449600')
|
||||
})
|
||||
|
||||
it('cy.tick() - move time in the browser', () => {
|
||||
// https://on.cypress.io/tick
|
||||
|
||||
// create the date in UTC so its always the same
|
||||
// no matter what local timezone the browser is running in
|
||||
let now = new Date(Date.UTC(2017, 2, 14)).getTime()
|
||||
|
||||
cy.clock(now)
|
||||
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
|
||||
cy.get('#tick-div').click()
|
||||
.should('have.text', '1489449600')
|
||||
cy.tick(10000) // 10 seconds passed
|
||||
cy.get('#tick-div').click()
|
||||
.should('have.text', '1489449610')
|
||||
})
|
||||
})
|
||||
@@ -1,121 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Traversal', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/traversal')
|
||||
})
|
||||
|
||||
it('.children() - get child DOM elements', () => {
|
||||
// https://on.cypress.io/children
|
||||
cy.get('.traversal-breadcrumb')
|
||||
.children('.active')
|
||||
.should('contain', 'Data')
|
||||
})
|
||||
|
||||
it('.closest() - get closest ancestor DOM element', () => {
|
||||
// https://on.cypress.io/closest
|
||||
cy.get('.traversal-badge')
|
||||
.closest('ul')
|
||||
.should('have.class', 'list-group')
|
||||
})
|
||||
|
||||
it('.eq() - get a DOM element at a specific index', () => {
|
||||
// https://on.cypress.io/eq
|
||||
cy.get('.traversal-list>li')
|
||||
.eq(1).should('contain', 'siamese')
|
||||
})
|
||||
|
||||
it('.filter() - get DOM elements that match the selector', () => {
|
||||
// https://on.cypress.io/filter
|
||||
cy.get('.traversal-nav>li')
|
||||
.filter('.active').should('contain', 'About')
|
||||
})
|
||||
|
||||
it('.find() - get descendant DOM elements of the selector', () => {
|
||||
// https://on.cypress.io/find
|
||||
cy.get('.traversal-pagination')
|
||||
.find('li').find('a')
|
||||
.should('have.length', 7)
|
||||
})
|
||||
|
||||
it('.first() - get first DOM element', () => {
|
||||
// https://on.cypress.io/first
|
||||
cy.get('.traversal-table td')
|
||||
.first().should('contain', '1')
|
||||
})
|
||||
|
||||
it('.last() - get last DOM element', () => {
|
||||
// https://on.cypress.io/last
|
||||
cy.get('.traversal-buttons .btn')
|
||||
.last().should('contain', 'Submit')
|
||||
})
|
||||
|
||||
it('.next() - get next sibling DOM element', () => {
|
||||
// https://on.cypress.io/next
|
||||
cy.get('.traversal-ul')
|
||||
.contains('apples').next().should('contain', 'oranges')
|
||||
})
|
||||
|
||||
it('.nextAll() - get all next sibling DOM elements', () => {
|
||||
// https://on.cypress.io/nextall
|
||||
cy.get('.traversal-next-all')
|
||||
.contains('oranges')
|
||||
.nextAll().should('have.length', 3)
|
||||
})
|
||||
|
||||
it('.nextUntil() - get next sibling DOM elements until next el', () => {
|
||||
// https://on.cypress.io/nextuntil
|
||||
cy.get('#veggies')
|
||||
.nextUntil('#nuts').should('have.length', 3)
|
||||
})
|
||||
|
||||
it('.not() - remove DOM elements from set of DOM elements', () => {
|
||||
// https://on.cypress.io/not
|
||||
cy.get('.traversal-disabled .btn')
|
||||
.not('[disabled]').should('not.contain', 'Disabled')
|
||||
})
|
||||
|
||||
it('.parent() - get parent DOM element from DOM elements', () => {
|
||||
// https://on.cypress.io/parent
|
||||
cy.get('.traversal-mark')
|
||||
.parent().should('contain', 'Morbi leo risus')
|
||||
})
|
||||
|
||||
it('.parents() - get parent DOM elements from DOM elements', () => {
|
||||
// https://on.cypress.io/parents
|
||||
cy.get('.traversal-cite')
|
||||
.parents().should('match', 'blockquote')
|
||||
})
|
||||
|
||||
it('.parentsUntil() - get parent DOM elements from DOM elements until el', () => {
|
||||
// https://on.cypress.io/parentsuntil
|
||||
cy.get('.clothes-nav')
|
||||
.find('.active')
|
||||
.parentsUntil('.clothes-nav')
|
||||
.should('have.length', 2)
|
||||
})
|
||||
|
||||
it('.prev() - get previous sibling DOM element', () => {
|
||||
// https://on.cypress.io/prev
|
||||
cy.get('.birds').find('.active')
|
||||
.prev().should('contain', 'Lorikeets')
|
||||
})
|
||||
|
||||
it('.prevAll() - get all previous sibling DOM elements', () => {
|
||||
// https://on.cypress.io/prevAll
|
||||
cy.get('.fruits-list').find('.third')
|
||||
.prevAll().should('have.length', 2)
|
||||
})
|
||||
|
||||
it('.prevUntil() - get all previous sibling DOM elements until el', () => {
|
||||
// https://on.cypress.io/prevUntil
|
||||
cy.get('.foods-list').find('#nuts')
|
||||
.prevUntil('#veggies').should('have.length', 3)
|
||||
})
|
||||
|
||||
it('.siblings() - get all sibling DOM elements', () => {
|
||||
// https://on.cypress.io/siblings
|
||||
cy.get('.traversal-pills .active')
|
||||
.siblings().should('have.length', 2)
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Utilities', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/utilities')
|
||||
})
|
||||
|
||||
it('Cypress._ - call a lodash method', () => {
|
||||
// https://on.cypress.io/_
|
||||
cy.request('https://jsonplaceholder.typicode.com/users')
|
||||
.then((response) => {
|
||||
let ids = Cypress._.chain(response.body).map('id').take(3).value()
|
||||
|
||||
expect(ids).to.deep.eq([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
it('Cypress.$ - call a jQuery method', () => {
|
||||
// https://on.cypress.io/$
|
||||
let $li = Cypress.$('.utility-jquery li:first')
|
||||
|
||||
cy.wrap($li)
|
||||
.should('not.have.class', 'active')
|
||||
.click()
|
||||
.should('have.class', 'active')
|
||||
})
|
||||
|
||||
it('Cypress.Blob - blob utilities and base64 string conversion', () => {
|
||||
// https://on.cypress.io/blob
|
||||
cy.get('.utility-blob').then(($div) =>
|
||||
// https://github.com/nolanlawson/blob-util#imgSrcToDataURL
|
||||
// get the dataUrl string for the javascript-logo
|
||||
Cypress.Blob.imgSrcToDataURL('https://example.cypress.io/assets/img/javascript-logo.png', undefined, 'anonymous')
|
||||
.then((dataUrl) => {
|
||||
// create an <img> element and set its src to the dataUrl
|
||||
let img = Cypress.$('<img />', { src: dataUrl })
|
||||
// need to explicitly return cy here since we are initially returning
|
||||
// the Cypress.Blob.imgSrcToDataURL promise to our test
|
||||
// append the image
|
||||
$div.append(img)
|
||||
|
||||
cy.get('.utility-blob img').click()
|
||||
.should('have.attr', 'src', dataUrl)
|
||||
}))
|
||||
})
|
||||
|
||||
it('Cypress.minimatch - test out glob patterns against strings', () => {
|
||||
// https://on.cypress.io/minimatch
|
||||
let matching = Cypress.minimatch('/users/1/comments', '/users/*/comments', {
|
||||
matchBase: true,
|
||||
})
|
||||
expect(matching, 'matching wildcard').to.be.true
|
||||
|
||||
matching = Cypress.minimatch("/users/1/comments/2", "/users/*/comments", {
|
||||
matchBase: true
|
||||
})
|
||||
expect(matching, 'comments').to.be.false
|
||||
|
||||
// ** matches against all downstream path segments
|
||||
matching = Cypress.minimatch("/foo/bar/baz/123/quux?a=b&c=2", "/foo/**", {
|
||||
matchBase: true
|
||||
})
|
||||
expect(matching, 'comments').to.be.true
|
||||
|
||||
// whereas * matches only the next path segment
|
||||
|
||||
matching = Cypress.minimatch("/foo/bar/baz/123/quux?a=b&c=2", "/foo/*", {
|
||||
matchBase: false
|
||||
})
|
||||
expect(matching, 'comments').to.be.false
|
||||
})
|
||||
|
||||
|
||||
it('Cypress.moment() - format or parse dates using a moment method', () => {
|
||||
// https://on.cypress.io/moment
|
||||
const time = Cypress.moment().utc('2014-04-25T19:38:53.196Z').format('h:mm A')
|
||||
expect(time).to.be.a('string')
|
||||
|
||||
cy.get('.utility-moment').contains('3:38 PM')
|
||||
.should('have.class', 'badge')
|
||||
})
|
||||
|
||||
|
||||
it('Cypress.Promise - instantiate a bluebird promise', () => {
|
||||
// https://on.cypress.io/promise
|
||||
let waited = false
|
||||
|
||||
/**
|
||||
* @return Bluebird<string>
|
||||
*/
|
||||
function waitOneSecond () {
|
||||
// return a promise that resolves after 1 second
|
||||
// @ts-ignore TS2351 (new Cypress.Promise)
|
||||
return new Cypress.Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
// set waited to true
|
||||
waited = true
|
||||
|
||||
// resolve with 'foo' string
|
||||
resolve('foo')
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
cy.then(() =>
|
||||
// return a promise to cy.then() that
|
||||
// is awaited until it resolves
|
||||
// @ts-ignore TS7006
|
||||
waitOneSecond().then((str) => {
|
||||
expect(str).to.eq('foo')
|
||||
expect(waited).to.be.true
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Viewport', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/viewport')
|
||||
})
|
||||
|
||||
it('cy.viewport() - set the viewport size and dimension', () => {
|
||||
// https://on.cypress.io/viewport
|
||||
|
||||
cy.get('#navbar').should('be.visible')
|
||||
cy.viewport(320, 480)
|
||||
|
||||
// the navbar should have collapse since our screen is smaller
|
||||
cy.get('#navbar').should('not.be.visible')
|
||||
cy.get('.navbar-toggle').should('be.visible').click()
|
||||
cy.get('.nav').find('a').should('be.visible')
|
||||
|
||||
// lets see what our app looks like on a super large screen
|
||||
cy.viewport(2999, 2999)
|
||||
|
||||
// cy.viewport() accepts a set of preset sizes
|
||||
// to easily set the screen to a device's width and height
|
||||
|
||||
// We added a cy.wait() between each viewport change so you can see
|
||||
// the change otherwise it is a little too fast to see :)
|
||||
|
||||
cy.viewport('macbook-15')
|
||||
cy.wait(200)
|
||||
cy.viewport('macbook-13')
|
||||
cy.wait(200)
|
||||
cy.viewport('macbook-11')
|
||||
cy.wait(200)
|
||||
cy.viewport('ipad-2')
|
||||
cy.wait(200)
|
||||
cy.viewport('ipad-mini')
|
||||
cy.wait(200)
|
||||
cy.viewport('iphone-6+')
|
||||
cy.wait(200)
|
||||
cy.viewport('iphone-6')
|
||||
cy.wait(200)
|
||||
cy.viewport('iphone-5')
|
||||
cy.wait(200)
|
||||
cy.viewport('iphone-4')
|
||||
cy.wait(200)
|
||||
cy.viewport('iphone-3')
|
||||
cy.wait(200)
|
||||
|
||||
// cy.viewport() accepts an orientation for all presets
|
||||
// the default orientation is 'portrait'
|
||||
cy.viewport('ipad-2', 'portrait')
|
||||
cy.wait(200)
|
||||
cy.viewport('iphone-4', 'landscape')
|
||||
cy.wait(200)
|
||||
|
||||
// The viewport will be reset back to the default dimensions
|
||||
// in between tests (the default can be set in cypress.json)
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Waiting', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/waiting')
|
||||
})
|
||||
// BE CAREFUL of adding unnecessary wait times.
|
||||
// https://on.cypress.io/best-practices#Unnecessary-Waiting
|
||||
|
||||
// https://on.cypress.io/wait
|
||||
it('cy.wait() - wait for a specific amount of time', () => {
|
||||
cy.get('.wait-input1').type('Wait 1000ms after typing')
|
||||
cy.wait(1000)
|
||||
cy.get('.wait-input2').type('Wait 1000ms after typing')
|
||||
cy.wait(1000)
|
||||
cy.get('.wait-input3').type('Wait 1000ms after typing')
|
||||
cy.wait(1000)
|
||||
})
|
||||
|
||||
it('cy.wait() - wait for a specific route', () => {
|
||||
cy.server()
|
||||
|
||||
// Listen to GET to comments/1
|
||||
cy.route('GET', 'comments/*').as('getComment')
|
||||
|
||||
// we have code that gets a comment when
|
||||
// the button is clicked in scripts.js
|
||||
cy.get('.network-btn').click()
|
||||
|
||||
// wait for GET comments/1
|
||||
cy.wait('@getComment').its('status').should('eq', 200)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
context('Window', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('https://example.cypress.io/commands/window')
|
||||
})
|
||||
|
||||
it('cy.window() - get the global window object', () => {
|
||||
// https://on.cypress.io/window
|
||||
cy.window().should('have.property', 'top')
|
||||
})
|
||||
|
||||
it('cy.document() - get the document object', () => {
|
||||
// https://on.cypress.io/document
|
||||
cy.document().should('have.property', 'charset').and('eq', 'UTF-8')
|
||||
})
|
||||
|
||||
it('cy.title() - get the title', () => {
|
||||
// https://on.cypress.io/title
|
||||
cy.title().should('include', 'Kitchen Sink')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import HelloWorld from '@/components/HelloWorld.vue'
|
||||
|
||||
describe('HelloWorld.vue', () => {
|
||||
it('renders props.msg when passed', () => {
|
||||
const msg = 'new message'
|
||||
const wrapper = shallowMount(HelloWorld, {
|
||||
propsData: { msg },
|
||||
})
|
||||
expect(wrapper.text()).toMatch(msg)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user