v5.0.0 update

This commit is contained in:
Mohit Panjwani
2021-11-30 18:58:19 +05:30
parent d332712c22
commit 082d5cacf2
1253 changed files with 88309 additions and 71741 deletions

View File

@ -0,0 +1,5 @@
<template>
<router-view />
<BaseDialog />
</template>

View File

@ -0,0 +1,62 @@
import { createApp } from 'vue'
import App from '@/scripts/App.vue'
import { createI18n } from 'vue-i18n'
import messages from '@/scripts/locales/locales'
import router from '@/scripts/router'
import { defineGlobalComponents } from './global-components'
import utils from '@/scripts/helpers/utilities.js'
import _ from 'lodash'
import Maska from 'maska'
import { VTooltip } from 'v-tooltip'
const app = createApp(App)
export default class Crater {
constructor() {
this.bootingCallbacks = []
this.messages = messages
}
booting(callback) {
this.bootingCallbacks.push(callback)
}
executeCallbacks() {
this.bootingCallbacks.forEach((callback) => {
callback(app, router)
})
}
addMessages(moduleMessages = []) {
_.merge(this.messages, moduleMessages)
}
start() {
this.executeCallbacks()
defineGlobalComponents(app)
app.provide('$utils', utils)
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
globalInjection: true,
messages: this.messages,
})
window.i18n = i18n
const { createPinia } = window.pinia
app.use(router)
app.use(Maska)
app.use(i18n)
app.use(createPinia())
app.provide('utils', utils)
app.directive('tooltip', VTooltip)
app.mount('body')
}
}

View File

@ -0,0 +1,217 @@
<template>
<div ref="companySwitchBar" class="relative rounded">
<CompanyModal />
<div
class="
flex
items-center
justify-center
px-3
h-8
md:h-9
ml-2
text-sm text-white
bg-white
rounded
cursor-pointer
bg-opacity-20
"
@click="isShow = !isShow"
>
<span
v-if="companyStore.selectedCompany"
class="w-16 text-sm font-medium truncate sm:w-auto"
>
{{ companyStore.selectedCompany.name }}
</span>
<BaseIcon name="ChevronDownIcon" class="h-5 ml-1 text-white" />
</div>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isShow"
class="absolute right-0 mt-2 bg-white rounded-md shadow-lg"
>
<div
class="
overflow-y-auto
scrollbar-thin scrollbar-thumb-rounded-full
w-[250px]
max-h-[350px]
scrollbar-thumb-gray-300 scrollbar-track-gray-10
pb-4
"
>
<label
class="
px-3
py-2
text-xs
font-semibold
text-gray-400
mb-0.5
block
uppercase
"
>
{{ $t('company_switcher.label') }}
</label>
<div
v-if="companyStore.companies.length < 1"
class="
flex flex-col
items-center
justify-center
p-2
px-3
mt-4
text-base text-gray-400
"
>
<BaseIcon name="ExclamationCircleIcon" class="h-5 text-gray-400" />
{{ $t('company_switcher.no_results_found') }}
</div>
<div v-else>
<div v-if="companyStore.companies.length > 0">
<div
v-for="(company, index) in companyStore.companies"
:key="index"
class="
p-2
px-3
rounded-md
cursor-pointer
hover:bg-gray-100 hover:text-primary-500
"
:class="{
'bg-gray-100 text-primary-500':
companyStore.selectedCompany.id === company.id,
}"
@click="changeCompany(company)"
>
<div class="flex items-center">
<span
class="
flex
items-center
justify-center
mr-3
overflow-hidden
text-base
font-semibold
bg-gray-200
rounded-md
w-9
h-9
text-primary-500
"
>
<span v-if="!company.logo">
{{ initGenerator(company.name) }}
</span>
<img
v-else
:src="company.logo"
alt="Company logo"
class="w-full h-full object-contain"
/>
</span>
<div class="flex flex-col">
<span class="text-sm">{{ company.name }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-if="userStore.currentUser.is_owner"
class="
flex
items-center
justify-center
p-4
pl-3
border-t-2 border-gray-100
cursor-pointer
text-primary-400
hover:text-primary-500
"
@click="addNewCompany"
>
<BaseIcon name="PlusIcon" class="h-5 mr-2" />
<span class="font-medium">
{{ $t('company_switcher.add_new_company') }}
</span>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useCompanyStore } from '@/scripts/stores/company'
import { onClickOutside } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { useModalStore } from '../stores/modal'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '../stores/global'
import { useUserStore } from '@/scripts/stores/user'
import CompanyModal from '@/scripts/components/modal-components/CompanyModal.vue'
import abilities from '@/scripts/stub/abilities'
const companyStore = useCompanyStore()
const modalStore = useModalStore()
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const { t } = useI18n()
const userStore = useUserStore()
const isShow = ref(false)
const name = ref('')
const companySwitchBar = ref(null)
watch(route, () => {
isShow.value = false
name.value = ''
})
onClickOutside(companySwitchBar, () => {
isShow.value = false
})
function initGenerator(name) {
if (name) {
const nameSplit = name.split(' ')
const initials = nameSplit[0].charAt(0).toUpperCase()
return initials
}
}
function addNewCompany() {
modalStore.openModal({
title: t('company_switcher.new_company'),
componentName: 'CompanyModal',
size: 'sm',
})
}
async function changeCompany(company) {
await companyStore.setSelectedCompany(company)
router.push('/admin/dashboard')
await globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
</script>

View File

@ -0,0 +1,207 @@
<template>
<div ref="searchBar" class="hidden rounded md:block relative">
<div>
<BaseInput
v-model="name"
placeholder="Search..."
container-class="!rounded"
class="h-8 md:h-9 !rounded"
@input="onSearch"
>
<template #left>
<BaseIcon name="SearchIcon" class="text-gray-400" />
</template>
<template #right>
<SpinnerIcon v-if="isSearching" class="h-5 text-primary-500" />
</template>
</BaseInput>
</div>
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isShow"
class="
scrollbar-thin
scrollbar-thumb-rounded-full
scrollbar-thumb-gray-300
scrollbar-track-gray-100
overflow-y-auto
bg-white
rounded-md
mt-2
shadow-lg
p-3
absolute
w-[300px]
h-[200px]
right-0
"
>
<div
v-if="
usersStore.userList.length < 1 && usersStore.customerList.length < 1
"
class="
flex
items-center
justify-center
text-gray-400 text-base
flex-col
mt-4
"
>
<BaseIcon name="ExclamationCircleIcon" class="text-gray-400" />
{{ $t('global_search.no_results_found') }}
</div>
<div v-else>
<div v-if="usersStore.customerList.length > 0">
<label class="text-sm text-gray-400 mb-0.5 block px-2 uppercase">
{{ $t('global_search.customers') }}
</label>
<div
v-for="(customer, index) in usersStore.customerList"
:key="index"
class="p-2 hover:bg-gray-100 cursor-pointer rounded-md"
>
<router-link
:to="{ path: `/admin/customers/${customer.id}/view` }"
class="flex items-center"
>
<span
class="
flex
items-center
justify-center
w-9
h-9
mr-3
text-base
font-semibold
bg-gray-200
rounded-full
text-primary-500
"
>
{{ initGenerator(customer.name) }}
</span>
<div class="flex flex-col">
<span class="text-sm">{{ customer.name }}</span>
<span
v-if="customer.contact_name"
class="text-xs text-gray-400"
>
{{ customer.contact_name }}
</span>
<span v-else class="text-xs text-gray-400">{{
customer.email
}}</span>
</div>
</router-link>
</div>
</div>
<div v-if="usersStore.userList.length > 0" class="mt-2">
<label
class="text-sm text-gray-400 mb-2 block px-2 mb-0.5 uppercase"
>
{{ $t('global_search.users') }}
</label>
<div
v-for="(user, index) in usersStore.userList"
:key="index"
class="p-2 hover:bg-gray-100 cursor-pointer rounded-md"
>
<router-link
:to="{ path: `users/${user.id}/view` }"
class="flex items-center"
>
<span
class="
flex
items-center
justify-center
w-9
h-9
mr-3
text-base
font-semibold
bg-gray-200
rounded-full
text-primary-500
"
>
{{ initGenerator(user.name) }}
</span>
<div class="flex flex-col">
<span class="text-sm">{{ user.name }}</span>
<span class="text-xs text-gray-400">{{ user.email }}</span>
</div>
</router-link>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useUsersStore } from '@/scripts/stores/users'
import { onClickOutside } from '@vueuse/core'
import { useRoute } from 'vue-router'
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
import { debounce } from 'lodash'
const usersStore = useUsersStore()
const isShow = ref(false)
const name = ref('')
const searchBar = ref(null)
const isSearching = ref(false)
const route = useRoute()
watch(route, () => {
isShow.value = false
name.value = ''
})
onSearch = debounce(onSearch, 500)
onClickOutside(searchBar, () => {
isShow.value = false
name.value = ''
})
function onSearch() {
let data = {
search: name.value,
}
if (name.value) {
isSearching.value = true
usersStore.searchUsers(data).then(() => {
isShow.value = true
})
isSearching.value = false
}
if (name.value === '') {
isShow.value = false
}
}
function initGenerator(name) {
if (name) {
const nameSplit = name.split(' ')
const initials = nameSplit[0].charAt(0).toUpperCase()
return initials
}
}
</script>

View File

@ -0,0 +1,101 @@
<template>
<svg
width="110"
height="110"
viewBox="0 0 110 110"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.76398 22.9512L4.54883 21.7361L21.7363 4.54858L22.9515 5.76374L5.76398 22.9512Z"
fill="#55547A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M88.264 105.451L87.0488 104.236L104.236 87.0486L105.451 88.2637L88.264 105.451Z"
fill="#55547A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M29.8265 81.3887L28.6113 80.1736L38.9238 69.8611L40.139 71.0762L29.8265 81.3887Z"
fill="#817AE3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M30.9375 81.6406C30.9375 83.0637 29.7825 84.2188 28.3594 84.2188C26.9362 84.2188 25.7812 83.0637 25.7812 81.6406C25.7812 80.2175 26.9362 79.0625 28.3594 79.0625C29.7825 79.0625 30.9375 80.2175 30.9375 81.6406Z"
fill="#817AE3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M77.3435 61.5801C76.4635 61.5801 75.5835 61.9152 74.9132 62.5873L62.5863 74.9124C61.244 76.2548 61.244 78.4324 62.5863 79.7748L92.8123 110.001L110 92.8132L79.7738 62.5873C79.1035 61.9152 78.2235 61.5801 77.3435 61.5801ZM77.3435 63.2988C77.8024 63.2988 78.2338 63.4776 78.5587 63.8024L107.569 92.8132L92.8123 107.569L63.8015 78.5596C63.4767 78.2348 63.2979 77.8034 63.2979 77.3445C63.2979 76.8838 63.4767 76.4524 63.8015 76.1276L76.1284 63.8024C76.4532 63.4776 76.8846 63.2988 77.3435 63.2988Z"
fill="#55547A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.1875 0L0 17.1875L30.2259 47.4134C30.8963 48.0838 31.7763 48.4206 32.6562 48.4206C33.5363 48.4206 34.4162 48.0838 35.0866 47.4134L47.4134 35.0866C48.7558 33.7442 48.7558 31.5683 47.4134 30.2259L17.1875 0ZM17.1875 2.43031L46.1983 31.4411C46.5231 31.7659 46.7019 32.1973 46.7019 32.6562C46.7019 33.1152 46.5231 33.5466 46.1983 33.8714L33.8714 46.1983C33.5466 46.5231 33.1152 46.7019 32.6562 46.7019C32.1973 46.7019 31.7659 46.5231 31.4411 46.1983L2.43031 17.1875L17.1875 2.43031Z"
fill="#55547A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M60.156 28.9238C59.276 28.9238 58.396 29.259 57.7257 29.931L29.9301 57.7249C28.5878 59.0673 28.5878 61.2449 29.9301 62.5873L47.4132 80.0687C48.0835 80.7407 48.9635 81.0759 49.8435 81.0759C50.7235 81.0759 51.6035 80.7407 52.2738 80.0687L80.0695 52.2748C81.4118 50.9324 81.4118 48.7548 80.0695 47.4124L62.5863 29.931C61.916 29.259 61.036 28.9238 60.156 28.9238ZM60.156 30.6426C60.6149 30.6426 61.0463 30.8213 61.3712 31.1462L78.8543 48.6276C79.1792 48.9524 79.3579 49.3838 79.3579 49.8445C79.3579 50.3034 79.1792 50.7348 78.8543 51.0596L51.0587 78.8535C50.7338 79.1784 50.3024 79.3571 49.8435 79.3571C49.3846 79.3571 48.9532 79.1784 48.6284 78.8535L31.1453 61.3721C30.8204 61.0473 30.6417 60.6159 30.6417 60.157C30.6417 59.6963 30.8204 59.2649 31.1453 58.9401L58.9409 31.1462C59.2657 30.8213 59.6971 30.6426 60.156 30.6426Z"
fill="#55547A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M71.0765 40.1387L69.8613 38.9236L72.4395 36.3455L73.6546 37.5606L71.0765 40.1387Z"
fill="#55547A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M72.9858 24.8608C69.6291 28.2176 69.6291 33.6574 72.9858 37.0141C74.6633 38.6916 76.8633 39.5321 79.0633 39.5321C81.2616 39.5321 83.4616 38.6916 85.1391 37.0141L72.9858 24.8608ZM73.1388 27.4441L82.5558 36.8612C81.5091 37.4816 80.3111 37.8133 79.0633 37.8133C77.226 37.8133 75.5003 37.0966 74.201 35.799C72.9033 34.4996 72.1883 32.774 72.1883 30.9383C72.1883 29.6888 72.5183 28.4908 73.1388 27.4441Z"
fill="#55547A"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M86.1459 32.0051C85.9259 32.0051 85.7059 31.9209 85.5374 31.7542C85.2023 31.4173 85.2023 30.8742 85.5374 30.5373C86.3504 29.7261 86.7973 28.6467 86.7973 27.5003C86.7973 26.3522 86.3504 25.2728 85.5374 24.4615C83.9149 22.839 81.0859 22.839 79.4616 24.4615C79.1265 24.7984 78.5834 24.7984 78.2465 24.4615C77.9113 24.1264 77.9113 23.5833 78.2465 23.2464C80.5187 20.9742 84.4821 20.9742 86.7543 23.2464C87.8904 24.3825 88.516 25.8933 88.516 27.5003C88.516 29.1073 87.8904 30.6181 86.7543 31.7542C86.5859 31.9209 86.3659 32.0051 86.1459 32.0051Z"
fill="#817AE3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M89.792 35.6514C89.572 35.6514 89.352 35.5672 89.1836 35.4004C88.8484 35.0636 88.8484 34.5204 89.1836 34.1836C90.9711 32.3978 91.9525 30.0259 91.9525 27.4994C91.9525 24.9745 90.9711 22.6009 89.1836 20.8151C87.3978 19.0294 85.0259 18.0462 82.4994 18.0462C79.9745 18.0462 77.6009 19.0294 75.8152 20.8151C75.48 21.1503 74.9352 21.1503 74.6 20.8151C74.2648 20.48 74.2648 19.9351 74.6 19.6C78.9553 15.2447 86.0434 15.2447 90.4005 19.6C94.7558 23.9553 94.7558 31.0434 90.4005 35.4004C90.232 35.5672 90.012 35.6514 89.792 35.6514Z"
fill="#817AE3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M93.4379 39.297C93.2179 39.297 92.9979 39.2128 92.8295 39.0461C92.4944 38.7092 92.4944 38.1661 92.8295 37.8292C95.5898 35.0706 97.1092 31.4028 97.1092 27.4995C97.1092 23.5979 95.5898 19.9284 92.8295 17.1698C90.0709 14.4112 86.4031 12.8901 82.4998 12.8901C78.5983 12.8901 74.9287 14.4112 72.1701 17.1698C71.835 17.505 71.2901 17.505 70.955 17.1698C70.6198 16.8347 70.6198 16.2898 70.955 15.9547C74.0384 12.8712 78.1394 11.1714 82.4998 11.1714C86.862 11.1714 90.9612 12.8712 94.0464 15.9547C97.1298 19.0381 98.8279 23.139 98.8279 27.4995C98.8279 31.8617 97.1298 35.9609 94.0464 39.0461C93.8779 39.2128 93.6579 39.297 93.4379 39.297Z"
fill="#817AE3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M39.7832 40.9981L8.8457 10.0606L10.0609 8.84546L40.9984 39.783L39.7832 40.9981Z"
fill="#817AE3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M99.9395 101.154L69.002 70.2169L70.2171 69.0017L101.155 99.9392L99.9395 101.154Z"
fill="#817AE3"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="110" height="110" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@ -0,0 +1,210 @@
<template>
<NoteModal />
<div class="w-full">
<Popover v-slot="{ isOpen }">
<PopoverButton
v-if="userStore.hasAbilities(abilities.VIEW_NOTE)"
:class="isOpen ? '' : 'text-opacity-90'"
class="
flex
items-center
z-10
font-medium
text-primary-400
focus:outline-none focus:border-none
"
@click="fetchInitialData"
>
<BaseIcon
name="PlusIcon"
class="w-4 h-4 font-medium text-primary-400"
/>
{{ $t('general.insert_note') }}
</PopoverButton>
<!-- Note Select Popup -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<PopoverPanel
v-slot="{ close }"
class="
absolute
z-20
px-4
mt-3
transform
sm:px-0
w-screen
max-w-full
left-0
top-3
"
>
<div
class="
overflow-hidden
rounded-md
shadow-lg
ring-1 ring-black ring-opacity-5
"
>
<div class="relative grid bg-white">
<div class="relative p-4">
<BaseInput
v-model="textSearch"
:placeholder="$t('general.search')"
type="text"
class="text-black"
>
</BaseInput>
</div>
<div
v-if="filteredNotes.length > 0"
class="relative flex flex-col overflow-auto list max-h-36"
>
<div
v-for="(note, index) in filteredNotes"
:key="index"
tabindex="2"
class="
px-6
py-4
border-b border-gray-200 border-solid
cursor-pointer
hover:bg-gray-100 hover:cursor-pointer
last:border-b-0
"
@click="selectNote(index, close)"
>
<div class="flex justify-between px-2">
<label
class="
m-0
text-base
font-semibold
leading-tight
text-gray-700
cursor-pointer
"
>
{{ note.name }}
</label>
</div>
</div>
</div>
<div v-else class="flex justify-center p-5 text-gray-400">
<label class="text-base text-gray-500">
{{ $t('general.no_note_found') }}
</label>
</div>
</div>
<button
v-if="userStore.hasAbilities(abilities.MANAGE_NOTE)"
type="button"
class="
h-10
flex
items-center
justify-center
w-full
px-2
py-3
bg-gray-200
border-none
outline-none
"
@click="openNoteModal"
>
<BaseIcon name="CheckCircleIcon" class="text-primary-400" />
<label
class="
m-0
ml-3
text-sm
leading-none
cursor-pointer
font-base
text-primary-400
"
>
{{ $t('settings.customization.notes.add_new_note') }}
</label>
</button>
</div>
</PopoverPanel>
</transition>
</Popover>
</div>
</template>
<script setup>
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { computed, ref, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNotesStore } from '@/scripts/stores/note'
import { useModalStore } from '@/scripts/stores/modal'
import NoteModal from '@/scripts/components/modal-components/NoteModal.vue'
import { useUserStore } from '@/scripts/stores/user'
import abilities from '@/scripts/stub/abilities'
const props = defineProps({
type: {
type: String,
default: null,
},
})
const emit = defineEmits(['select'])
const table = ref(null)
const { t } = useI18n()
const textSearch = ref(null)
const modalStore = useModalStore()
const noteStore = useNotesStore()
const userStore = useUserStore()
const filteredNotes = computed(() => {
if (textSearch.value) {
return noteStore.notes.filter(function (el) {
return (
el.name.toLowerCase().indexOf(textSearch.value.toLowerCase()) !== -1
)
})
} else {
return noteStore.notes
}
})
async function fetchInitialData() {
await noteStore.fetchNotes({
filter: {},
orderByField: '',
orderBy: '',
type: props.type ? props.type : '',
})
}
function selectNote(data, close) {
emit('select', { ...noteStore.notes[data] })
textSearch.value = null
close()
}
function openNoteModal() {
modalStore.openModal({
title: t('settings.customization.notes.add_note'),
componentName: 'NoteModal',
size: 'lg',
data: props.type,
})
}
</script>

View File

@ -0,0 +1,75 @@
import Vue, { VNode } from 'vue';
declare class BaseMultiselect extends Vue {
modelValue?: any;
value?: any;
mode: 'single' | 'multiple' | 'tags';
options?: any[];
searchable?: boolean;
valueProp?: string;
trackBy?: string;
label?: string;
placeholder?: string | null;
multipleLabel?: any; // Function
disabled?: boolean;
max?: number;
limit?: number;
loading?: boolean;
id?: string;
caret?: boolean;
maxHeight?: string | number;
noOptionsText?: string;
noResultsText?: string;
canDeselect?: boolean;
canClear?: boolean;
clearOnSearch?: boolean;
clearOnSelect?: boolean;
delay?: number;
filterResults?: boolean;
minChars?: number;
resolveOnLoad?: boolean;
appendNewTag?: boolean;
createTag?: boolean;
addTagOn?: string[];
hideSelected?: boolean;
showOptions?: boolean;
object?: boolean;
required?: boolean;
openDirection?: 'top' | 'bottom';
nativeSupport?: boolean;
classes?: object;
strict?: boolean;
closeOnSelect?: boolean;
autocomplete?: string;
groups: boolean;
groupLabel: string;
groupOptions: string;
groupHideEmpty: boolean;
groupSelect: boolean;
inputType: string;
$emit(eventName: 'change', e: { originalEvent: Event, value: any }): this;
$emit(eventName: 'select', e: { originalEvent: Event, value: any, option: any }): this;
$emit(eventName: 'deselect', e: { originalEvent: Event, value: any, option: any }): this;
$emit(eventName: 'remove', e: { originalEvent: Event, value: any, option: any }): this;
$emit(eventName: 'search-change', e: { originalEvent: Event, query: string }): this;
$emit(eventName: 'tag', e: { originalEvent: Event, query: string }): this;
$emit(eventName: 'paste', e: { originalEvent: Event }): this;
$emit(eventName: 'open'): this;
$emit(eventName: 'close'): this;
$emit(eventName: 'clear'): this;
$slots: {
placeholder: VNode[];
afterlist: VNode[];
beforelist: VNode[];
list: VNode[];
multiplelabel: VNode[];
singlelabel: VNode[];
option: VNode[];
groupLabel: VNode[];
tag: VNode[];
};
}
export default BaseMultiselect;

View File

@ -0,0 +1,646 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
class="w-full"
style="height: 40px"
/>
</BaseContentPlaceholders>
<div
v-else
:id="id"
ref="multiselect"
:tabindex="tabindex"
:class="classList.container"
@focusin="activate"
@focusout="deactivate"
@keydown="handleKeydown"
@focus="handleFocus"
>
<!-- Search -->
<template v-if="mode !== 'tags' && searchable && !disabled">
<input
ref="input"
:type="inputType"
:modelValue="search"
:value="search"
:class="classList.search"
:autocomplete="autocomplete"
@input="handleSearchInput"
@paste.stop="handlePaste"
/>
</template>
<!-- Tags (with search) -->
<template v-if="mode == 'tags'">
<div :class="classList.tags">
<slot
v-for="(option, i, key) in iv"
name="tag"
:option="option"
:handleTagRemove="handleTagRemove"
:disabled="disabled"
>
<span :key="key" :class="classList.tag">
{{ option[label] }}
<span
v-if="!disabled"
:class="classList.tagRemove"
@mousedown.stop="handleTagRemove(option, $event)"
>
<span :class="classList.tagRemoveIcon"></span>
</span>
</span>
</slot>
<div :class="classList.tagsSearchWrapper">
<!-- Used for measuring search width -->
<span :class="classList.tagsSearchCopy">{{ search }}</span>
<!-- Actual search input -->
<input
v-if="searchable && !disabled"
ref="input"
:type="inputType"
:modelValue="search"
:value="search"
:class="classList.tagsSearch"
:autocomplete="autocomplete"
style="box-shadow: none !important"
@input="handleSearchInput"
@paste.stop="handlePaste"
/>
</div>
</div>
</template>
<!-- Single label -->
<template v-if="mode == 'single' && hasSelected && !search && iv">
<slot name="singlelabel" :value="iv">
<div :class="classList.singleLabel">
{{ iv[label] }}
</div>
</slot>
</template>
<!-- Multiple label -->
<template v-if="mode == 'multiple' && hasSelected && !search">
<slot name="multiplelabel" :values="iv">
<div :class="classList.multipleLabel">
{{ multipleLabelText }}
</div>
</slot>
</template>
<!-- Placeholder -->
<template v-if="placeholder && !hasSelected && !search">
<slot name="placeholder">
<div :class="classList.placeholder">
{{ placeholder }}
</div>
</slot>
</template>
<!-- Spinner -->
<slot v-if="busy" name="spinner">
<span :class="classList.spinner"></span>
</slot>
<!-- Clear -->
<slot
v-if="hasSelected && !disabled && canClear && !busy"
name="clear"
:clear="clear"
>
<span :class="classList.clear" @mousedown="clear"
><span :class="classList.clearIcon"></span
></span>
</slot>
<!-- Caret -->
<slot v-if="caret" name="caret">
<span
:class="classList.caret"
@mousedown.prevent.stop="handleCaretClick"
></span>
</slot>
<!-- Options -->
<div :class="classList.dropdown" tabindex="-1">
<div class="w-full overflow-y-auto">
<slot name="beforelist" :options="fo"></slot>
<ul :class="classList.options">
<template v-if="groups">
<li
v-for="(group, i, key) in fg"
:key="key"
:class="classList.group"
>
<div
:class="classList.groupLabel(group)"
:data-pointed="isPointed(group)"
@mouseenter="setPointer(group)"
@click="handleGroupClick(group)"
>
<slot name="grouplabel" :group="group">
<span>{{ group[groupLabel] }}</span>
</slot>
</div>
<ul :class="classList.groupOptions">
<li
v-for="(option, i, key) in group.__VISIBLE__"
:key="key"
:class="classList.option(option, group)"
:data-pointed="isPointed(option)"
@mouseenter="setPointer(option)"
@click="handleOptionClick(option)"
>
<slot name="option" :option="option" :search="search">
<span>{{ option[label] }}</span>
</slot>
</li>
</ul>
</li>
</template>
<template v-else>
<li
v-for="(option, i, key) in fo"
:key="key"
:class="classList.option(option)"
:data-pointed="isPointed(option)"
@mouseenter="setPointer(option)"
@click="handleOptionClick(option)"
>
<slot name="option" :option="option" :search="search">
<span>{{ option[label] }}</span>
</slot>
</li>
</template>
</ul>
<slot v-if="noOptions" name="nooptions">
<div :class="classList.noOptions" v-html="noOptionsText"></div>
</slot>
<slot v-if="noResults" name="noresults">
<div :class="classList.noResults" v-html="noResultsText"></div>
</slot>
<slot name="afterlist" :options="fo"> </slot>
</div>
<slot name="action"></slot>
</div>
<!-- Hacky input element to show HTML5 required warning -->
<input
v-if="required"
:class="classList.fakeInput"
tabindex="-1"
:value="textValue"
required
/>
<!-- Native input support -->
<template v-if="nativeSupport">
<input
v-if="mode == 'single'"
type="hidden"
:name="name"
:value="plainValue !== undefined ? plainValue : ''"
/>
<template v-else>
<input
v-for="(v, i) in plainValue"
:key="i"
type="hidden"
:name="`${name}[]`"
:value="v"
/>
</template>
</template>
<!-- Create height for empty input -->
<div :class="classList.spacer"></div>
</div>
</template>
<script>
import useData from './composables/useData'
import useValue from './composables/useValue'
import useSearch from './composables/useSearch'
import usePointer from './composables/usePointer'
import useOptions from './composables/useOptions'
import usePointerAction from './composables/usePointerAction'
import useDropdown from './composables/useDropdown'
import useMultiselect from './composables/useMultiselect'
import useKeyboard from './composables/useKeyboard'
import useClasses from './composables/useClasses'
export default {
name: 'BaseMultiselect',
props: {
preserveSearch: {
type: Boolean,
default: false,
},
initialSearch: {
type: String,
default: null,
},
contentLoading: {
type: Boolean,
default: false,
},
value: {
required: false,
},
modelValue: {
required: false,
},
options: {
type: [Array, Object, Function],
required: false,
default: () => [],
},
id: {
type: [String, Number],
required: false,
},
name: {
type: [String, Number],
required: false,
default: 'multiselect',
},
disabled: {
type: Boolean,
required: false,
default: false,
},
label: {
type: String,
required: false,
default: 'label',
},
trackBy: {
type: String,
required: false,
default: 'label',
},
valueProp: {
type: String,
required: false,
default: 'value',
},
placeholder: {
type: String,
required: false,
default: null,
},
mode: {
type: String,
required: false,
default: 'single', // single|multiple|tags
},
searchable: {
type: Boolean,
required: false,
default: false,
},
limit: {
type: Number,
required: false,
default: -1,
},
hideSelected: {
type: Boolean,
required: false,
default: true,
},
createTag: {
type: Boolean,
required: false,
default: false,
},
appendNewTag: {
type: Boolean,
required: false,
default: true,
},
caret: {
type: Boolean,
required: false,
default: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
noOptionsText: {
type: String,
required: false,
default: 'The list is empty',
},
noResultsText: {
type: String,
required: false,
default: 'No results found',
},
multipleLabel: {
type: Function,
required: false,
},
object: {
type: Boolean,
required: false,
default: false,
},
delay: {
type: Number,
required: false,
default: -1,
},
minChars: {
type: Number,
required: false,
default: 0,
},
resolveOnLoad: {
type: Boolean,
required: false,
default: true,
},
filterResults: {
type: Boolean,
required: false,
default: true,
},
clearOnSearch: {
type: Boolean,
required: false,
default: false,
},
clearOnSelect: {
type: Boolean,
required: false,
default: true,
},
canDeselect: {
type: Boolean,
required: false,
default: true,
},
canClear: {
type: Boolean,
required: false,
default: false,
},
max: {
type: Number,
required: false,
default: -1,
},
showOptions: {
type: Boolean,
required: false,
default: true,
},
addTagOn: {
type: Array,
required: false,
default: () => ['enter'],
},
required: {
type: Boolean,
required: false,
default: false,
},
openDirection: {
type: String,
required: false,
default: 'bottom',
},
nativeSupport: {
type: Boolean,
required: false,
default: false,
},
invalid: {
type: Boolean,
required: false,
default: false,
},
classes: {
type: Object,
required: false,
default: () => ({
container:
'p-0 relative mx-auto w-full flex items-center justify-end box-border cursor-pointer border border-gray-200 rounded-md bg-white text-sm leading-snug outline-none max-h-10',
containerDisabled:
'cursor-default bg-gray-200 bg-opacity-50 !text-gray-400',
containerOpen: '',
containerOpenTop: '',
containerActive: 'ring-1 ring-primary-400 border-primary-400',
containerInvalid:
'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400',
containerInvalidActive: 'ring-1 border-red-400 ring-red-400',
singleLabel:
'flex items-center h-full absolute left-0 top-0 pointer-events-none bg-transparent leading-snug pl-3.5',
multipleLabel:
'flex items-center h-full absolute left-0 top-0 pointer-events-none bg-transparent leading-snug pl-3.5',
search:
'w-full absolute inset-0 outline-none appearance-none box-border border-0 text-sm font-sans bg-white rounded-md pl-3.5',
tags: 'flex-grow flex-shrink flex flex-wrap mt-1 pl-2',
tag: 'bg-primary-500 text-white text-sm font-semibold py-0.5 pl-2 rounded mr-1 mb-1 flex items-center whitespace-nowrap',
tagDisabled: 'pr-2 !bg-gray-400 text-white',
tagRemove:
'flex items-center justify-center p-1 mx-0.5 rounded-sm hover:bg-black hover:bg-opacity-10 group',
tagRemoveIcon:
'bg-multiselect-remove text-white bg-center bg-no-repeat opacity-30 inline-block w-3 h-3 group-hover:opacity-60',
tagsSearchWrapper:
'inline-block relative mx-1 mb-1 flex-grow flex-shrink h-full',
tagsSearch:
'absolute inset-0 border-0 focus:outline-none !shadow-none !focus:shadow-none appearance-none p-0 text-sm font-sans box-border w-full',
tagsSearchCopy: 'invisible whitespace-pre-wrap inline-block h-px',
placeholder:
'flex items-center h-full absolute left-0 top-0 pointer-events-none bg-transparent leading-snug pl-3.5 text-gray-400 text-sm',
caret:
'bg-multiselect-caret bg-center bg-no-repeat w-5 h-5 py-px box-content z-5 relative mr-1 opacity-40 flex-shrink-0 flex-grow-0 transition-transform transform',
caretOpen: 'rotate-180 pointer-events-auto',
clear:
'pr-3.5 relative z-10 opacity-40 transition duration-300 flex-shrink-0 flex-grow-0 flex hover:opacity-80',
clearIcon:
'bg-multiselect-remove bg-center bg-no-repeat w-2.5 h-4 py-px box-content inline-block',
spinner:
'bg-multiselect-spinner bg-center bg-no-repeat w-4 h-4 z-10 mr-3.5 animate-spin flex-shrink-0 flex-grow-0',
dropdown:
'max-h-60 shadow-lg absolute -left-px -right-px -bottom-1 transform translate-y-full border border-gray-300 mt-1 overflow-y-auto z-50 bg-white flex flex-col rounded-md',
dropdownTop:
'-translate-y-full -top-2 bottom-auto flex-col-reverse rounded-md',
dropdownHidden: 'hidden',
options: 'flex flex-col p-0 m-0 list-none',
optionsTop: 'flex-col-reverse',
group: 'p-0 m-0',
groupLabel:
'flex text-sm box-border items-center justify-start text-left py-1 px-3 font-semibold bg-gray-200 cursor-default leading-normal',
groupLabelPointable: 'cursor-pointer',
groupLabelPointed: 'bg-gray-300 text-gray-700',
groupLabelSelected: 'bg-primary-600 text-white',
groupLabelDisabled: 'bg-gray-100 text-gray-300 cursor-not-allowed',
groupLabelSelectedPointed: 'bg-primary-600 text-white opacity-90',
groupLabelSelectedDisabled:
'text-primary-100 bg-primary-600 bg-opacity-50 cursor-not-allowed',
groupOptions: 'p-0 m-0',
option:
'flex items-center justify-start box-border text-left cursor-pointer text-sm leading-snug py-2 px-3',
optionPointed: 'text-gray-800 bg-gray-100',
optionSelected: 'text-white bg-primary-500',
optionDisabled: 'text-gray-300 cursor-not-allowed',
optionSelectedPointed: 'text-white bg-primary-500 opacity-90',
optionSelectedDisabled:
'text-primary-100 bg-primary-500 bg-opacity-50 cursor-not-allowed',
noOptions: 'py-2 px-3 text-gray-600 bg-white',
noResults: 'py-2 px-3 text-gray-600 bg-white',
fakeInput:
'bg-transparent absolute left-0 right-0 -bottom-px w-full h-px border-0 p-0 appearance-none outline-none text-transparent',
spacer: 'h-9 py-px box-content',
}),
},
strict: {
type: Boolean,
required: false,
default: true,
},
closeOnSelect: {
type: Boolean,
required: false,
default: true,
},
autocomplete: {
type: String,
required: false,
},
groups: {
type: Boolean,
required: false,
default: false,
},
groupLabel: {
type: String,
required: false,
default: 'label',
},
groupOptions: {
type: String,
required: false,
default: 'options',
},
groupHideEmpty: {
type: Boolean,
required: false,
default: false,
},
groupSelect: {
type: Boolean,
required: false,
default: true,
},
inputType: {
type: String,
required: false,
default: 'text',
},
},
emits: [
'open',
'close',
'select',
'deselect',
'input',
'search-change',
'tag',
'update:modelValue',
'change',
'clear',
],
setup(props, context) {
const value = useValue(props, context)
const pointer = usePointer(props, context)
const dropdown = useDropdown(props, context)
const search = useSearch(props, context)
const data = useData(props, context, {
iv: value.iv,
})
const multiselect = useMultiselect(props, context, {
input: search.input,
open: dropdown.open,
close: dropdown.close,
clearSearch: search.clearSearch,
})
const options = useOptions(props, context, {
ev: value.ev,
iv: value.iv,
search: search.search,
clearSearch: search.clearSearch,
update: data.update,
pointer: pointer.pointer,
clearPointer: pointer.clearPointer,
blur: multiselect.blur,
deactivate: multiselect.deactivate,
})
const pointerAction = usePointerAction(props, context, {
fo: options.fo,
fg: options.fg,
handleOptionClick: options.handleOptionClick,
handleGroupClick: options.handleGroupClick,
search: search.search,
pointer: pointer.pointer,
setPointer: pointer.setPointer,
clearPointer: pointer.clearPointer,
multiselect: multiselect.multiselect,
})
const keyboard = useKeyboard(props, context, {
iv: value.iv,
update: data.update,
search: search.search,
setPointer: pointer.setPointer,
selectPointer: pointerAction.selectPointer,
backwardPointer: pointerAction.backwardPointer,
forwardPointer: pointerAction.forwardPointer,
blur: multiselect.blur,
fo: options.fo,
})
const classes = useClasses(props, context, {
isOpen: dropdown.isOpen,
isPointed: pointerAction.isPointed,
canPointGroups: pointerAction.canPointGroups,
isSelected: options.isSelected,
isDisabled: options.isDisabled,
isActive: multiselect.isActive,
resolving: options.resolving,
fo: options.fo,
})
return {
...value,
...dropdown,
...multiselect,
...pointer,
...data,
...search,
...options,
...pointerAction,
...keyboard,
...classes,
}
},
}
</script>

View File

@ -0,0 +1,181 @@
import { computed, toRefs } from 'vue'
export default function useClasses(props, context, dependencies) {
const refs = toRefs(props)
const { disabled, openDirection, showOptions, invalid } = refs
// ============ DEPENDENCIES ============
const isOpen = dependencies.isOpen
const isPointed = dependencies.isPointed
const isSelected = dependencies.isSelected
const isDisabled = dependencies.isDisabled
const isActive = dependencies.isActive
const canPointGroups = dependencies.canPointGroups
const resolving = dependencies.resolving
const fo = dependencies.fo
const isInvalid = invalid
const classes = {
container: 'multiselect',
containerDisabled: 'is-disabled',
containerOpen: 'is-open',
containerOpenTop: 'is-open-top',
containerActive: 'is-active',
containerInvalid: 'is-invalid',
containerInvalidActive: 'is-invalid-active',
singleLabel: 'multiselect-single-label',
multipleLabel: 'multiselect-multiple-label',
search: 'multiselect-search',
tags: 'multiselect-tags',
tag: 'multiselect-tag',
tagDisabled: 'is-disabled',
tagRemove: 'multiselect-tag-remove',
tagRemoveIcon: 'multiselect-tag-remove-icon',
tagsSearchWrapper: 'multiselect-tags-search-wrapper',
tagsSearch: 'multiselect-tags-search',
tagsSearchCopy: 'multiselect-tags-search-copy',
placeholder: 'multiselect-placeholder',
caret: 'multiselect-caret',
caretOpen: 'is-open',
clear: 'multiselect-clear',
clearIcon: 'multiselect-clear-icon',
spinner: 'multiselect-spinner',
dropdown: 'multiselect-dropdown',
dropdownTop: 'is-top',
dropdownHidden: 'is-hidden',
options: 'multiselect-options',
optionsTop: 'is-top',
group: 'multiselect-group',
groupLabel: 'multiselect-group-label',
groupLabelPointable: 'is-pointable',
groupLabelPointed: 'is-pointed',
groupLabelSelected: 'is-selected',
groupLabelDisabled: 'is-disabled',
groupLabelSelectedPointed: 'is-selected is-pointed',
groupLabelSelectedDisabled: 'is-selected is-disabled',
groupOptions: 'multiselect-group-options',
option: 'multiselect-option',
optionPointed: 'is-pointed',
optionSelected: 'is-selected',
optionDisabled: 'is-disabled',
optionSelectedPointed: 'is-selected is-pointed',
optionSelectedDisabled: 'is-selected is-disabled',
noOptions: 'multiselect-no-options',
noResults: 'multiselect-no-results',
fakeInput: 'multiselect-fake-input',
spacer: 'multiselect-spacer',
...refs.classes.value,
}
// ============== COMPUTED ==============
const showDropdown = computed(() => {
return !!(
isOpen.value &&
showOptions.value &&
(!resolving.value || (resolving.value && fo.value.length))
)
})
const classList = computed(() => {
return {
container: [classes.container]
.concat(disabled.value ? classes.containerDisabled : [])
.concat(
showDropdown.value && openDirection.value === 'top'
? classes.containerOpenTop
: []
)
.concat(
showDropdown.value && openDirection.value !== 'top'
? classes.containerOpen
: []
)
.concat(isActive.value ? classes.containerActive : [])
.concat(invalid.value ? classes.containerInvalid : []),
spacer: classes.spacer,
singleLabel: classes.singleLabel,
multipleLabel: classes.multipleLabel,
search: classes.search,
tags: classes.tags,
tag: [classes.tag].concat(disabled.value ? classes.tagDisabled : []),
tagRemove: classes.tagRemove,
tagRemoveIcon: classes.tagRemoveIcon,
tagsSearchWrapper: classes.tagsSearchWrapper,
tagsSearch: classes.tagsSearch,
tagsSearchCopy: classes.tagsSearchCopy,
placeholder: classes.placeholder,
caret: [classes.caret].concat(isOpen.value ? classes.caretOpen : []),
clear: classes.clear,
clearIcon: classes.clearIcon,
spinner: classes.spinner,
dropdown: [classes.dropdown]
.concat(openDirection.value === 'top' ? classes.dropdownTop : [])
.concat(
!isOpen.value || !showOptions.value || !showDropdown.value
? classes.dropdownHidden
: []
),
options: [classes.options].concat(
openDirection.value === 'top' ? classes.optionsTop : []
),
group: classes.group,
groupLabel: (g) => {
let groupLabel = [classes.groupLabel]
if (isPointed(g)) {
groupLabel.push(
isSelected(g)
? classes.groupLabelSelectedPointed
: classes.groupLabelPointed
)
} else if (isSelected(g) && canPointGroups.value) {
groupLabel.push(
isDisabled(g)
? classes.groupLabelSelectedDisabled
: classes.groupLabelSelected
)
} else if (isDisabled(g)) {
groupLabel.push(classes.groupLabelDisabled)
}
if (canPointGroups.value) {
groupLabel.push(classes.groupLabelPointable)
}
return groupLabel
},
groupOptions: classes.groupOptions,
option: (o, g) => {
let option = [classes.option]
if (isPointed(o)) {
option.push(
isSelected(o)
? classes.optionSelectedPointed
: classes.optionPointed
)
} else if (isSelected(o)) {
option.push(
isDisabled(o)
? classes.optionSelectedDisabled
: classes.optionSelected
)
} else if (isDisabled(o) || (g && isDisabled(g))) {
option.push(classes.optionDisabled)
}
return option
},
noOptions: classes.noOptions,
noResults: classes.noResults,
fakeInput: classes.fakeInput,
}
})
return {
classList,
showDropdown,
}
}

View File

@ -0,0 +1,56 @@
import { toRefs } from 'vue'
import isNullish from './../utils/isNullish'
export default function useData(props, context, dep) {
const { object, valueProp, mode } = toRefs(props)
// ============ DEPENDENCIES ============
const iv = dep.iv
// =============== METHODS ==============
const update = (val) => {
// Setting object(s) as internal value
iv.value = makeInternal(val)
// Setting object(s) or plain value as external
// value based on `option` setting
const externalVal = makeExternal(val)
context.emit('change', externalVal)
context.emit('input', externalVal)
context.emit('update:modelValue', externalVal)
}
// no export
const makeExternal = (val) => {
// If external value should be object
// no transformation is required
if (object.value) {
return val
}
// No need to transform if empty value
if (isNullish(val)) {
return val
}
// If external should be plain transform
// value object to plain values
return !Array.isArray(val) ? val[valueProp.value] : val.map(v => v[valueProp.value])
}
// no export
const makeInternal = (val) => {
if (isNullish(val)) {
return mode.value === 'single' ? {} : []
}
return val
}
return {
update,
}
}

View File

@ -0,0 +1,35 @@
import { ref, toRefs } from 'vue'
export default function useDropdown(props, context, dep) {
const { disabled } = toRefs(props)
// ================ DATA ================
const isOpen = ref(false)
// =============== METHODS ==============
const open = () => {
if (isOpen.value || disabled.value) {
return
}
isOpen.value = true
context.emit('open')
}
const close = () => {
if (!isOpen.value) {
return
}
isOpen.value = false
context.emit('close')
}
return {
isOpen,
open,
close,
}
}

View File

@ -0,0 +1,140 @@
import { toRefs } from 'vue'
export default function useKeyboard(props, context, dep) {
const {
mode, addTagOn, createTag, openDirection, searchable,
showOptions, valueProp, groups: groupped,
} = toRefs(props)
// ============ DEPENDENCIES ============
const iv = dep.iv
const update = dep.update
const search = dep.search
const setPointer = dep.setPointer
const selectPointer = dep.selectPointer
const backwardPointer = dep.backwardPointer
const forwardPointer = dep.forwardPointer
const blur = dep.blur
const fo = dep.fo
// =============== METHODS ==============
// no export
const preparePointer = () => {
// When options are hidden and creating tags is allowed
// no pointer will be set (because options are hidden).
// In such case we need to set the pointer manually to the
// first option, which equals to the option created from
// the search value.
if (mode.value === 'tags' && !showOptions.value && createTag.value && searchable.value && !groupped.value) {
setPointer(fo.value[fo.value.map(o => o[valueProp.value]).indexOf(search.value)])
}
}
const handleKeydown = (e) => {
switch (e.keyCode) {
// backspace
case 8:
if (mode.value === 'single') {
return
}
if (searchable.value && [null, ''].indexOf(search.value) === -1) {
return
}
if (iv.value.length === 0) {
return
}
update([...iv.value].slice(0, -1))
break
// enter
case 13:
e.preventDefault()
if (mode.value === 'tags' && addTagOn.value.indexOf('enter') === -1 && createTag.value) {
return
}
preparePointer()
selectPointer()
break
// space
case 32:
if (searchable.value && mode.value !== 'tags' && !createTag.value) {
return
}
if (mode.value === 'tags' && ((addTagOn.value.indexOf('space') === -1 && createTag.value) || !createTag.value)) {
return
}
e.preventDefault()
preparePointer()
selectPointer()
break
// tab
// semicolon
// comma
case 9:
case 186:
case 188:
if (mode.value !== 'tags') {
return
}
const charMap = {
9: 'tab',
186: ';',
188: ','
}
if (addTagOn.value.indexOf(charMap[e.keyCode]) === -1 || !createTag.value) {
return
}
preparePointer()
selectPointer()
e.preventDefault()
break
// escape
case 27:
blur()
break
// up
case 38:
e.preventDefault()
if (!showOptions.value) {
return
}
openDirection.value === 'top' ? forwardPointer() : backwardPointer()
break
// down
case 40:
e.preventDefault()
if (!showOptions.value) {
return
}
openDirection.value === 'top' ? backwardPointer() : forwardPointer()
break
}
}
return {
handleKeydown,
preparePointer,
}
}

View File

@ -0,0 +1,82 @@
import { ref, toRefs, computed } from 'vue'
export default function useMultiselect(props, context, dep) {
const { searchable, disabled } = toRefs(props)
// ============ DEPENDENCIES ============
const input = dep.input
const open = dep.open
const close = dep.close
const clearSearch = dep.clearSearch
// ================ DATA ================
const multiselect = ref(null)
const isActive = ref(false)
// ============== COMPUTED ==============
const tabindex = computed(() => {
return searchable.value || disabled.value ? -1 : 0
})
// =============== METHODS ==============
const blur = () => {
if (searchable.value) {
input.value.blur()
}
multiselect.value.blur()
}
const handleFocus = () => {
if (searchable.value && !disabled.value) {
input.value.focus()
}
}
const activate = () => {
if (disabled.value) {
return
}
isActive.value = true
open()
}
const deactivate = () => {
isActive.value = false
setTimeout(() => {
if (!isActive.value) {
close()
clearSearch()
}
}, 1)
}
const handleCaretClick = () => {
if (isActive.value) {
deactivate()
blur()
} else {
activate()
}
}
return {
multiselect,
tabindex,
isActive,
blur,
handleFocus,
activate,
deactivate,
handleCaretClick,
}
}

View File

@ -0,0 +1,626 @@
import { ref, toRefs, computed, watch, nextTick } from 'vue'
import normalize from './../utils/normalize'
import isObject from './../utils/isObject'
import isNullish from './../utils/isNullish'
import arraysEqual from './../utils/arraysEqual'
export default function useOptions(props, context, dep) {
const {
options, mode, trackBy, limit, hideSelected, createTag, label,
appendNewTag, multipleLabel, object, loading, delay, resolveOnLoad,
minChars, filterResults, clearOnSearch, clearOnSelect, valueProp,
canDeselect, max, strict, closeOnSelect, groups: groupped, groupLabel,
groupOptions, groupHideEmpty, groupSelect,
} = toRefs(props)
// ============ DEPENDENCIES ============
const iv = dep.iv
const ev = dep.ev
const search = dep.search
const clearSearch = dep.clearSearch
const update = dep.update
const pointer = dep.pointer
const clearPointer = dep.clearPointer
const blur = dep.blur
const deactivate = dep.deactivate
// ================ DATA ================
// no export
// appendedOptions
const ap = ref([])
// no export
// resolvedOptions
const ro = ref([])
const resolving = ref(false)
// ============== COMPUTED ==============
// no export
// extendedOptions
const eo = computed(() => {
if (groupped.value) {
let groups = ro.value || /* istanbul ignore next */[]
let eo = []
groups.forEach((group) => {
optionsToArray(group[groupOptions.value]).forEach((option) => {
eo.push(Object.assign({}, option, group.disabled ? { disabled: true } : {}))
})
})
return eo
} else {
let eo = optionsToArray(ro.value || [])
if (ap.value.length) {
eo = eo.concat(ap.value)
}
return eo
}
})
const fg = computed(() => {
if (!groupped.value) {
return []
}
return filterGroups((ro.value || /* istanbul ignore next */[]).map((group) => {
const arrayOptions = optionsToArray(group[groupOptions.value])
return {
...group,
group: true,
[groupOptions.value]: filterOptions(arrayOptions, false).map(o => Object.assign({}, o, group.disabled ? { disabled: true } : {})),
__VISIBLE__: filterOptions(arrayOptions).map(o => Object.assign({}, o, group.disabled ? { disabled: true } : {})),
}
// Difference between __VISIBLE__ and {groupOptions}: visible does not contain selected options when hideSelected=true
}))
})
// filteredOptions
const fo = computed(() => {
let options = eo.value
if (createdTag.value.length) {
options = createdTag.value.concat(options)
}
options = filterOptions(options)
if (limit.value > 0) {
options = options.slice(0, limit.value)
}
return options
})
const hasSelected = computed(() => {
switch (mode.value) {
case 'single':
return !isNullish(iv.value[valueProp.value])
case 'multiple':
case 'tags':
return !isNullish(iv.value) && iv.value.length > 0
}
})
const multipleLabelText = computed(() => {
return multipleLabel !== undefined && multipleLabel.value !== undefined
? multipleLabel.value(iv.value)
: (iv.value && iv.value.length > 1 ? `${iv.value.length} options selected` : `1 option selected`)
})
const noOptions = computed(() => {
return !eo.value.length && !resolving.value && !createdTag.value.length
})
const noResults = computed(() => {
return eo.value.length > 0 && fo.value.length == 0 && ((search.value && groupped.value) || !groupped.value)
})
// no export
const createdTag = computed(() => {
if (createTag.value === false || !search.value) {
return []
}
return getOptionByTrackBy(search.value) !== -1 ? [] : [{
[valueProp.value]: search.value,
[label.value]: search.value,
[trackBy.value]: search.value,
}]
})
// no export
const nullValue = computed(() => {
switch (mode.value) {
case 'single':
return null
case 'multiple':
case 'tags':
return []
}
})
const busy = computed(() => {
return loading.value || resolving.value
})
// =============== METHODS ==============
/**
* @param {array|object|string|number} option
*/
const select = (option) => {
if (typeof option !== 'object') {
option = getOption(option)
}
switch (mode.value) {
case 'single':
update(option)
break
case 'multiple':
case 'tags':
update((iv.value).concat(option))
break
}
context.emit('select', finalValue(option), option)
}
const deselect = (option) => {
if (typeof option !== 'object') {
option = getOption(option)
}
switch (mode.value) {
case 'single':
clear()
break
case 'tags':
case 'multiple':
update(Array.isArray(option)
? iv.value.filter(v => option.map(o => o[valueProp.value]).indexOf(v[valueProp.value]) === -1)
: iv.value.filter(v => v[valueProp.value] != option[valueProp.value]))
break
}
context.emit('deselect', finalValue(option), option)
}
// no export
const finalValue = (option) => {
return object.value ? option : option[valueProp.value]
}
const remove = (option) => {
deselect(option)
}
const handleTagRemove = (option, e) => {
if (e.button !== 0) {
e.preventDefault()
return
}
remove(option)
}
const clear = () => {
context.emit('clear')
update(nullValue.value)
}
const isSelected = (option) => {
if (option.group !== undefined) {
return mode.value === 'single' ? false : areAllSelected(option[groupOptions.value]) && option[groupOptions.value].length
}
switch (mode.value) {
case 'single':
return !isNullish(iv.value) && iv.value[valueProp.value] == option[valueProp.value]
case 'tags':
case 'multiple':
return !isNullish(iv.value) && iv.value.map(o => o[valueProp.value]).indexOf(option[valueProp.value]) !== -1
}
}
const isDisabled = (option) => {
return option.disabled === true
}
const isMax = () => {
if (max === undefined || max.value === -1 || (!hasSelected.value && max.value > 0)) {
return false
}
return iv.value.length >= max.value
}
const handleOptionClick = (option) => {
if (isDisabled(option)) {
return
}
switch (mode.value) {
case 'single':
if (isSelected(option)) {
if (canDeselect.value) {
deselect(option)
}
return
}
blur()
select(option)
break
case 'multiple':
if (isSelected(option)) {
deselect(option)
return
}
if (isMax()) {
return
}
select(option)
if (clearOnSelect.value) {
clearSearch()
}
if (hideSelected.value) {
clearPointer()
}
// If we need to close the dropdown on select we also need
// to blur the input, otherwise further searches will not
// display any options
if (closeOnSelect.value) {
blur()
}
break
case 'tags':
if (isSelected(option)) {
deselect(option)
return
}
if (isMax()) {
return
}
if (getOption(option[valueProp.value]) === undefined && createTag.value) {
context.emit('tag', option[valueProp.value])
if (appendNewTag.value) {
appendOption(option)
}
clearSearch()
}
if (clearOnSelect.value) {
clearSearch()
}
select(option)
if (hideSelected.value) {
clearPointer()
}
// If we need to close the dropdown on select we also need
// to blur the input, otherwise further searches will not
// display any options
if (closeOnSelect.value) {
blur()
}
break
}
if (closeOnSelect.value) {
deactivate()
}
}
const handleGroupClick = (group) => {
if (isDisabled(group) || mode.value === 'single' || !groupSelect.value) {
return
}
switch (mode.value) {
case 'multiple':
case 'tags':
if (areAllEnabledSelected(group[groupOptions.value])) {
deselect(group[groupOptions.value])
} else {
select(group[groupOptions.value]
.filter(o => iv.value.map(v => v[valueProp.value]).indexOf(o[valueProp.value]) === -1)
.filter(o => !o.disabled)
.filter((o, k) => iv.value.length + 1 + k <= max.value || max.value === -1)
)
}
break
}
if (closeOnSelect.value) {
deactivate()
}
}
// no export
const areAllEnabledSelected = (options) => {
return options.find(o => !isSelected(o) && !o.disabled) === undefined
}
// no export
const areAllSelected = (options) => {
return options.find(o => !isSelected(o)) === undefined
}
const getOption = (val) => {
return eo.value[eo.value.map(o => String(o[valueProp.value])).indexOf(String(val))]
}
// no export
const getOptionByTrackBy = (val, norm = true) => {
return eo.value.map(o => o[trackBy.value]).indexOf(val)
}
// no export
const shouldHideOption = (option) => {
return ['tags', 'multiple'].indexOf(mode.value) !== -1 && hideSelected.value && isSelected(option)
}
// no export
const appendOption = (option) => {
ap.value.push(option)
}
// no export
const filterGroups = (groups) => {
// If the search has value we need to filter among
// he ones that are visible to the user to avoid
// displaying groups which technically have options
// based on search but that option is already selected.
return groupHideEmpty.value
? groups.filter(g => search.value
? g.__VISIBLE__.length
: g[groupOptions.value].length
)
: groups.filter(g => search.value ? g.__VISIBLE__.length : true)
}
// no export
const filterOptions = (options, excludeHideSelected = true) => {
let fo = options
if (search.value && filterResults.value) {
fo = fo.filter((option) => {
return normalize(option[trackBy.value], strict.value).indexOf(normalize(search.value, strict.value)) !== -1
})
}
if (hideSelected.value && excludeHideSelected) {
fo = fo.filter((option) => !shouldHideOption(option))
}
return fo
}
// no export
const optionsToArray = (options) => {
let uo = options
// Transforming an object to an array of objects
if (isObject(uo)) {
uo = Object.keys(uo).map((key) => {
let val = uo[key]
return { [valueProp.value]: key, [trackBy.value]: val, [label.value]: val }
})
}
// Transforming an plain arrays to an array of objects
uo = uo.map((val) => {
return typeof val === 'object' ? val : { [valueProp.value]: val, [trackBy.value]: val, [label.value]: val }
})
return uo
}
// no export
const initInternalValue = () => {
if (!isNullish(ev.value)) {
iv.value = makeInternal(ev.value)
}
}
const resolveOptions = (callback) => {
resolving.value = true
options.value(search.value).then((response) => {
ro.value = response
if (typeof callback == 'function') {
callback(response)
}
resolving.value = false
})
}
// no export
const refreshLabels = () => {
if (!hasSelected.value) {
return
}
if (mode.value === 'single') {
let newLabel = getOption(iv.value[valueProp.value])[label.value]
iv.value[label.value] = newLabel
if (object.value) {
ev.value[label.value] = newLabel
}
} else {
iv.value.forEach((val, i) => {
let newLabel = getOption(iv.value[i][valueProp.value])[label.value]
iv.value[i][label.value] = newLabel
if (object.value) {
ev.value[i][label.value] = newLabel
}
})
}
}
const refreshOptions = (callback) => {
resolveOptions(callback)
}
// no export
const makeInternal = (val) => {
if (isNullish(val)) {
return mode.value === 'single' ? {} : []
}
if (object.value) {
return val
}
// If external should be plain transform
// value object to plain values
return mode.value === 'single' ? getOption(val) || {} : val.filter(v => !!getOption(v)).map(v => getOption(v))
}
// ================ HOOKS ===============
if (mode.value !== 'single' && !isNullish(ev.value) && !Array.isArray(ev.value)) {
throw new Error(`v-model must be an array when using "${mode.value}" mode`)
}
if (options && typeof options.value == 'function') {
if (resolveOnLoad.value) {
resolveOptions(initInternalValue)
} else if (object.value == true) {
initInternalValue()
}
}
else {
ro.value = options.value
initInternalValue()
}
// ============== WATCHERS ==============
if (delay.value > -1) {
watch(search, (query) => {
if (query.length < minChars.value) {
return
}
resolving.value = true
if (clearOnSearch.value) {
ro.value = []
}
setTimeout(() => {
if (query != search.value) {
return
}
options.value(search.value).then((response) => {
if (query == search.value) {
ro.value = response
pointer.value = fo.value.filter(o => o.disabled !== true)[0] || null
resolving.value = false
}
})
}, delay.value)
}, { flush: 'sync' })
}
watch(ev, (newValue) => {
if (isNullish(newValue)) {
iv.value = makeInternal(newValue)
return
}
switch (mode.value) {
case 'single':
if (object.value ? newValue[valueProp.value] != iv.value[valueProp.value] : newValue != iv.value[valueProp.value]) {
iv.value = makeInternal(newValue)
}
break
case 'multiple':
case 'tags':
if (!arraysEqual(object.value ? newValue.map(o => o[valueProp.value]) : newValue, iv.value.map(o => o[valueProp.value]))) {
iv.value = makeInternal(newValue)
}
break
}
}, { deep: true })
if (typeof props.options !== 'function') {
watch(options, (n, o) => {
ro.value = props.options
if (!Object.keys(iv.value).length) {
initInternalValue()
}
refreshLabels()
})
}
return {
fo,
filteredOptions: fo,
hasSelected,
multipleLabelText,
eo,
extendedOptions: eo,
fg,
filteredGroups: fg,
noOptions,
noResults,
resolving,
busy,
select,
deselect,
remove,
clear,
isSelected,
isDisabled,
isMax,
getOption,
handleOptionClick,
handleGroupClick,
handleTagRemove,
refreshOptions,
resolveOptions,
}
}

View File

@ -0,0 +1,33 @@
import { ref, toRefs } from 'vue'
export default function usePointer(props, context, dep) {
const { groupSelect, mode, groups } = toRefs(props)
// ================ DATA ================
const pointer = ref(null)
// =============== METHODS ==============
const setPointer = (option) => {
if (option === undefined || (option !== null && option.disabled)) {
return
}
if (groups.value && option && option.group && (mode.value === 'single' || !groupSelect.value)) {
return
}
pointer.value = option
}
const clearPointer = () => {
setPointer(null)
}
return {
pointer,
setPointer,
clearPointer,
}
}

View File

@ -0,0 +1,241 @@
import { toRefs, watch, nextTick, computed } from 'vue'
export default function usePointer(props, context, dep) {
const {
valueProp, showOptions, searchable, groupLabel,
groups: groupped, mode, groupSelect,
} = toRefs(props)
// ============ DEPENDENCIES ============
const fo = dep.fo
const fg = dep.fg
const handleOptionClick = dep.handleOptionClick
const handleGroupClick = dep.handleGroupClick
const search = dep.search
const pointer = dep.pointer
const setPointer = dep.setPointer
const clearPointer = dep.clearPointer
const multiselect = dep.multiselect
// ============== COMPUTED ==============
// no export
const options = computed(() => {
return fo.value.filter(o => !o.disabled)
})
const groups = computed(() => {
return fg.value.filter(o => !o.disabled)
})
const canPointGroups = computed(() => {
return mode.value !== 'single' && groupSelect.value
})
const isPointerGroup = computed(() => {
return pointer.value && pointer.value.group
})
const currentGroup = computed(() => {
return getParentGroup(pointer.value)
})
const prevGroup = computed(() => {
const group = isPointerGroup.value ? pointer.value : /* istanbul ignore next */ getParentGroup(pointer.value)
const groupIndex = groups.value.map(g => g[groupLabel.value]).indexOf(group[groupLabel.value])
let prevGroup = groups.value[groupIndex - 1]
if (prevGroup === undefined) {
prevGroup = lastGroup.value
}
return prevGroup
})
const nextGroup = computed(() => {
let nextIndex = groups.value.map(g => g.label).indexOf(isPointerGroup.value
? pointer.value[groupLabel.value]
: getParentGroup(pointer.value)[groupLabel.value]) + 1
if (groups.value.length <= nextIndex) {
nextIndex = 0
}
return groups.value[nextIndex]
})
const lastGroup = computed(() => {
return [...groups.value].slice(-1)[0]
})
const currentGroupFirstEnabledOption = computed(() => {
return pointer.value.__VISIBLE__.filter(o => !o.disabled)[0]
})
const currentGroupPrevEnabledOption = computed(() => {
const options = currentGroup.value.__VISIBLE__.filter(o => !o.disabled)
return options[options.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) - 1]
})
const currentGroupNextEnabledOption = computed(() => {
const options = getParentGroup(pointer.value).__VISIBLE__.filter(o => !o.disabled)
return options[options.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) + 1]
})
const prevGroupLastEnabledOption = computed(() => {
return [...prevGroup.value.__VISIBLE__.filter(o => !o.disabled)].slice(-1)[0]
})
const lastGroupLastEnabledOption = computed(() => {
return [...lastGroup.value.__VISIBLE__.filter(o => !o.disabled)].slice(-1)[0]
})
// =============== METHODS ==============
const isPointed = (option) => {
if (!pointer.value) {
return
}
if (option.group) {
return pointer.value[groupLabel.value] == option[groupLabel.value]
} else {
return pointer.value[valueProp.value] == option[valueProp.value]
}
}
const setPointerFirst = () => {
setPointer(options.value[0] || null)
}
const selectPointer = () => {
if (!pointer.value || pointer.value.disabled === true) {
return
}
if (isPointerGroup.value) {
handleGroupClick(pointer.value)
} else {
handleOptionClick(pointer.value)
}
}
const forwardPointer = () => {
if (pointer.value === null) {
setPointer((groupped.value && canPointGroups.value ? groups.value[0] : options.value[0]) || null)
}
else if (groupped.value && canPointGroups.value) {
let nextPointer = isPointerGroup.value ? currentGroupFirstEnabledOption.value : currentGroupNextEnabledOption.value
if (nextPointer === undefined) {
nextPointer = nextGroup.value
}
setPointer(nextPointer || /* istanbul ignore next */ null)
} else {
let next = options.value.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) + 1
if (options.value.length <= next) {
next = 0
}
setPointer(options.value[next] || null)
}
nextTick(() => {
adjustWrapperScrollToPointer()
})
}
const backwardPointer = () => {
if (pointer.value === null) {
let prevPointer = options.value[options.value.length - 1]
if (groupped.value && canPointGroups.value) {
prevPointer = lastGroupLastEnabledOption.value
if (prevPointer === undefined) {
prevPointer = lastGroup.value
}
}
setPointer(prevPointer || null)
}
else if (groupped.value && canPointGroups.value) {
let prevPointer = isPointerGroup.value ? prevGroupLastEnabledOption.value : currentGroupPrevEnabledOption.value
if (prevPointer === undefined) {
prevPointer = isPointerGroup.value ? prevGroup.value : currentGroup.value
}
setPointer(prevPointer || /* istanbul ignore next */ null)
} else {
let prevIndex = options.value.map(o => o[valueProp.value]).indexOf(pointer.value[valueProp.value]) - 1
if (prevIndex < 0) {
prevIndex = options.value.length - 1
}
setPointer(options.value[prevIndex] || null)
}
nextTick(() => {
adjustWrapperScrollToPointer()
})
}
const getParentGroup = (option) => {
return groups.value.find((group) => {
return group.__VISIBLE__.map(o => o[valueProp.value]).indexOf(option[valueProp.value]) !== -1
})
}
// no export
/* istanbul ignore next */
const adjustWrapperScrollToPointer = () => {
let pointedOption = multiselect.value.querySelector(`[data-pointed]`)
if (!pointedOption) {
return
}
let wrapper = pointedOption.parentElement.parentElement
if (groupped.value) {
wrapper = isPointerGroup.value
? pointedOption.parentElement.parentElement.parentElement
: pointedOption.parentElement.parentElement.parentElement.parentElement
}
if (pointedOption.offsetTop + pointedOption.offsetHeight > wrapper.clientHeight + wrapper.scrollTop) {
wrapper.scrollTop = pointedOption.offsetTop + pointedOption.offsetHeight - wrapper.clientHeight
}
if (pointedOption.offsetTop < wrapper.scrollTop) {
wrapper.scrollTop = pointedOption.offsetTop
}
}
// ============== WATCHERS ==============
watch(search, (val) => {
if (searchable.value) {
if (val.length && showOptions.value) {
setPointerFirst()
} else {
clearPointer()
}
}
})
return {
pointer,
canPointGroups,
isPointed,
setPointerFirst,
selectPointer,
forwardPointer,
backwardPointer,
}
}

View File

@ -0,0 +1,41 @@
import { ref, toRefs, computed, watch } from 'vue'
export default function useSearch (props, context, dep)
{
const { preserveSearch } = toRefs(props)
// ================ DATA ================
const search = ref(props.initialSearch) || ref(null)
const input = ref(null)
// =============== METHODS ==============
const clearSearch = () => {
if (!preserveSearch.value) search.value = ''
}
const handleSearchInput = (e) => {
search.value = e.target.value
}
const handlePaste = (e) => {
context.emit('paste', e)
}
// ============== WATCHERS ==============
watch(search, (val) => {
context.emit('search-change', val)
})
return {
search,
input,
clearSearch,
handleSearchInput,
handlePaste,
}
}

View File

@ -0,0 +1,33 @@
import { computed, toRefs, ref } from 'vue'
export default function useValue(props, context) {
const { value, modelValue, mode, valueProp } = toRefs(props)
// ================ DATA ================
// internalValue
const iv = ref(mode.value !== 'single' ? [] : {})
// ============== COMPUTED ==============
/* istanbul ignore next */
// externalValue
const ev = context.expose !== undefined ? modelValue : value
const plainValue = computed(() => {
return mode.value === 'single' ? iv.value[valueProp.value] : iv.value.map(v => v[valueProp.value])
})
const textValue = computed(() => {
return mode.value !== 'single' ? iv.value.map(v => v[valueProp.value]).join(',') : iv.value[valueProp.value]
})
return {
iv,
internalValue: iv,
ev,
externalValue: ev,
textValue,
plainValue,
}
}

View File

@ -0,0 +1 @@
export * from './BaseMultiselect';

View File

@ -0,0 +1,7 @@
export default function arraysEqual (array1, array2) {
const array2Sorted = array2.slice().sort()
return array1.length === array2.length && array1.slice().sort().every(function(value, index) {
return value === array2Sorted[index];
})
}

View File

@ -0,0 +1,3 @@
export default function isNullish (val) {
return [null, undefined, false].indexOf(val) !== -1
}

View File

@ -0,0 +1,3 @@
export default function isObject (variable) {
return Object.prototype.toString.call(variable) === '[object Object]'
}

View File

@ -0,0 +1,5 @@
export default function normalize (str, strict = true) {
return strict
? String(str).toLowerCase().trim()
: String(str).normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim()
}

View File

@ -0,0 +1,29 @@
<template>
<span
class="
px-2
py-1
text-sm
font-normal
text-center text-green-800
uppercase
bg-success
"
:style="{ backgroundColor: bgColor, color }"
>
<slot />
</span>
</template>
<script setup>
const props = defineProps({
bgColor: {
type: String,
default: null,
},
color: {
type: String,
default: null,
},
})
</script>

View File

@ -0,0 +1,13 @@
<template>
<nav>
<ol class="flex flex-wrap py-4 text-gray-900 rounded list-reset">
<slot />
</ol>
</nav>
</template>
<script>
export default {
name: 'BaseBreadcrumb',
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<li class="pr-2 text-sm">
<router-link
class="
m-0
mr-2
text-sm
font-medium
leading-5
text-gray-900
outline-none
focus:ring-2 focus:ring-offset-2 focus:ring-primary-400
"
:to="to"
>
{{ title }}
</router-link>
<span v-if="!active" class="px-1">/</span>
</li>
</template>
<script setup>
let name = 'BaseBreadcrumItem'
const props = defineProps({
title: {
type: String,
default: String,
},
to: {
type: String,
default: '#',
},
active: {
type: Boolean,
default: false,
required: false,
},
})
</script>

View File

@ -0,0 +1,155 @@
<script setup>
import { computed, ref } from 'vue'
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
defaultClass: {
type: String,
default:
'inline-flex whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2',
},
tag: {
type: String,
default: 'button',
},
disabled: {
type: Boolean,
default: false,
},
rounded: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'md',
validator: function (value) {
return ['xs', 'sm', 'md', 'lg', 'xl'].indexOf(value) !== -1
},
},
variant: {
type: String,
default: 'primary',
validator: function (value) {
return (
[
'primary',
'secondary',
'primary-outline',
'white',
'danger',
'gray',
].indexOf(value) !== -1
)
},
},
})
const sizeClass = computed(() => {
return {
'px-2.5 py-1.5 text-xs leading-4 rounded': props.size === 'xs',
'px-3 py-2 text-sm leading-4 rounded-md': props.size == 'sm',
'px-4 py-2 text-sm leading-5 rounded-md': props.size === 'md',
'px-4 py-2 text-base leading-6 rounded-md': props.size === 'lg',
'px-6 py-3 text-base leading-6 rounded-md': props.size === 'xl',
}
})
const placeHolderSize = computed(() => {
switch (props.size) {
case 'xs':
return '32'
case 'sm':
return '38'
case 'md':
return '42'
case 'lg':
return '42'
case 'xl':
return '46'
default:
return ''
}
})
const variantClass = computed(() => {
return {
'border-transparent shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:ring-primary-500':
props.variant === 'primary',
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500':
props.variant === 'secondary',
'border-transparent border-solid border-primary-500 font-normal transition ease-in-out duration-150 text-primary-500 hover:bg-primary-200 shadow-inner ':
props.variant == 'primary-outline',
'border-gray-200 text-gray-700 bg-white hover:bg-gray-50 focus:ring-primary-500 focus:ring-offset-0':
props.variant == 'white',
'border-transparent shadow-sm text-white bg-red-600 hover:bg-red-700 focus:ring-red-500':
props.variant === 'danger',
'border-transparent bg-gray-200 border hover:bg-opacity-60 focus:ring-gray-500 focus:ring-offset-0':
props.variant === 'gray',
}
})
const roundedClass = computed(() => {
return props.rounded ? '!rounded-full' : ''
})
const iconLeftClass = computed(() => {
return {
'-ml-0.5 mr-2 h-4 w-4': props.size == 'sm',
'-ml-1 mr-2 h-5 w-5': props.size === 'md',
'-ml-1 mr-3 h-5 w-5': props.size === 'lg' || props.size === 'xl',
}
})
const iconVariantClass = computed(() => {
return {
'text-white': props.variant === 'primary',
'text-primary-700': props.variant === 'secondary',
'text-gray-700': props.variant === 'white',
'text-gray-400': props.variant === 'gray',
}
})
const iconRightClass = computed(() => {
return {
'ml-2 -mr-0.5 h-4 w-4': props.size == 'sm',
'ml-2 -mr-1 h-5 w-5': props.size === 'md',
'ml-3 -mr-1 h-5 w-5': props.size === 'lg' || props.size === 'xl',
}
})
</script>
<template>
<BaseContentPlaceholders
v-if="contentLoading"
class="disabled cursor-normal pointer-events-none"
>
<BaseContentPlaceholdersBox
:rounded="true"
style="width: 96px"
:style="`height: ${placeHolderSize}px;`"
/>
</BaseContentPlaceholders>
<BaseCustomTag
v-else
:tag="tag"
:disabled="disabled"
:class="[defaultClass, sizeClass, variantClass, roundedClass]"
>
<SpinnerIcon v-if="loading" :class="[iconLeftClass, iconVariantClass]" />
<slot v-else name="left" :class="iconLeftClass"></slot>
<slot />
<slot name="right" :class="[iconRightClass, iconVariantClass]"></slot>
</BaseCustomTag>
</template>

View File

@ -0,0 +1,39 @@
<template>
<div class="bg-white rounded-lg shadow">
<div
v-if="hasHeaderSlot"
class="px-5 py-4 text-black border-b border-gray-100 border-solid"
>
<slot name="header" />
</div>
<div :class="containerClass">
<slot />
</div>
<div
v-if="hasFooterSlot"
class="px-5 py-4 border-t border-gray-100 border-solid sm:px-6"
>
<slot name="footer" />
</div>
</div>
</template>
<script setup>
import { computed, useSlots } from 'vue'
const props = defineProps({
containerClass: {
type: String,
default: 'px-4 py-5 sm:px-8 sm:py-8',
},
})
const slots = useSlots()
const hasHeaderSlot = computed(() => {
return !!slots.header
})
const hasFooterSlot = computed(() => {
return !!slots.footer
})
</script>

View File

@ -0,0 +1,78 @@
<template>
<div class="relative flex items-start">
<div class="flex items-center h-5">
<input
:id="id"
v-model="checked"
v-bind="$attrs"
:disabled="disabled"
type="checkbox"
:class="[checkboxClass, disabledClass]"
/>
</div>
<div class="ml-3 text-sm">
<label
v-if="label"
:for="id"
:class="`font-medium ${
disabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-600'
} cursor-pointer `"
>
{{ label }}
</label>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: {
type: String,
default: '',
},
modelValue: {
type: [Boolean, Array],
default: false,
},
id: {
type: [Number, String],
default: () => `check_${Math.random().toString(36).substr(2, 9)}`,
},
disabled: {
type: Boolean,
default: false,
},
checkboxClass: {
type: String,
default: 'w-4 h-4 border-gray-300 rounded cursor-pointer',
},
setInitialValue: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'change'])
if (props.setInitialValue) {
emit('update:modelValue', props.modelValue)
}
const checked = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
emit('change', value)
},
})
const disabledClass = computed(() => {
if (props.disabled) {
return 'text-gray-300 cursor-not-allowed'
}
return 'text-primary-600 focus:ring-primary-500'
})
</script>

View File

@ -0,0 +1,190 @@
<template>
<div :class="classObject">
<slot />
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
rounded: {
type: Boolean,
default: false,
},
centered: {
type: Boolean,
default: false,
},
animated: {
type: Boolean,
default: true,
},
})
const classObject = computed(() => {
return {
'base-content-placeholders': true,
'base-content-placeholders-is-rounded': props.rounded,
'base-content-placeholders-is-centered': props.centered,
'base-content-placeholders-is-animated': props.animated,
}
})
</script>
<style lang="scss">
$base-content-placeholders-primary-color: #ccc !default;
$base-content-placeholders-secondary-color: #eee !default;
$base-content-placeholders-border-radius: 6px !default;
$base-content-placeholders-line-height: 15px !default;
$base-content-placeholders-spacing: 10px !default;
// Animations
@keyframes vueContentPlaceholdersAnimation {
0% {
transform: translate3d(-30%, 0, 0);
}
100% {
transform: translate3d(100%, 0, 0);
}
}
// Mixins
@mixin base-content-placeholders {
position: relative;
overflow: hidden;
min-height: $base-content-placeholders-line-height;
background: $base-content-placeholders-secondary-color;
.base-content-placeholders-is-rounded & {
border-radius: $base-content-placeholders-border-radius;
}
.base-content-placeholders-is-centered & {
margin-left: auto;
margin-right: auto;
}
.base-content-placeholders-is-animated &::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100vw;
max-width: 1000px;
height: 100%;
background: linear-gradient(
to right,
transparent 0%,
darken($base-content-placeholders-secondary-color, 5%) 15%,
transparent 30%
);
animation-duration: 1.5s;
animation-fill-mode: forwards;
animation-iteration-count: infinite;
animation-name: vueContentPlaceholdersAnimation;
animation-timing-function: linear;
}
}
@mixin base-content-placeholders-spacing {
[class^='base-content-placeholders-'] + & {
margin-top: 2 * $base-content-placeholders-spacing;
}
}
// Styles
.base-content-placeholders-heading {
@include base-content-placeholders-spacing;
display: flex;
&__img {
@include base-content-placeholders;
margin-right: 1.5 * $base-content-placeholders-spacing;
}
&__content {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
}
&__title {
@include base-content-placeholders;
width: 85%;
margin-bottom: $base-content-placeholders-spacing;
background: $base-content-placeholders-primary-color;
}
&__subtitle {
@include base-content-placeholders;
width: 90%;
}
}
.base-content-placeholders-text {
@include base-content-placeholders-spacing;
&__line {
@include base-content-placeholders;
width: 100%;
margin-bottom: $base-content-placeholders-spacing;
&:first-child {
width: 100%;
}
&:nth-child(2) {
width: 90%;
}
&:nth-child(3) {
width: 80%;
}
&:nth-child(4) {
width: 70%;
}
}
}
.base-content-placeholders-box {
position: relative;
overflow: hidden;
min-height: $base-content-placeholders-line-height;
background: $base-content-placeholders-secondary-color;
.base-content-placeholders-is-animated &::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100vw;
max-width: 1000px;
height: 100%;
background: linear-gradient(
to right,
transparent 0%,
darken($base-content-placeholders-secondary-color, 5%) 15%,
transparent 30%
);
animation-duration: 1.5s;
animation-fill-mode: forwards;
animation-iteration-count: infinite;
animation-name: vueContentPlaceholdersAnimation;
animation-timing-function: linear;
}
// @include base-content-placeholders-spacing;
}
.base-content-circle {
border-radius: 100%;
}
.base-content-placeholders-is-rounded {
border-radius: $base-content-placeholders-border-radius;
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div class="base-content-placeholders-box" :class="circleClass" />
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
circle: {
type: Boolean,
default: false,
},
rounded: {
type: Boolean,
default: false,
},
})
const circleClass = computed(() => {
return {
'base-content-circle': props.circle,
'base-content-placeholders-is-rounded': props.rounded,
}
})
</script>

View File

@ -0,0 +1,25 @@
<template>
<div class="base-content-placeholders-heading">
<div v-if="box" class="base-content-placeholders-heading__box" />
<div class="base-content-placeholders-heading__content">
<div
class="base-content-placeholders-heading__title"
style="background: #eee"
/>
<div class="base-content-placeholders-heading__subtitle" />
</div>
</div>
</template>
<script setup>
const props = defineProps({
box: {
type: Boolean,
default: false,
},
rounded: {
type: Boolean,
default: false,
},
})
</script>

View File

@ -0,0 +1,31 @@
<template>
<div class="base-content-placeholders-text">
<div
v-for="n in lines"
:key="n"
:class="lineClass"
class="w-full h-full base-content-placeholders-text__line"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
lines: {
type: Number,
default: 4,
},
rounded: {
type: Boolean,
default: false,
},
})
const lineClass = computed(() => {
return {
'base-content-placeholders-is-rounded': props.rounded,
}
})
</script>

View File

@ -0,0 +1,253 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
class="w-full"
style="height: 200px"
/>
</BaseContentPlaceholders>
<div v-else class="relative">
<div class="absolute bottom-0 right-0 z-10">
<BaseDropdown
:close-on-select="true"
max-height="220"
position="top-end"
width-class="w-92"
class="mb-2"
>
<template #activator>
<BaseButton type="button" variant="primary-outline" class="mr-4">
{{ $t('settings.customization.insert_fields') }}
<template #left="slotProps">
<BaseIcon name="PlusSmIcon" :class="slotProps.class" />
</template>
</BaseButton>
</template>
<div class="flex p-2">
<ul v-for="(type, index) in fieldList" :key="index" class="list-none">
<li class="mb-1 ml-2 text-xs font-semibold text-gray-500 uppercase">
{{ type.label }}
</li>
<li
v-for="(field, fieldIndex) in type.fields"
:key="fieldIndex"
class="
w-48
text-sm
font-normal
cursor-pointer
hover:bg-gray-100
rounded
ml-1
py-0.5
"
@click="value += `{${field.value}}`"
>
<div class="flex pl-1">
<BaseIcon
name="ChevronDoubleRightIcon"
class="h-3 mt-1 mr-2 text-gray-400"
/>
{{ field.label }}
</div>
</li>
</ul>
</div>
</BaseDropdown>
</div>
<BaseEditor v-model="value" />
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useCustomFieldStore } from '@/scripts/stores/custom-field'
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
modelValue: {
type: String,
default: '',
},
fields: {
type: Array,
default: null,
},
})
const emit = defineEmits(['update:modelValue'])
const customFieldsStore = useCustomFieldStore()
let fieldList = ref([])
let invoiceFields = ref([])
let estimateFields = ref([])
let paymentFields = ref([])
let customerFields = ref([])
const position = null
watch(
() => props.fields,
(val) => {
if (props.fields && props.fields.length > 0) {
getFields()
}
}
)
watch(
() => customFieldsStore.customFields,
(newValue) => {
invoiceFields.value = newValue
? newValue.filter((field) => field.model_type === 'Invoice')
: []
customerFields.value = newValue
? newValue.filter((field) => field.model_type === 'Customer')
: []
paymentFields.value = newValue
? newValue.filter((field) => field.model_type === 'Payment')
: []
estimateFields.value = newValue.filter(
(field) => field.model_type === 'Estimate'
)
getFields()
}
)
const value = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
},
})
async function getFields() {
fieldList.value = []
if (props.fields && props.fields.length > 0) {
if (props.fields.find((field) => field == 'shipping')) {
fieldList.value.push({
label: 'Shipping Address',
fields: [
{ label: 'Address name', value: 'SHIPPING_ADDRESS_NAME' },
{ label: 'Country', value: 'SHIPPING_COUNTRY' },
{ label: 'State', value: 'SHIPPING_STATE' },
{ label: 'City', value: 'SHIPPING_CITY' },
{ label: 'Address Street 1', value: 'SHIPPING_ADDRESS_STREET_1' },
{ label: 'Address Street 2', value: 'SHIPPING_ADDRESS_STREET_2' },
{ label: 'Phone', value: 'SHIPPING_PHONE' },
{ label: 'Zip Code', value: 'SHIPPING_ZIP_CODE' },
],
})
}
if (props.fields.find((field) => field == 'billing')) {
fieldList.value.push({
label: 'Billing Address',
fields: [
{ label: 'Address name', value: 'BILLING_ADDRESS_NAME' },
{ label: 'Country', value: 'BILLING_COUNTRY' },
{ label: 'State', value: 'BILLING_STATE' },
{ label: 'City', value: 'BILLING_CITY' },
{ label: 'Address Street 1', value: 'BILLING_ADDRESS_STREET_1' },
{ label: 'Address Street 2', value: 'BILLING_ADDRESS_STREET_2' },
{ label: 'Phone', value: 'BILLING_PHONE' },
{ label: 'Zip Code', value: 'BILLING_ZIP_CODE' },
],
})
}
if (props.fields.find((field) => field == 'customer')) {
fieldList.value.push({
label: 'Customer',
fields: [
{ label: 'Display Name', value: 'CONTACT_DISPLAY_NAME' },
{ label: 'Contact Name', value: 'PRIMARY_CONTACT_NAME' },
{ label: 'Email', value: 'CONTACT_EMAIL' },
{ label: 'Phone', value: 'CONTACT_PHONE' },
{ label: 'Website', value: 'CONTACT_WEBSITE' },
...customerFields.value.map((i) => ({
label: i.label,
value: i.slug,
})),
],
})
}
if (props.fields.find((field) => field == 'invoice')) {
fieldList.value.push({
label: 'Invoice',
fields: [
{ label: 'Date', value: 'INVOICE_DATE' },
{ label: 'Due Date', value: 'INVOICE_DUE_DATE' },
{ label: 'Number', value: 'INVOICE_NUMBER' },
{ label: 'Ref Number', value: 'INVOICE_REF_NUMBER' },
{ label: 'Invoice Link', value: 'INVOICE_LINK' },
...invoiceFields.value.map((i) => ({
label: i.label,
value: i.slug,
})),
],
})
}
if (props.fields.find((field) => field == 'estimate')) {
fieldList.value.push({
label: 'Estimate',
fields: [
{ label: 'Date', value: 'ESTIMATE_DATE' },
{ label: 'Expiry Date', value: 'ESTIMATE_EXPIRY_DATE' },
{ label: 'Number', value: 'ESTIMATE_NUMBER' },
{ label: 'Ref Number', value: 'ESTIMATE_REF_NUMBER' },
{ label: 'Estimate Link', value: 'ESTIMATE_LINK' },
...estimateFields.value.map((i) => ({
label: i.label,
value: i.slug,
})),
],
})
}
if (props.fields.find((field) => field == 'payment')) {
fieldList.value.push({
label: 'Payment',
fields: [
{ label: 'Date', value: 'PAYMENT_DATE' },
{ label: 'Number', value: 'PAYMENT_NUMBER' },
{ label: 'Mode', value: 'PAYMENT_MODE' },
{ label: 'Amount', value: 'PAYMENT_AMOUNT' },
{ label: 'Payment Link', value: 'PAYMENT_LINK' },
...paymentFields.value.map((i) => ({
label: i.label,
value: i.slug,
})),
],
})
}
if (props.fields.find((field) => field == 'company')) {
fieldList.value.push({
label: 'Company',
fields: [
{ label: 'Company Name', value: 'COMPANY_NAME' },
{ label: 'Country', value: 'COMPANY_COUNTRY' },
{ label: 'State', value: 'COMPANY_STATE' },
{ label: 'City', value: 'COMPANY_CITY' },
{ label: 'Address Street 1', value: 'COMPANY_ADDRESS_STREET_1' },
{ label: 'Address Street 2', value: 'COMPANY_ADDRESS_STREET_2' },
{ label: 'Phone', value: 'COMPANY_PHONE' },
{ label: 'Zip Code', value: 'COMPANY_ZIP_CODE' },
],
})
}
}
}
getFields()
</script>

View File

@ -0,0 +1,16 @@
<script>
import { h } from 'vue'
export default {
props: {
tag: {
type: String,
default: 'button',
},
},
setup(props, { slots, attrs, emit }) {
// return the render function
return () => h(`${props.tag}`, attrs, slots)
},
}
</script>

View File

@ -0,0 +1,27 @@
<template>
<div
v-if="address"
class="text-sm font-bold leading-5 text-black non-italic space-y-1"
>
<p v-if="address?.address_street_1">{{ address?.address_street_1 }},</p>
<p v-if="address?.address_street_2">{{ address?.address_street_2 }},</p>
<p v-if="address?.city">{{ address?.city }},</p>
<p v-if="address?.state">{{ address?.state }},</p>
<p v-if="address?.country?.name">{{ address?.country?.name }},</p>
<p v-if="address?.zip">{{ address?.zip }}.</p>
</div>
</template>
<script setup>
const props = defineProps({
address: {
type: Object,
required: true,
},
})
</script>

View File

@ -0,0 +1,98 @@
<template>
<BaseMultiselect
v-model="selectedCustomer"
v-bind="$attrs"
track-by="name"
value-prop="id"
label="name"
:filter-results="false"
:min-chars="1"
resolve-on-load
:delay="500"
:searchable="true"
:options="searchCustomers"
label-value="name"
:placeholder="$t('customers.type_or_click')"
:can-deselect="false"
class="w-full"
>
<template v-if="showAction" #action>
<BaseSelectAction
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
@click="addCustomer"
>
<BaseIcon
name="UserAddIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('customers.add_new_customer') }}
</BaseSelectAction>
</template>
</BaseMultiselect>
<CustomerModal />
</template>
<script setup>
import { useCustomerStore } from '@/scripts/stores/customer'
import { computed, watch } from 'vue'
import { useModalStore } from '@/scripts/stores/modal'
import { useI18n } from 'vue-i18n'
import CustomerModal from '@/scripts/components/modal-components/CustomerModal.vue'
import { useUserStore } from '@/scripts/stores/user'
import abilities from '@/scripts/stub/abilities'
const props = defineProps({
modelValue: {
type: [String, Number, Object],
default: '',
},
fetchAll: {
type: Boolean,
default: false,
},
showAction: {
type: Boolean,
default: false,
},
})
const { t } = useI18n()
const emit = defineEmits(['update:modelValue'])
const modalStore = useModalStore()
const customerStore = useCustomerStore()
const userStore = useUserStore()
const selectedCustomer = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
},
})
async function searchCustomers(search) {
let data = {
search,
}
if (props.fetchAll) {
data.limit = 'all'
}
let res = await customerStore.fetchCustomers(data)
return res.data.data
}
async function addCustomer() {
customerStore.resetCurrentCustomer()
modalStore.openModal({
title: t('customers.add_new_customer'),
componentName: 'CustomerModal',
})
}
</script>

View File

@ -0,0 +1,533 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
class="w-full"
style="min-height: 170px"
/>
</BaseContentPlaceholders>
<div v-else class="max-h-[173px]">
<CustomerModal />
<div
v-if="selectedCustomer"
class="
flex flex-col
p-4
bg-white
border border-gray-200 border-solid
min-h-[170px]
rounded-md
"
@click.stop
>
<div class="flex relative justify-between mb-2">
<label class="flex-1 text-base font-medium text-left text-gray-900">
{{ selectedCustomer.name }}
</label>
<div class="flex">
<a
class="
relative
my-0
ml-6
text-sm
font-medium
cursor-pointer
text-primary-500
items-center
flex
"
@click.stop="editCustomer"
>
<BaseIcon name="PencilIcon" class="text-gray-500 h-4 w-4 mr-1" />
{{ $t('general.edit') }}
</a>
<a
class="
relative
my-0
ml-6
text-sm
flex
items-center
font-medium
cursor-pointer
text-primary-500
"
@click="resetSelectedCustomer"
>
<BaseIcon name="XCircleIcon" class="text-gray-500 h-4 w-4 mr-1" />
{{ $t('general.deselect') }}
</a>
</div>
</div>
<div class="grid grid-cols-2 gap-8 mt-2">
<div v-if="selectedCustomer.billing" class="flex flex-col">
<label
class="
mb-1
text-sm
font-medium
text-left text-gray-400
uppercase
whitespace-nowrap
"
>
{{ $t('general.bill_to') }}
</label>
<div
v-if="selectedCustomer.billing"
class="flex flex-col flex-1 p-0 text-left"
>
<label
v-if="selectedCustomer.billing.name"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing.name }}
</label>
<label class="relative w-11/12 text-sm truncate">
<span v-if="selectedCustomer.billing.city">
{{ selectedCustomer.billing.city }}
</span>
<span
v-if="
selectedCustomer.billing.city &&
selectedCustomer.billing.state
"
>
,
</span>
<span v-if="selectedCustomer.billing.state">
{{ selectedCustomer.billing.state }}
</span>
</label>
<label
v-if="selectedCustomer.billing.zip"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.billing.zip }}
</label>
</div>
</div>
<div v-if="selectedCustomer.shipping" class="flex flex-col">
<label
class="
mb-1
text-sm
font-medium
text-left text-gray-400
uppercase
whitespace-nowrap
"
>
{{ $t('general.ship_to') }}
</label>
<div
v-if="selectedCustomer.shipping"
class="flex flex-col flex-1 p-0 text-left"
>
<label
v-if="selectedCustomer.shipping.name"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping.name }}
</label>
<label class="relative w-11/12 text-sm truncate">
<span v-if="selectedCustomer.shipping.city">
{{ selectedCustomer.shipping.city }}
</span>
<span
v-if="
selectedCustomer.shipping.city &&
selectedCustomer.shipping.state
"
>
,
</span>
<span v-if="selectedCustomer.shipping.state">
{{ selectedCustomer.shipping.state }}
</span>
</label>
<label
v-if="selectedCustomer.shipping.zip"
class="relative w-11/12 text-sm truncate"
>
{{ selectedCustomer.shipping.zip }}
</label>
</div>
</div>
</div>
</div>
<Popover v-else v-slot="{ open }" class="relative flex flex-col rounded-md">
<PopoverButton
:class="{
'text-opacity-90': open,
'border border-solid border-red-500 focus:ring-red-500 rounded':
valid.$error,
'focus:ring-2 focus:ring-primary-400': !valid.$error,
}"
class="w-full outline-none rounded-md"
>
<div
class="
relative
flex
justify-center
px-0
p-0
py-16
bg-white
border border-gray-200 border-solid
rounded-md
min-h-[170px]
"
>
<BaseIcon
name="UserIcon"
class="
flex
justify-center
w-10
h-10
p-2
mr-5
text-sm text-white
bg-gray-200
rounded-full
font-base
"
/>
<div class="mt-1">
<label class="text-lg font-medium text-gray-900">
{{ $t('customers.new_customer') }}
<span class="text-red-500"> * </span>
</label>
<p
v-if="valid.$error && valid.$errors[0].$message"
class="text-red-500 text-sm absolute right-3 bottom-3"
>
{{ $t('estimates.errors.required') }}
</p>
</div>
</div>
</PopoverButton>
<!-- Customer Select Popup -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div v-if="open" class="absolute min-w-full z-10">
<PopoverPanel
v-slot="{ close }"
focus
static
class="
overflow-hidden
rounded-md
shadow-lg
ring-1 ring-black ring-opacity-5
bg-white
"
>
<div class="relative">
<BaseInput
v-model="search"
container-class="m-4"
:placeholder="$t('general.search')"
type="text"
icon="search"
@update:modelValue="(val) => debounceSearchCustomer(val)"
/>
<ul
class="
max-h-80
flex flex-col
overflow-auto
list
border-t border-gray-200
"
>
<li
v-for="(customer, index) in customerStore.customers"
:key="index"
href="#"
class="
flex
px-6
py-2
border-b border-gray-200 border-solid
cursor-pointer
hover:cursor-pointer hover:bg-gray-100
focus:outline-none focus:bg-gray-100
last:border-b-0
"
@click="selectNewCustomer(customer.id, close)"
>
<span
class="
flex
items-center
content-center
justify-center
w-10
h-10
mr-4
text-xl
font-semibold
leading-9
text-white
bg-gray-300
rounded-full
avatar
"
>
{{ initGenerator(customer.name) }}
</span>
<div class="flex flex-col justify-center text-left">
<label
v-if="customer.name"
class="
m-0
text-base
font-normal
leading-tight
cursor-pointer
"
>
{{ customer.name }}
</label>
<label
v-if="customer.contact_name"
class="
m-0
text-sm
font-medium
text-gray-400
cursor-pointer
"
>
{{ customer.contact_name }}
</label>
</div>
</li>
<div
v-if="customerStore.customers.length === 0"
class="flex justify-center p-5 text-gray-400"
>
<label class="text-base text-gray-500 cursor-pointer">
{{ $t('customers.no_customers_found') }}
</label>
</div>
</ul>
</div>
<button
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
type="button"
class="
h-10
flex
items-center
justify-center
w-full
px-2
py-3
bg-gray-200
border-none
outline-none
focus:bg-gray-300
"
@click="openCustomerModal"
>
<BaseIcon name="UserAddIcon" class="text-primary-400" />
<label
class="
m-0
ml-3
text-sm
leading-none
cursor-pointer
font-base
text-primary-400
"
>
{{ $t('customers.add_new_customer') }}
</label>
</button>
</PopoverPanel>
</div>
</transition>
</Popover>
</div>
</template>
<script setup>
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { useEstimateStore } from '@/scripts/stores/estimate'
import { useInvoiceStore } from '@/scripts/stores/invoice'
import { useRecurringInvoiceStore } from '@/scripts/stores/recurring-invoice'
import { useModalStore } from '@/scripts/stores/modal'
import { useGlobalStore } from '@/scripts/stores/global'
import { useCustomerStore } from '@/scripts/stores/customer'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDebounceFn } from '@vueuse/core'
import { useUserStore } from '@/scripts/stores/user'
import abilities from '@/scripts/stub/abilities'
import { useRoute } from 'vue-router'
import CustomerModal from '@/scripts/components/modal-components/CustomerModal.vue'
const props = defineProps({
valid: {
type: Object,
default: () => {},
},
customerId: {
type: Number,
default: null,
},
type: {
type: String,
default: null,
},
contentLoading: {
type: Boolean,
default: false,
},
})
const modalStore = useModalStore()
const estimateStore = useEstimateStore()
const customerStore = useCustomerStore()
const globalStore = useGlobalStore()
const invoiceStore = useInvoiceStore()
const recurringInvoiceStore = useRecurringInvoiceStore()
const userStore = useUserStore()
const routes = useRoute()
const { t } = useI18n()
const search = ref(null)
const isSearchingCustomer = ref(false)
const selectedCustomer = computed(() => {
switch (props.type) {
case 'estimate':
return estimateStore.newEstimate.customer
case 'invoice':
return invoiceStore.newInvoice.customer
case 'recurring-invoice':
return recurringInvoiceStore.newRecurringInvoice.customer
default:
return ''
}
})
function resetSelectedCustomer() {
if (props.type === 'estimate') {
estimateStore.resetSelectedCustomer()
} else if (props.type === 'invoice') {
invoiceStore.resetSelectedCustomer()
} else {
recurringInvoiceStore.resetSelectedCustomer()
}
}
if (props.customerId && props.type === 'estimate') {
estimateStore.selectCustomer(props.customerId)
} else if (props.customerId && props.type === 'invoice') {
invoiceStore.selectCustomer(props.customerId)
} else {
if (props.customerId) recurringInvoiceStore.selectCustomer(props.customerId)
}
async function editCustomer() {
await customerStore.fetchCustomer(selectedCustomer.value.id)
modalStore.openModal({
title: t('customers.edit_customer'),
componentName: 'CustomerModal',
})
}
async function fetchInitialCustomers() {
await customerStore.fetchCustomers({
filter: {},
orderByField: '',
orderBy: '',
customer_id: props.customerId,
})
}
const debounceSearchCustomer = useDebounceFn(() => {
isSearchingCustomer.value = true
searchCustomer()
}, 500)
async function searchCustomer() {
let data = {
display_name: search.value,
page: 1,
}
await customerStore.fetchCustomers(data)
isSearchingCustomer.value = false
}
function openCustomerModal() {
modalStore.openModal({
title: t('customers.add_customer'),
componentName: 'CustomerModal',
variant: 'md',
})
}
function initGenerator(name) {
if (name) {
let nameSplit = name.split(' ')
let initials = nameSplit[0].charAt(0).toUpperCase()
return initials
}
}
function selectNewCustomer(id, close) {
let params = {
userId: id,
}
if (routes.params.id) params.model_id = routes.params.id
if (props.type === 'estimate') {
estimateStore.getNextNumber(params, true)
estimateStore.selectCustomer(id)
} else if (props.type === 'invoice') {
invoiceStore.getNextNumber(params, true)
invoiceStore.selectCustomer(id)
} else {
recurringInvoiceStore.selectCustomer(id)
}
close()
search.value = null
}
globalStore.fetchCurrencies()
globalStore.fetchCountries()
fetchInitialCustomers()
</script>

View File

@ -0,0 +1,177 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
:class="`w-full ${computedContainerClass}`"
style="height: 38px"
/>
</BaseContentPlaceholders>
<div v-else :class="computedContainerClass" class="relative flex flex-row">
<svg
v-if="showCalendarIcon && !hasIconSlot"
viewBox="0 0 20 20"
fill="currentColor"
class="
absolute
w-4
h-4
mx-2
my-2.5
text-sm
not-italic
font-black
text-gray-400
cursor-pointer
"
@click="onClickDp"
>
<path
fill-rule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clip-rule="evenodd"
></path>
</svg>
<slot v-if="showCalendarIcon && hasIconSlot" name="icon" />
<FlatPickr
ref="dp"
v-model="date"
v-bind="$attrs"
:disabled="disabled"
:config="config"
:class="[defaultInputClass, inputInvalidClass, inputDisabledClass]"
/>
</div>
</template>
<script type="text/babel" setup>
import FlatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import { computed, reactive, watch, ref, useSlots } from 'vue'
import { useCompanyStore } from '@/scripts/stores/company'
const dp = ref(null)
const props = defineProps({
modelValue: {
type: [String, Date],
default: () => new Date(),
},
contentLoading: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: null,
},
invalid: {
type: Boolean,
default: false,
},
enableTime: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
showCalendarIcon: {
type: Boolean,
default: true,
},
containerClass: {
type: String,
default: '',
},
defaultInputClass: {
type: String,
default:
'font-base pl-8 py-2 outline-none focus:ring-primary-400 focus:outline-none focus:border-primary-400 block w-full sm:text-sm border-gray-200 rounded-md text-black',
},
time24hr: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const slots = useSlots()
const companyStore = useCompanyStore()
let config = reactive({
altInput: true,
enableTime: props.enableTime,
time_24hr: props.time24hr,
})
const date = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
},
})
const carbonFormat = computed(() => {
return companyStore.selectedCompanySettings?.carbon_date_format
})
const hasIconSlot = computed(() => {
return !!slots.icon
})
const computedContainerClass = computed(() => {
let containerClass = `${props.containerClass} `
return containerClass
})
const inputInvalidClass = computed(() => {
if (props.invalid) {
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
}
return ''
})
const inputDisabledClass = computed(() => {
if (props.disabled) {
return 'border border-solid rounded-md outline-none input-field box-border-2 base-date-picker-input placeholder-gray-400 bg-gray-200 text-gray-600 border-gray-200'
}
return ''
})
function onClickDp(params) {
dp.value.fp.open()
}
watch(
() => props.enableTime,
(val) => {
if (props.enableTime) {
config.enableTime = props.enableTime
}
},
{ immediate: true }
)
watch(
() => carbonFormat,
() => {
if (!props.enableTime) {
config.altFormat = carbonFormat.value ? carbonFormat.value : 'd M Y'
} else {
config.altFormat = carbonFormat.value
? `${carbonFormat.value} H:i `
: 'd M Y H:i'
}
},
{ immediate: true }
)
</script>

View File

@ -0,0 +1,5 @@
<template>
<div class="grid gap-4 mt-5 md:grid-cols-2 lg:grid-cols-3">
<slot />
</div>
</template>

View File

@ -0,0 +1,37 @@
<template>
<div>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox class="w-20 h-5 mb-1" />
<BaseContentPlaceholdersBox class="w-40 h-5" />
</BaseContentPlaceholders>
<div v-else>
<BaseLabel class="font-normal mb-1">
{{ label }}
</BaseLabel>
<p class="text-sm font-bold leading-5 text-black non-italic">
{{ value }}
<slot />
</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
label: {
type: String,
required: true,
},
value: {
type: [String, Number],
default: '',
},
contentLoading: {
type: Boolean,
default: false,
},
})
</script>

View File

@ -0,0 +1,181 @@
<template>
<TransitionRoot as="template" :show="dialogStore.active">
<Dialog
as="div"
static
class="fixed inset-0 z-20 overflow-y-auto"
:open="dialogStore.active"
@close="dialogStore.closeDialog"
>
<div
class="
flex
items-end
justify-center
min-h-screen min-h-screen-ios
px-4
pt-4
pb-20
text-center
sm:block sm:p-0
"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay
class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
/>
</TransitionChild>
<!-- This element is to trick the browser into centering the modal contents. -->
<span
class="
hidden
sm:inline-block sm:align-middle sm:h-screen sm:h-screen-ios
"
aria-hidden="true"
>&#8203;</span
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
class="
inline-block
px-4
pt-5
pb-4
overflow-hidden
text-left
align-bottom
transition-all
transform
bg-white
rounded-lg
shadow-xl
sm:my-8 sm:align-middle sm:w-full sm:p-6
"
:class="dialogSizeClasses"
>
<div>
<div
class="
flex
items-center
justify-center
w-12
h-12
mx-auto
bg-green-100
rounded-full
"
:class="{
'bg-green-100': dialogStore.variant === 'primary',
'bg-red-100': dialogStore.variant === 'danger',
}"
>
<BaseIcon
v-if="dialogStore.variant === 'primary'"
name="CheckIcon"
class="w-6 h-6 text-green-600"
/>
<BaseIcon
v-else
name="ExclamationIcon"
class="w-6 h-6 text-red-600"
aria-hidden="true"
/>
</div>
<div class="mt-3 text-center sm:mt-5">
<DialogTitle
as="h3"
class="text-lg font-medium leading-6 text-gray-900"
>
{{ dialogStore.title }}
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-gray-500">
{{ dialogStore.message }}
</p>
</div>
</div>
</div>
<div
class="mt-5 sm:mt-6"
:class="{
'sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense':
!dialogStore.hideNoButton,
}"
>
<base-button
class="justify-center"
:variant="dialogStore.variant"
:class="{ 'w-full': dialogStore.hideNoButton }"
@click="resolveDialog(true)"
>
{{ dialogStore.yesLabel }}
</base-button>
<base-button
v-if="!dialogStore.hideNoButton"
class="justify-center"
variant="white"
@click="resolveDialog(false)"
>
{{ dialogStore.noLabel }}
</base-button>
</div>
</div>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { computed } from 'vue'
import { useDialogStore } from '@/scripts/stores/dialog'
import {
Dialog,
DialogOverlay,
DialogTitle,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue'
const dialogStore = useDialogStore()
function resolveDialog(resValue) {
dialogStore.resolve(resValue)
dialogStore.closeDialog()
}
const dialogSizeClasses = computed(() => {
const size = dialogStore.size
switch (size) {
case 'sm':
return 'sm:max-w-sm'
case 'md':
return 'sm:max-w-md'
case 'lg':
return 'sm:max-w-lg'
default:
return 'sm:max-w-md'
}
})
</script>

View File

@ -0,0 +1,3 @@
<template>
<hr class="w-full text-gray-300" />
</template>

View File

@ -0,0 +1,85 @@
<template>
<div class="relative" :class="wrapperClass">
<BaseContentPlaceholders
v-if="contentLoading"
class="disabled cursor-normal pointer-events-none"
>
<BaseContentPlaceholdersBox
:rounded="true"
class="w-14"
style="height: 42px"
/>
</BaseContentPlaceholders>
<Menu v-else>
<MenuButton ref="trigger" class="focus:outline-none" @click="onClick">
<slot name="activator" />
</MenuButton>
<div ref="container" class="z-10" :class="widthClass">
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems :class="containerClasses">
<div class="py-1">
<slot />
</div>
</MenuItems>
</transition>
</div>
</Menu>
</div>
</template>
<script setup>
import { Menu, MenuButton, MenuItems } from '@headlessui/vue'
import { computed, onMounted, ref, onUpdated } from 'vue'
import { usePopper } from '@/scripts/helpers/use-popper'
const props = defineProps({
containerClass: {
type: String,
required: false,
default: '',
},
widthClass: {
type: String,
default: 'w-56',
},
positionClass: {
type: String,
default: 'absolute z-10 right-0',
},
position: {
type: String,
default: 'bottom-end',
},
wrapperClass: {
type: String,
default: 'inline-block h-full text-left',
},
contentLoading: {
type: Boolean,
default: false,
},
})
const containerClasses = computed(() => {
const baseClass = `origin-top-right rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-100 focus:outline-none`
return `${baseClass} ${props.containerClass}`
})
let [trigger, container, popper] = usePopper({
placement: 'bottom-end',
strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
})
function onClick() {
popper.value.update()
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<MenuItem v-slot="{ active }" v-bind="$attrs">
<a
href="#"
:class="[
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
'group flex items-center px-4 py-2 text-sm font-normal',
]"
>
<slot :active="active" />
</a>
</MenuItem>
</template>
<script setup>
import { MenuItem } from '@headlessui/vue'
</script>

View File

@ -0,0 +1,31 @@
<template>
<div class="flex flex-col items-center justify-center mt-16">
<div class="flex flex-col items-center justify-center">
<slot></slot>
</div>
<div class="mt-2">
<label class="font-medium">{{ title }}</label>
</div>
<div class="mt-2">
<label class="text-gray-500">
{{ description }}
</label>
</div>
<div class="mt-6">
<slot name="actions" />
</div>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: String,
},
description: {
type: String,
default: String,
},
})
</script>

View File

@ -0,0 +1,36 @@
<template>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
{{ errorTitle }}
</h3>
<div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc pl-5 space-y-1">
<li v-for="(error, key) in errors" :key="key">
{{ error }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { XCircleIcon } from '@heroicons/vue/solid'
const props = defineProps({
errorTitle: {
type: String,
default: 'There were some errors with your submission',
},
errors: {
type: Array,
default: null,
},
})
</script>

View File

@ -0,0 +1,36 @@
<template>
<span :class="badgeColorClasses">
<slot />
</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
status: {
type: String,
required: false,
default: '',
},
})
const badgeColorClasses = computed(() => {
switch (props.status) {
case 'DRAFT':
return 'bg-yellow-300 bg-opacity-25 px-2 py-1 text-sm text-yellow-800 uppercase font-normal text-center '
case 'SENT':
return ' bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center '
case 'VIEWED':
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
case 'EXPIRED':
return 'bg-red-300 bg-opacity-25 px-2 py-1 text-sm text-red-800 uppercase font-normal text-center'
case 'ACCEPTED':
return 'bg-green-400 bg-opacity-25 px-2 py-1 text-sm text-green-800 uppercase font-normal text-center'
case 'REJECTED':
return 'bg-purple-300 bg-opacity-25 px-2 py-1 text-sm text-purple-800 uppercase font-normal text-center'
default:
return 'bg-gray-500 bg-opacity-25 px-2 py-1 text-sm text-gray-900 uppercase font-normal text-center'
}
})
</script>

View File

@ -0,0 +1,565 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import axios from 'axios'
import utils from '@/scripts/helpers/utilities'
const props = defineProps({
multiple: {
type: Boolean,
default: false,
},
avatar: {
type: Boolean,
default: false,
},
autoProcess: {
type: Boolean,
default: false,
},
uploadUrl: {
type: String,
default: '',
},
preserveLocalFiles: {
type: Boolean,
default: false,
},
accept: {
type: String,
default: 'image/*',
},
inputFieldName: {
type: String,
default: 'photos',
},
base64: {
type: Boolean,
default: false,
},
modelValue: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['change', 'remove', 'update:modelValue'])
// status
const STATUS_INITIAL = 0
const STATUS_SAVING = 1
const STATUS_SUCCESS = 2
const STATUS_FAILED = 3
let uploadedFiles = ref([])
const localFiles = ref([])
const inputRef = ref(null)
let uploadError = ref(null)
let currentStatus = ref(null)
function reset() {
// reset form to initial state
currentStatus = STATUS_INITIAL
uploadedFiles.value = []
if (props.modelValue && props.modelValue.length) {
localFiles.value = [...props.modelValue]
} else {
localFiles.value = []
}
uploadError = null
}
function upload(formData) {
return (
axios
.post(props.uploadUrl, formData)
// get data
.then((x) => x.data)
// add url field
.then((x) => x.map((img) => ({ ...img, url: `/images/${img.id}` })))
)
}
// upload data to the server
function save(formData) {
currentStatus = STATUS_SAVING
upload(formData)
.then((x) => {
uploadedFiles = [].concat(x)
currentStatus = STATUS_SUCCESS
})
.catch((err) => {
uploadError = err.response
currentStatus = STATUS_FAILED
})
}
function getBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
function onChange(fieldName, fileList, fileCount) {
if (!fileList.length) return
if (props.multiple) {
emit('change', fieldName, fileList, fileCount)
} else {
if (props.base64) {
getBase64(fileList[0]).then((res) => {
emit('change', fieldName, res, fileCount, fileList[0])
})
} else {
emit('change', fieldName, fileList[0], fileCount)
}
}
if (!props.preserveLocalFiles) {
localFiles.value = []
}
Array.from(Array(fileList.length).keys()).forEach((x) => {
const file = fileList[x]
if (utils.isImageFile(file.type)) {
getBase64(file).then((image) => {
localFiles.value.push({
fileObject: file,
type: file.type,
name: file.name,
image,
})
})
} else {
localFiles.value.push({
fileObject: file,
type: file.type,
name: file.name,
})
}
})
emit('update:modelValue', localFiles.value)
if (!props.autoProcess) return
// append the files to FormData
const formData = new FormData()
Array.from(Array(fileList.length).keys()).forEach((x) => {
formData.append(fieldName, fileList[x], fileList[x].name)
})
// save it
save(formData)
}
function onBrowse() {
if (inputRef.value) {
inputRef.value.click()
}
}
function onAvatarRemove(image) {
localFiles.value = []
emit('remove', image)
}
function onFileRemove(index) {
localFiles.value.splice(index, 1)
}
onMounted(() => {
reset()
})
watch(
() => props.modelValue,
(v) => {
localFiles.value = [...v]
}
)
</script>
<template>
<form
enctype="multipart/form-data"
class="
relative
flex
items-center
justify-center
p-2
border-2 border-dashed
rounded-md
cursor-pointer
avatar-upload
border-gray-200
transition-all
duration-300
ease-in-out
isolate
w-full
hover:border-gray-300
group
min-h-[100px]
bg-gray-50
"
:class="avatar ? 'w-32 h-32' : 'w-full'"
>
<input
id="file-upload"
ref="inputRef"
type="file"
tabindex="-1"
:multiple="multiple"
:name="inputFieldName"
:accept="accept"
class="absolute z-10 w-full h-full opacity-0 cursor-pointer"
@change="
onChange(
$event.target.name,
$event.target.files,
$event.target.files.length
)
"
/>
<!-- Avatar Not Selected -->
<div v-if="!localFiles.length && avatar" class="">
<img src="/img/default-avatar.jpg" class="rounded" alt="Default Avatar" />
<a
href="#"
class="absolute z-30 bg-white rounded-full -bottom-3 -right-3 group"
@click.prevent.stop="onBrowse"
>
<BaseIcon
name="PlusCircleIcon"
class="
h-8
text-xl
leading-6
text-primary-500
group-hover:text-primary-600
"
/>
</a>
</div>
<!-- Not Selected -->
<div v-else-if="!localFiles.length" class="flex flex-col items-center">
<BaseIcon
name="CloudUploadIcon"
class="h-6 mb-2 text-xl leading-6 text-gray-400"
/>
<p class="text-xs leading-4 text-center text-gray-400">
Drag a file here or
<a
class="
cursor-pointer
text-primary-500
hover:text-primary-600 hover:font-medium
relative
z-20
"
href="#"
@click.prevent.stop="onBrowse"
>
browse
</a>
to choose a file
</p>
</div>
<div
v-else-if="localFiles.length && avatar && !multiple"
class="flex w-full h-full border border-gray-200 rounded"
>
<img
v-if="localFiles[0].image"
for="file-upload"
:src="localFiles[0].image"
class="block object-cover w-full h-full rounded opacity-100"
style="animation: fadeIn 2s ease"
/>
<div
v-else
class="
flex
justify-center
items-center
text-gray-400
flex-col
space-y-2
px-2
py-4
w-full
"
>
<!-- DocumentText Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.25"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p
v-if="localFiles[0].name"
class="
text-gray-600
font-medium
text-sm
truncate
overflow-hidden
w-full
"
>
{{ localFiles[0].name }}
</p>
</div>
<a
href="#"
class="
box-border
absolute
z-30
flex
items-center
justify-center
w-8
h-8
bg-white
border border-gray-200
rounded-full
shadow-md
-bottom-3
-right-3
group
hover:border-gray-300
"
@click.prevent.stop="onAvatarRemove(localFiles[0])"
>
<BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black" />
</a>
</div>
<!-- Preview Files Multiple -->
<div
v-else-if="localFiles.length && multiple"
class="flex flex-wrap w-full"
>
<a
v-for="(localFile, index) in localFiles"
:key="localFile"
href="#"
class="
block
p-2
m-2
bg-white
border border-gray-200
rounded
hover:border-gray-500
relative
max-w-md
"
@click.prevent
>
<img
v-if="localFile.image"
for="file-upload"
:src="localFile.image"
class="block object-cover w-20 h-20 opacity-100"
style="animation: fadeIn 2s ease"
/>
<div
v-else
class="
flex
justify-center
items-center
text-gray-400
flex-col
space-y-2
px-2
py-4
w-full
"
>
<!-- DocumentText Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.25"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p
v-if="localFile.name"
class="
text-gray-600
font-medium
text-sm
truncate
overflow-hidden
w-full
"
>
{{ localFile.name }}
</p>
</div>
<a
href="#"
class="
box-border
absolute
z-30
flex
items-center
justify-center
w-8
h-8
bg-white
border border-gray-200
rounded-full
shadow-md
-bottom-3
-right-3
group
hover:border-gray-300
"
@click.prevent.stop="onFileRemove(index)"
>
<BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black" />
</a>
</a>
</div>
<div v-else class="flex w-full items-center justify-center">
<a
v-for="(localFile, index) in localFiles"
:key="localFile"
href="#"
class="
block
p-2
m-2
bg-white
border border-gray-200
rounded
hover:border-gray-500
relative
max-w-md
"
@click.prevent
>
<img
v-if="localFile.image"
for="file-upload"
:src="localFile.image"
class="block object-contain h-20 opacity-100 min-w-[5rem]"
style="animation: fadeIn 2s ease"
/>
<div
v-else
class="
flex
justify-center
items-center
text-gray-400
flex-col
space-y-2
px-2
py-4
w-full
"
>
<!-- DocumentText Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.25"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p
v-if="localFile.name"
class="
text-gray-600
font-medium
text-sm
truncate
overflow-hidden
w-full
"
>
{{ localFile.name }}
</p>
</div>
<a
href="#"
class="
box-border
absolute
z-30
flex
items-center
justify-center
w-8
h-8
bg-white
border border-gray-200
rounded-full
shadow-md
-bottom-3
-right-3
group
hover:border-gray-300
"
@click.prevent.stop="onFileRemove(index)"
>
<BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black" />
</a>
</a>
</div>
</form>
</template>

View File

@ -0,0 +1,51 @@
<template>
<transition
enter-active-class="transition duration-500 ease-in-out"
enter-from-class="transform opacity-0"
enter-to-class="transform opacity-100"
leave-active-class="transition ease-in-out"
leave-from-class="transform opacity-100"
leave-to-class="transform opacity-0"
>
<div v-show="show" class="relative z-10 p-4 md:p-8 bg-gray-200 rounded">
<slot name="filter-header" />
<label
class="
absolute
text-sm
leading-snug
text-gray-900
cursor-pointer
hover:text-gray-700
top-2.5
right-3.5
"
@click="$emit('clear')"
>
{{ $t('general.clear_all') }}
</label>
<div
class="
flex flex-col
space-y-3
lg:flex-row lg:space-x-4 lg:space-y-0 lg:items-center
"
>
<slot />
</div>
</div>
</transition>
</template>
<script setup>
defineProps({
show: {
type: Boolean,
default: false,
},
})
defineEmits(['clear'])
</script>

View File

@ -0,0 +1,32 @@
<template>
<span style="font-family: sans-serif">{{ formattedAmount }}</span>
</template>
<script setup>
import { useCompanyStore } from '@/scripts/stores/company'
import { inject, computed } from 'vue'
const props = defineProps({
amount: {
type: [Number, String],
required: true,
},
currency: {
type: Object,
default: () => {
return null
},
},
})
const utils = inject('utils')
const companyStore = useCompanyStore()
const formattedAmount = computed(() => {
return utils.formatMoney(
props.amount,
props.currency || companyStore.selectedCompanyCurrency
)
})
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
<template>
<h6 :class="typeClass">
<slot />
</h6>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: {
type: String,
default: 'section-title',
validator: function (value) {
return ['section-title', 'heading-title'].indexOf(value) !== -1
},
},
})
const typeClass = computed(() => {
return {
'text-gray-900 text-lg font-medium': props.type === 'heading-title',
'text-gray-500 uppercase text-base': props.type === 'section-title',
}
})
</script>

View File

@ -0,0 +1,20 @@
<template>
<component v-if="isLoaded" :is="heroIcons[name]" class="h-5 w-5" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as heroIcons from '@heroicons/vue/outline'
const isLoaded = ref(false)
const props = defineProps({
name: {
type: String,
},
})
onMounted(() => {
isLoaded.value = true
})
</script>

View File

@ -0,0 +1,103 @@
<template>
<div class="rounded-md bg-yellow-50 p-4 relative">
<BaseIcon
name="XIcon"
class="h-5 w-5 text-yellow-500 absolute right-4 cursor-pointer"
@click="$emit('hide')"
/>
<div class="flex flex-col">
<div class="flex">
<div class="flex-shrink-0">
<BaseIcon
name="ExclamationIcon"
class="h-5 w-5 text-yellow-400"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
{{ title }}
</h3>
<div class="mt-2 text-sm text-yellow-700">
<ul role="list" class="list-disc pl-5 space-y-1">
<li v-for="(list, key) in lists" :key="key">
{{ list }}
</li>
</ul>
</div>
</div>
</div>
<div v-if="actions.length" class="mt-4 ml-3">
<div class="-mx-2 -my-1.5 flex flex-row-reverse">
<button
v-for="(action, i) in actions"
:key="i"
type="button"
class="
bg-yellow-50
px-2
py-1.5
rounded-md
text-sm
font-medium
text-yellow-800
hover:bg-yellow-100
focus:outline-none
focus:ring-2
focus:ring-offset-2
focus:ring-offset-yellow-50
focus:ring-yellow-600
mr-3
"
@click="$emit(`${action}`)"
>
{{ action }}
</button>
<!-- <button
v-if="actions[1]"
type="button"
class="
ml-3
bg-yellow-50
px-2
py-1.5
rounded-md
text-sm
font-medium
text-yellow-800
hover:bg-yellow-100
focus:outline-none
focus:ring-2
focus:ring-offset-2
focus:ring-offset-yellow-50
focus:ring-yellow-600
"
@click="$emit('action2')"
>
{{ actions[1] }}
</button> -->
</div>
</div>
</div>
</div>
</template>
<script setup>
import { XCircleIcon } from '@heroicons/vue/solid'
const emits = defineEmits(['hide'])
const props = defineProps({
title: {
type: String,
default: 'There were some errors with your submission',
},
lists: {
type: Array,
default: null,
},
actions: {
type: Array,
default: () => ['Dismiss'],
},
})
</script>

View File

@ -0,0 +1,285 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
:class="`w-full ${contentLoadClass}`"
style="height: 38px"
/>
</BaseContentPlaceholders>
<div
v-else
:class="[containerClass, computedContainerClass]"
class="relative rounded-md shadow-sm font-base"
>
<div
v-if="loading && loadingPosition === 'left'"
class="
absolute
inset-y-0
left-0
flex
items-center
pl-3
pointer-events-none
"
>
<svg
class="animate-spin !text-primary-500"
:class="[iconLeftClass]"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<div
v-else-if="hasLeftIconSlot"
class="absolute inset-y-0 left-0 flex items-center pl-3"
>
<slot name="left" :class="iconLeftClass" />
</div>
<span
v-if="addon"
class="
inline-flex
items-center
px-3
text-gray-500
border border-r-0 border-gray-200
rounded-l-md
bg-gray-50
sm:text-sm
"
>
{{ addon }}
</span>
<div
v-if="inlineAddon"
class="
absolute
inset-y-0
left-0
flex
items-center
pl-3
pointer-events-none
"
>
<span class="text-gray-500 sm:text-sm">
{{ inlineAddon }}
</span>
</div>
<input
v-bind="$attrs"
:type="type"
:value="modelValue"
:disabled="disabled"
:class="[
defaultInputClass,
inputPaddingClass,
inputAddonClass,
inputInvalidClass,
inputDisabledClass,
]"
@input="emitValue"
/>
<div
v-if="loading && loadingPosition === 'right'"
class="
absolute
inset-y-0
right-0
flex
items-center
pr-3
pointer-events-none
"
>
<svg
class="animate-spin !text-primary-500"
:class="[iconRightClass]"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<div
v-if="hasRightIconSlot"
class="absolute inset-y-0 right-0 flex items-center pr-3"
>
<slot name="right" :class="iconRightClass" />
</div>
</div>
</template>
<script setup>
import { computed, ref, useSlots } from 'vue'
let inheritAttrs = ref(false)
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
type: {
type: [Number, String],
default: 'text',
},
modelValue: {
type: [String, Number],
default: '',
},
loading: {
type: Boolean,
default: false,
},
loadingPosition: {
type: String,
default: 'left',
},
addon: {
type: String,
default: null,
},
inlineAddon: {
type: String,
default: '',
},
invalid: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
containerClass: {
type: String,
default: '',
},
contentLoadClass: {
type: String,
default: '',
},
defaultInputClass: {
type: String,
default:
'font-base block w-full sm:text-sm border-gray-200 rounded-md text-black',
},
iconLeftClass: {
type: String,
default: 'h-5 w-5 text-gray-400',
},
iconRightClass: {
type: String,
default: 'h-5 w-5 text-gray-400',
},
modelModifiers: {
default: () => ({}),
},
})
const slots = useSlots()
const emit = defineEmits(['update:modelValue'])
const hasLeftIconSlot = computed(() => {
return !!slots.left || (props.loading && props.loadingPosition === 'left')
})
const hasRightIconSlot = computed(() => {
return !!slots.right || (props.loading && props.loadingPosition === 'right')
})
const inputPaddingClass = computed(() => {
if (hasLeftIconSlot.value && hasRightIconSlot.value) {
return 'px-10'
} else if (hasLeftIconSlot.value) {
return 'pl-10'
} else if (hasRightIconSlot.value) {
return 'pr-10'
}
return ''
})
const inputAddonClass = computed(() => {
if (props.addon) {
return 'flex-1 min-w-0 block w-full px-3 py-2 !rounded-none !rounded-r-md'
} else if (props.inlineAddon) {
return 'pl-7'
}
return ''
})
const inputInvalidClass = computed(() => {
if (props.invalid) {
return 'border-red-500 ring-red-500 focus:ring-red-500 focus:border-red-500'
}
return 'focus:ring-primary-400 focus:border-primary-400'
})
const inputDisabledClass = computed(() => {
if (props.disabled) {
return `border-gray-100 bg-gray-100 !text-gray-400 ring-gray-200 focus:ring-gray-200 focus:border-gray-100`
}
return ''
})
const computedContainerClass = computed(() => {
let containerClass = `${props.containerClass} `
if (props.addon) {
return `${props.containerClass} flex`
}
return containerClass
})
function emitValue(e) {
let val = e.target.value
if (props.modelModifiers.uppercase) {
val = val.toUpperCase()
}
emit('update:modelValue', val)
}
</script>

View File

@ -0,0 +1,24 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
layout: {
type: String,
default: 'two-column',
},
})
const formLayout = computed(() => {
if (props.layout === 'two-column') {
return 'grid gap-y-6 gap-x-4 md:grid-cols-2'
}
return 'grid gap-y-6 gap-x-4 grid-cols-1'
})
</script>
<template>
<div :class="formLayout">
<slot />
</div>
</template>

View File

@ -0,0 +1,113 @@
<template>
<div :class="containerClasses" class="relative w-full text-left">
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersText :lines="1" :class="contentLoadClass" />
</BaseContentPlaceholders>
<label
v-else-if="label"
:class="labelClasses"
class="
flex
text-sm
not-italic
items-center
font-medium
text-primary-800
whitespace-nowrap
justify-between
"
>
<div>
{{ label }}
<span v-show="required" class="text-sm text-red-500"> * </span>
</div>
<slot v-if="hasRightLabelSlot" name="labelRight" />
<BaseIcon
v-if="tooltip"
v-tooltip="{ content: tooltip }"
name="InformationCircleIcon"
class="h-4 text-gray-400 cursor-pointer hover:text-gray-600"
/>
</label>
<div :class="inputContainerClasses">
<slot></slot>
<span v-if="helpText" class="text-gray-400 text-xs mt-1 font-light">
{{ helpText }}
</span>
<span v-if="error" class="block mt-0.5 text-sm text-red-500">
{{ error }}
</span>
</div>
</div>
</template>
<script setup>
import { computed, useSlots } from 'vue'
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
contentLoadClass: {
type: String,
default: 'w-16 h-5',
},
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'vertical',
},
error: {
type: [String, Boolean],
default: null,
},
required: {
type: Boolean,
default: false,
},
tooltip: {
type: String,
default: null,
required: false,
},
helpText: {
type: String,
default: null,
required: false,
},
})
const containerClasses = computed(() => {
if (props.variant === 'horizontal') {
return 'grid md:grid-cols-12 items-center'
}
return ''
})
const labelClasses = computed(() => {
if (props.variant === 'horizontal') {
return 'relative pr-0 pt-1 mr-3 text-sm md:col-span-4 md:text-right mb-1 md:mb-0'
}
return ''
})
const inputContainerClasses = computed(() => {
if (props.variant === 'horizontal') {
return 'md:col-span-8 md:col-start-5 md:col-ends-12'
}
return 'flex flex-col mt-1'
})
const slots = useSlots()
const hasRightLabelSlot = computed(() => {
return !!slots.labelRight
})
</script>

View File

@ -0,0 +1,47 @@
<template>
<span :class="badgeColorClasses">
<slot />
</span>
</template>
<script>
import { computed } from 'vue'
export default {
props: {
status: {
type: String,
required: false,
default: '',
},
},
setup(props) {
const badgeColorClasses = computed(() => {
switch (props.status) {
case 'DRAFT':
return 'bg-yellow-300 bg-opacity-25 px-2 py-1 text-sm text-yellow-800 uppercase font-normal text-center'
case 'SENT':
return ' bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center '
case 'VIEWED':
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
case 'COMPLETED':
return 'bg-green-500 bg-opacity-25 px-2 py-1 text-sm text-green-900 uppercase font-normal text-center'
case 'DUE':
return 'bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center'
case 'OVERDUE':
return 'bg-red-300 bg-opacity-50 px-2 py-1 text-sm text-red-900 uppercase font-normal text-center'
case 'UNPAID':
return 'bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center'
case 'PARTIALLY_PAID':
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
case 'PAID':
return 'bg-green-500 bg-opacity-25 px-2 py-1 text-sm text-green-900 uppercase font-normal text-center'
default:
return 'bg-gray-500 bg-opacity-25 px-2 py-1 text-sm text-gray-900 uppercase font-normal text-center'
}
})
return { badgeColorClasses }
},
}
</script>

View File

@ -0,0 +1,193 @@
<template>
<div class="flex-1 text-sm">
<!-- Selected Item Field -->
<div
v-if="item.item_id"
class="
relative
flex
items-center
h-10
pl-2
bg-gray-200
border border-gray-200 border-solid
rounded
"
>
{{ item.name }}
<span
class="absolute text-gray-400 cursor-pointer top-[8px] right-[10px]"
@click="deselectItem(index)"
>
<BaseIcon name="XCircleIcon" />
</span>
</div>
<!-- Select Item Field -->
<BaseMultiselect
v-else
v-model="itemSelect"
:content-loading="contentLoading"
value-prop="id"
track-by="id"
:invalid="invalid"
preserve-search
:initial-search="itemData.name"
label="name"
:filterResults="false"
resolve-on-load
:delay="500"
searchable
:options="searchItems"
object
@update:modelValue="(val) => $emit('select', val)"
@searchChange="(val) => $emit('search', val)"
>
<!-- Add Item Action -->
<template #action>
<BaseSelectAction
v-if="userStore.hasAbilities(abilities.CREATE_ITEM)"
@click="openItemModal"
>
<BaseIcon
name="PlusCircleIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('general.add_new_item') }}
</BaseSelectAction>
</template>
</BaseMultiselect>
<!-- Item Description -->
<div class="w-full pt-1 text-xs text-light">
<BaseTextarea
v-model="description"
:content-loading="contentLoading"
:autosize="true"
class="text-xs"
:borderless="true"
:placeholder="$t('estimates.item.type_item_description')"
:invalid="invalidDescription"
/>
<div v-if="invalidDescription">
<span class="text-red-600">
{{ $tc('validation.description_maxlength') }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useEstimateStore } from '@/scripts/stores/estimate'
import { useInvoiceStore } from '@/scripts/stores/invoice'
import { useItemStore } from '@/scripts/stores/item'
import { useModalStore } from '@/scripts/stores/modal'
import { useUserStore } from '@/scripts/stores/user'
import abilities from '@/scripts/stub/abilities'
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
type: {
type: String,
default: null,
},
item: {
type: Object,
required: true,
},
index: {
type: Number,
default: 0,
},
invalid: {
type: Boolean,
required: false,
default: false,
},
invalidDescription: {
type: Boolean,
required: false,
default: false,
},
taxPerItem: {
type: String,
default: '',
},
taxes: {
type: Array,
default: null,
},
store: {
type: Object,
default: null,
},
storeProp: {
type: String,
default: '',
},
})
const emit = defineEmits(['search', 'select'])
const itemStore = useItemStore()
const estimateStore = useEstimateStore()
const invoiceStore = useInvoiceStore()
const modalStore = useModalStore()
const userStore = useUserStore()
let route = useRoute()
const { t } = useI18n()
const itemSelect = ref(null)
const loading = ref(false)
let itemData = reactive({ ...props.item })
Object.assign(itemData, props.item)
const taxAmount = computed(() => {
return 0
})
const description = computed({
get: () => props.item.description,
set: (value) => {
props.store[props.storeProp].items[props.index].description = value
},
})
async function searchItems(search) {
let res = await itemStore.fetchItems({ search })
return res.data.data
}
function onTextChange(val) {
searchItems(val)
emit('search', val)
}
function openItemModal() {
modalStore.openModal({
title: t('items.add_item'),
componentName: 'ItemModal',
refreshData: (val) => emit('select', val),
data: {
taxPerItem: props.taxPerItem,
taxes: props.taxes,
itemIndex: props.index,
store: props.store,
storeProps: props.storeProp,
},
})
}
function deselectItem(index) {
props.store.deselectItem(index)
}
</script>

View File

@ -0,0 +1,5 @@
<template>
<label class="text-sm not-italic font-medium leading-5 text-primary-800">
<slot />
</label>
</template>

View File

@ -0,0 +1,143 @@
<template>
<Teleport to="body">
<TransitionRoot appear as="template" :show="show">
<Dialog
as="div"
static
class="fixed inset-0 z-20 overflow-y-auto"
:open="show"
@close="$emit('close')"
>
<div
class="
flex
items-end
justify-center
min-h-screen min-h-screen-ios
px-4
text-center
sm:block sm:px-2
"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay
class="fixed inset-0 transition-opacity bg-gray-700 bg-opacity-25"
/>
</TransitionChild>
<!-- This element is to trick the browser into centering the modal contents. -->
<span
class="
hidden
sm:inline-block sm:align-middle sm:h-screen sm:h-screen-ios
"
aria-hidden="true"
>&#8203;</span
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
:class="`inline-block
align-middle
bg-white
rounded-lg
text-left
overflow-hidden
shadow-xl
transform
transition-all
my-4
${modalSize}
sm:w-full
border-t-8 border-solid rounded shadow-xl border-primary-500`"
>
<div
v-if="hasHeaderSlot"
class="
flex
items-center
justify-between
px-6
py-4
text-lg
font-medium
text-black
border-b border-gray-200 border-solid
"
>
<slot name="header" />
</div>
<slot />
<slot name="footer" />
</div>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
</Teleport>
</template>
<script setup>
import { useModalStore } from '@/scripts/stores/modal'
import { computed, watchEffect, useSlots } from 'vue'
import {
Dialog,
DialogOverlay,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
})
const slots = useSlots()
const emit = defineEmits(['close', 'open'])
const modalStore = useModalStore()
watchEffect(() => {
if (props.show) {
emit('open', props.show)
}
})
const modalSize = computed(() => {
const size = modalStore.size
switch (size) {
case 'sm':
return 'sm:max-w-2xl w-full'
case 'md':
return 'sm:max-w-4xl w-full'
case 'lg':
return 'sm:max-w-6xl w-full'
default:
return 'sm:max-w-2xl w-full'
}
})
const hasHeaderSlot = computed(() => {
return !!slots.header
})
</script>

View File

@ -0,0 +1,93 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
class="w-full"
style="height: 38px"
/>
</BaseContentPlaceholders>
<money3
v-else
v-model="money"
v-bind="currencyBindings"
:class="[inputClass, invalidClass]"
:disabled="disabled"
/>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Money3Component } from 'v-money3'
import { useCompanyStore } from '@/scripts/stores/company'
let money3 = Money3Component
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
modelValue: {
type: [String, Number],
required: true,
default: '',
},
invalid: {
type: Boolean,
default: false,
},
inputClass: {
type: String,
default:
'font-base block w-full sm:text-sm border-gray-200 rounded-md text-black',
},
disabled: {
type: Boolean,
default: false,
},
percent: {
type: Boolean,
default: false,
},
currency: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:modelValue'])
const companyStore = useCompanyStore()
let hasInitialValueSet = false
const money = computed({
get: () => props.modelValue,
set: (value) => {
if (!hasInitialValueSet) {
hasInitialValueSet = true
return
}
emit('update:modelValue', value)
},
})
const currencyBindings = computed(() => {
const currency = props.currency
? props.currency
: companyStore.selectedCompanyCurrency
return {
decimal: currency.decimal_separator,
thousands: currency.thousand_separator,
prefix: currency.symbol + ' ',
precision: currency.precision,
masked: false,
}
})
const invalidClass = computed(() => {
if (props.invalid) {
return 'border-red-500 ring-red-500 focus:ring-red-500 focus:border-red-500'
}
return 'focus:ring-primary-400 focus:border-primary-400'
})
</script>

View File

@ -0,0 +1,19 @@
<template>
<span
:class="[
sucess ? 'bg-green-100 text-green-700 ' : 'bg-red-100 text-red-700',
'px-2 py-1 text-sm font-normal text-center uppercase',
]"
>
<slot />
</span>
</template>
<script setup>
const props = defineProps({
sucess: {
type: Boolean,
default: false,
},
})
</script>

View File

@ -0,0 +1,5 @@
<template>
<div class="flex-1 p-4 md:p-8 flex flex-col">
<slot />
</div>
</template>

View File

@ -0,0 +1,23 @@
<template>
<div class="flex flex-wrap justify-between">
<div>
<h3 class="text-2xl font-bold text-left text-black">
{{ title }}
</h3>
<slot />
</div>
<div class="flex items-center">
<slot name="actions" />
</div>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: null,
required: true,
},
})
</script>

View File

@ -0,0 +1,39 @@
<template>
<span :class="[badgeColorClasses, defaultClass]" class="">
<slot />
</span>
</template>
<script>
import { computed } from 'vue'
export default {
props: {
status: {
type: String,
required: false,
default: '',
},
defaultClass: {
type: String,
default: 'px-1 py-0.5 text-xs',
},
},
setup(props) {
const badgeColorClasses = computed(() => {
switch (props.status) {
case 'PAID':
return 'bg-primary-300 bg-opacity-25 text-primary-800 uppercase font-normal text-center'
case 'UNPAID':
return ' bg-yellow-500 bg-opacity-25 text-yellow-900 uppercase font-normal text-center '
case 'PARTIALLY_PAID':
return 'bg-blue-400 bg-opacity-25 text-blue-900 uppercase font-normal text-center'
default:
return 'bg-gray-500 bg-opacity-25 text-gray-900 uppercase font-normal text-center'
}
})
return { badgeColorClasses }
},
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<RadioGroup v-model="selected">
<RadioGroupLabel class="sr-only"> Privacy setting </RadioGroupLabel>
<div class="-space-y-px rounded-md">
<RadioGroupOption
:id="id"
v-slot="{ checked, active }"
as="template"
:value="value"
:name="name"
v-bind="$attrs"
>
<div class="relative flex cursor-pointer focus:outline-none">
<span
:class="[
checked ? checkedStateClass : unCheckedStateClass,
active ? optionGroupActiveStateClass : '',
optionGroupClass,
]"
aria-hidden="true"
>
<span class="rounded-full bg-white w-1.5 h-1.5" />
</span>
<div class="flex flex-col ml-3">
<RadioGroupLabel
as="span"
:class="[
checked ? checkedStateLabelClass : unCheckedStateLabelClass,
optionGroupLabelClass,
]"
>
{{ label }}
</RadioGroupLabel>
</div>
</div>
</RadioGroupOption>
</div>
</RadioGroup>
</template>
<script setup>
import { computed } from 'vue'
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from '@headlessui/vue'
const props = defineProps({
id: {
type: [String, Number],
required: false,
default: () => `radio_${Math.random().toString(36).substr(2, 9)}`,
},
label: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: '',
},
value: {
type: [String, Number],
default: '',
},
name: {
type: [String, Number],
default: '',
},
checkedStateClass: {
type: String,
default: 'bg-primary-600',
},
unCheckedStateClass: {
type: String,
default: 'bg-white ',
},
optionGroupActiveStateClass: {
type: String,
default: 'ring-2 ring-offset-2 ring-primary-500',
},
checkedStateLabelClass: {
type: String,
default: 'text-primary-900 ',
},
unCheckedStateLabelClass: {
type: String,
default: 'text-gray-900',
},
optionGroupClass: {
type: String,
default:
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center',
},
optionGroupLabelClass: {
type: String,
default: 'block text-sm font-light',
},
})
const emit = defineEmits(['update:modelValue'])
const selected = computed({
get: () => props.modelValue,
set: (modelValue) => emit('update:modelValue', modelValue),
})
</script>

View File

@ -0,0 +1,35 @@
<template>
<span :class="badgeColorClasses">
<slot />
</span>
</template>
<script>
import { computed } from 'vue'
export default {
props: {
status: {
type: String,
required: false,
default: '',
},
},
setup(props) {
const badgeColorClasses = computed(() => {
switch (props.status) {
case 'COMPLETED':
return 'bg-green-500 bg-opacity-25 px-2 py-1 text-sm text-green-900 uppercase font-normal text-center'
case 'ON_HOLD':
return 'bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center'
case 'ACTIVE':
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
default:
return 'bg-gray-500 bg-opacity-25 px-2 py-1 text-sm text-gray-900 uppercase font-normal text-center'
}
})
return { badgeColorClasses }
},
}
</script>

View File

@ -0,0 +1,12 @@
<template>
<div class="flex flex-col">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="py-2 align-middle inline-block min-w-full sm:px-4 lg:px-6">
<div class="overflow-hidden sm:px-2 lg:p-2">
<slot />
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,18 @@
<template>
<div
class="
flex
items-center
justify-center
w-full
px-6
py-2
text-sm
bg-gray-200
cursor-pointer
text-primary-400
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,216 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox :rounded="true" class="w-full h-10" />
</BaseContentPlaceholders>
<Listbox
v-else
v-model="selectedValue"
as="div"
v-bind="{
...$attrs,
}"
>
<ListboxLabel
v-if="label"
class="block text-sm not-italic font-medium text-primary-800 mb-0.5"
>
{{ label }}
</ListboxLabel>
<div class="relative">
<!-- Select Input button -->
<ListboxButton
class="
relative
w-full
py-2
pl-3
pr-10
text-left
bg-white
border border-gray-200
rounded-md
shadow-sm
cursor-default
focus:outline-none
focus:ring-1 focus:ring-primary-500
focus:border-primary-500
sm:text-sm
"
>
<span v-if="getValue(selectedValue)" class="block truncate">
{{ getValue(selectedValue) }}
</span>
<span v-else-if="placeholder" class="block text-gray-400 truncate">
{{ placeholder }}
</span>
<span v-else class="block text-gray-400 truncate">
Please select an option
</span>
<span
class="
absolute
inset-y-0
right-0
flex
items-center
pr-2
pointer-events-none
"
>
<BaseIcon
name="SelectorIcon"
class="text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="
absolute
z-10
w-full
py-1
mt-1
overflow-auto
text-base
bg-white
rounded-md
shadow-lg
max-h-60
ring-1 ring-black ring-opacity-5
focus:outline-none
sm:text-sm
"
>
<ListboxOption
v-for="option in options"
v-slot="{ active, selected }"
:key="option.id"
:value="option"
as="template"
>
<li
:class="[
active ? 'text-white bg-primary-600' : 'text-gray-900',
'cursor-default select-none relative py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>
{{ getValue(option) }}
</span>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-primary-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<BaseIcon name="CheckIcon" aria-hidden="true" />
/>
</span>
</li>
</ListboxOption>
<slot />
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup>
import { ref, watch } from 'vue'
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from '@headlessui/vue'
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
modelValue: {
type: [String, Number, Boolean, Object, Array],
default: '',
},
options: {
type: Array,
required: true,
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
labelKey: {
type: [String],
default: 'label',
},
valueProp: {
type: String,
default: null,
},
multiple: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
let selectedValue = ref(props.modelValue)
function isObject(val) {
return typeof val === 'object' && val !== null
}
function getValue(val) {
if (isObject(val)) {
return val[props.labelKey]
}
return val
}
watch(
() => props.modelValue,
() => {
if (props.valueProp && props.options.length) {
selectedValue.value = props.options.find((val) => {
if (val[props.valueProp]) {
return val[props.valueProp] === props.modelValue
}
})
} else {
selectedValue.value = props.modelValue
}
}
)
watch(selectedValue, (val) => {
if (props.valueProp) {
emit('update:modelValue', val[props.valueProp])
} else {
emit('update:modelValue', val)
}
})
</script>

View File

@ -0,0 +1,42 @@
<template>
<BaseCard>
<div class="flex flex-wrap justify-between lg:flex-nowrap mb-5">
<div>
<h6 class="font-medium text-lg text-left">
{{ title }}
</h6>
<p
class="
mt-2
text-sm
leading-snug
text-left text-gray-500
max-w-[680px]
"
>
{{ description }}
</p>
</div>
<div class="mt-4 lg:mt-0 lg:ml-2">
<slot name="action" />
</div>
</div>
<slot />
</BaseCard>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
})
</script>

View File

@ -0,0 +1,69 @@
<template>
<SwitchGroup>
<div class="flex flex-row items-start">
<SwitchLabel v-if="labelLeft" class="mr-4 cursor-pointer">{{
labelLeft
}}</SwitchLabel>
<Switch
v-model="enabled"
:class="enabled ? 'bg-primary-500' : 'bg-gray-300'"
class="
relative
inline-flex
items-center
h-6
transition-colors
rounded-full
w-11
focus:outline-none focus:ring-primary-500
"
v-bind="$attrs"
>
<span
:class="enabled ? 'translate-x-6' : 'translate-x-1'"
class="
inline-block
w-4
h-4
transition-transform
transform
bg-white
rounded-full
"
/>
</Switch>
<SwitchLabel v-if="labelRight" class="ml-4 cursor-pointer">{{
labelRight
}}</SwitchLabel>
</div>
</SwitchGroup>
</template>
<script setup>
import { computed } from 'vue'
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue'
const props = defineProps({
labelLeft: {
type: String,
default: '',
},
labelRight: {
type: String,
default: '',
},
modelValue: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const enabled = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
</script>

View File

@ -0,0 +1,64 @@
<template>
<SwitchGroup as="li" class="py-4 flex items-center justify-between">
<div class="flex flex-col">
<SwitchLabel
as="p"
class="p-0 mb-1 text-sm leading-snug text-black font-medium"
passive
>
{{ title }}
</SwitchLabel>
<SwitchDescription class="text-sm text-gray-500">
{{ description }}
</SwitchDescription>
</div>
<Switch
:model-value="modelValue"
:class="[
modelValue ? 'bg-primary-500' : 'bg-gray-200',
'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500',
]"
@update:modelValue="onUpdate"
>
<span
aria-hidden="true"
:class="[
modelValue ? 'translate-x-5' : 'translate-x-0',
'inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
]"
/>
</Switch>
</SwitchGroup>
</template>
<script setup>
import {
Switch,
SwitchDescription,
SwitchGroup,
SwitchLabel,
} from '@headlessui/vue'
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
modelValue: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
function onUpdate(value) {
emit('update:modelValue', value)
}
</script>

View File

@ -0,0 +1,29 @@
<template>
<TabPanel :class="[tabPanelContainer, 'focus:outline-none']">
<!-- focus:ring-1 focus:ring-jet focus:ring-opacity-60 -->
<slot />
</TabPanel>
</template>
<script setup>
import { TabPanel } from '@headlessui/vue'
const props = defineProps({
title: {
type: [String, Number],
default: 'Tab',
},
count: {
type: [String, Number],
default: '',
},
countVariant: {
type: [String, Number],
default: '',
},
tabPanelContainer: {
type: String,
default: 'py-4 mt-px',
},
})
</script>

View File

@ -0,0 +1,72 @@
<template>
<div>
<TabGroup :default-index="defaultIndex" @change="onChange">
<TabList
:class="[
'flex border-b border-grey-light',
'relative overflow-x-auto overflow-y-hidden',
'lg:pb-0 lg:ml-0',
]"
>
<Tab
v-for="(tab, index) in tabs"
v-slot="{ selected }"
:key="index"
as="template"
>
<button
:class="[
'px-8 py-2 text-sm leading-5 font-medium flex items-center relative border-b-2 mt-4 focus:outline-none whitespace-nowrap',
selected
? ' border-primary-400 text-black font-medium'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
]"
>
{{ tab.title }}
<BaseBadge
v-if="tab.count"
class="!rounded-full overflow-hidden ml-2"
:variant="tab['count-variant']"
default-class="flex items-center justify-center w-5 h-5 p-1 rounded-full text-medium"
>
{{ tab.count }}
</BaseBadge>
</button>
</Tab>
</TabList>
<slot name="before-tabs" />
<TabPanels>
<slot />
</TabPanels>
</TabGroup>
</div>
</template>
<script setup>
import { computed, useSlots } from 'vue'
import { TabGroup, TabList, Tab, TabPanels } from '@headlessui/vue'
const props = defineProps({
defaultIndex: {
type: Number,
default: 0,
},
filter: {
type: String,
default: null,
},
})
const emit = defineEmits(['change'])
const slots = useSlots()
const tabs = computed(() => slots.default().map((tab) => tab.props))
function onChange(d) {
emit('change', tabs.value[d])
}
</script>

View File

@ -0,0 +1,105 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
class="w-full"
:style="`height: ${loadingPlaceholderSize}px`"
/>
</BaseContentPlaceholders>
<textarea
v-else
v-bind="$attrs"
ref="textarea"
:value="modelValue"
:class="[defaultInputClass, inputBorderClass]"
:disabled="disabled"
@input="onInput"
/>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
row: {
type: Number,
default: null,
},
invalid: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
modelValue: {
type: [String, Number],
default: '',
},
defaultInputClass: {
type: String,
default:
'box-border w-full px-3 py-2 text-sm not-italic font-normal leading-snug text-left text-black placeholder-gray-400 bg-white border border-gray-200 border-solid rounded outline-none',
},
autosize: {
type: Boolean,
default: false,
},
borderless: {
type: Boolean,
default: false,
},
})
const textarea = ref(null)
const inputBorderClass = computed(() => {
if (props.invalid && !props.borderless) {
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
} else if (!props.borderless) {
return 'focus:ring-primary-400 focus:border-primary-400'
}
return 'border-none outline-none focus:ring-primary-400 focus:border focus:border-primary-400'
})
const loadingPlaceholderSize = computed(() => {
switch (props.row) {
case 2:
return '56'
case 4:
return '94'
default:
return '56'
}
})
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
emit('update:modelValue', e.target.value)
if (props.autosize) {
e.target.style.height = 'auto'
e.target.style.height = `${e.target.scrollHeight}px`
}
}
onMounted(() => {
if (textarea.value && props.autosize) {
textarea.value.style.height = textarea.value.scrollHeight + 'px'
if (textarea.value.style.overflow && textarea.value.style.overflow.y) {
textarea.value.style.overflow.y = 'hidden'
}
textarea.value.style.resize = 'none'
}
})
</script>

View File

@ -0,0 +1,138 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
:class="`w-full ${computedContainerClass}`"
style="height: 38px"
/>
</BaseContentPlaceholders>
<div v-else :class="computedContainerClass" class="relative flex flex-row">
<svg
v-if="clockIcon && !hasIconSlot"
xmlns="http://www.w3.org/2000/svg"
class="
absolute
top-px
w-4
h-4
mx-2
my-2.5
text-sm
not-italic
font-black
text-gray-400
cursor-pointer
"
viewBox="0 0 20 20"
fill="currentColor"
@click="onClickPicker"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clip-rule="evenodd"
/>
</svg>
<slot v-if="clockIcon && hasIconSlot" name="icon" />
<FlatPickr
ref="dpt"
v-model="time"
v-bind="$attrs"
:disabled="disabled"
:config="config"
:class="[defaultInputClass, inputInvalidClass, inputDisabledClass]"
/>
</div>
</template>
<script setup>
import FlatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import { computed, reactive, useSlots, ref } from 'vue'
const dpt = ref(null)
const props = defineProps({
modelValue: {
type: [String, Date],
default: () => moment(new Date()),
},
contentLoading: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: null,
},
invalid: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
containerClass: {
type: String,
default: '',
},
clockIcon: {
type: Boolean,
default: true,
},
defaultInputClass: {
type: String,
default:
'font-base pl-8 py-2 outline-none focus:ring-primary-400 focus:outline-none focus:border-primary-400 block w-full sm:text-sm border-gray-300 rounded-md text-black',
},
})
const emit = defineEmits(['update:modelValue'])
const slots = useSlots()
let config = reactive({
enableTime: true,
noCalendar: true,
dateFormat: 'H:i',
time_24hr: true,
})
const time = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const hasIconSlot = computed(() => {
return !!slots.icon
})
function onClickPicker(params) {
dpt.value.fp.open()
}
const computedContainerClass = computed(() => {
let containerClass = `${props.containerClass} `
return containerClass
})
const inputInvalidClass = computed(() => {
if (props.invalid) {
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
}
return ''
})
const inputDisabledClass = computed(() => {
if (props.disabled) {
return 'border border-solid rounded-md outline-none input-field box-border-2 base-date-picker-input placeholder-gray-400 bg-gray-300 text-gray-600 border-gray-300'
}
return ''
})
</script>

View File

@ -0,0 +1,36 @@
<template>
<div class="w-full">
<slot name="nav">
<WizardNavigation
:current-step="currentStep"
:steps="steps"
@click="(stepIndex) => $emit('click', stepIndex)"
/>
</slot>
<div :class="wizardStepsContainerClass">
<slot />
</div>
</div>
</template>
<script setup>
import WizardNavigation from './BaseWizardNavigation.vue'
const props = defineProps({
wizardStepsContainerClass: {
type: String,
default: 'relative flex items-center justify-center',
},
currentStep: {
type: Number,
default: 0,
},
steps: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['click'])
</script>

View File

@ -0,0 +1,100 @@
<template>
<div
:class="containerClass"
class="
relative
after:bg-gray-200
after:absolute
after:transform
after:top-1/2
after:-translate-y-1/2
after:h-2
after:w-full
"
>
<a
v-for="(number, index) in steps"
:key="index"
:class="stepStyle(number)"
class="z-10"
href="#"
@click.prevent="$emit('click', index)"
>
<svg
v-if="currentStep > number"
:class="iconClass"
fill="currentColor"
viewBox="0 0 20 20"
@click="$emit('click', index)"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
</a>
</div>
</template>
<script>
export default {
props: {
currentStep: {
type: Number,
default: null,
},
steps: {
type: Number,
default: null,
},
containerClass: {
type: String,
default: 'flex justify-between w-full my-10 max-w-xl mx-auto',
},
progress: {
type: String,
default: 'rounded-full float-left w-6 h-6 border-4 cursor-pointer',
},
currentStepClass: {
type: String,
default: 'bg-white border-primary-500',
},
nextStepClass: {
type: String,
default: 'border-gray-200 bg-white',
},
previousStepClass: {
type: String,
default:
'bg-primary-500 border-primary-500 flex justify-center items-center',
},
iconClass: {
type: String,
default:
'flex items-center justify-center w-full h-full text-sm font-black text-center text-white',
},
},
emits: ['click'],
setup(props) {
function stepStyle(number) {
if (props.currentStep === number) {
return [props.currentStepClass, props.progress]
}
if (props.currentStep > number) {
return [props.previousStepClass, props.progress]
}
if (props.currentStep < number) {
return [props.nextStepClass, props.progress]
}
return [props.progress]
}
return {
stepStyle,
}
},
}
</script>

View File

@ -0,0 +1,40 @@
<template>
<div :class="stepContainerClass">
<div v-if="title || description">
<p v-if="title" :class="stepTitleClass">
{{ title }}
</p>
<p v-if="description" :class="stepDescriptionClass">
{{ description }}
</p>
</div>
<slot />
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
stepContainerClass: {
type: String,
default:
'w-full p-8 mb-8 bg-white border border-gray-200 border-solid rounded',
},
stepTitleClass: {
type: String,
default: 'text-2xl not-italic font-semibold leading-7 text-black',
},
stepDescriptionClass: {
type: String,
default:
'w-full mt-2.5 mb-8 text-sm not-italic leading-snug text-gray-500 lg:w-7/12 md:w-7/12 sm:w-7/12',
},
})
</script>

View File

@ -0,0 +1,672 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
class="w-full"
style="height: 200px"
/>
</BaseContentPlaceholders>
<div
v-else
class="
box-border
w-full
text-sm
leading-8
text-left
bg-white
border border-gray-200
rounded-md
min-h-[200px]
overflow-hidden
"
>
<div v-if="editor" class="editor-content">
<div class="flex justify-end p-2 border-b border-gray-200 md:hidden">
<BaseDropdown width-class="w-48">
<template #activator>
<div
class="
flex
items-center
justify-center
w-6
h-6
ml-2
text-sm text-black
bg-white
rounded-sm
md:h-9 md:w-9
"
>
<dots-vertical-icon class="w-6 h-6 text-gray-600" />
</div>
</template>
<div class="flex flex-wrap space-x-1">
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
<bold-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
<italic-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
>
<strikethrough-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('code') }"
@click="editor.chain().focus().toggleCode().run()"
>
<coding-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('paragraph') }"
@click="editor.chain().focus().setParagraph().run()"
>
<paragraph-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{
'bg-gray-200': editor.isActive('heading', { level: 1 }),
}"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
>
H1
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{
'bg-gray-200': editor.isActive('heading', { level: 2 }),
}"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>
H2
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{
'bg-gray-200': editor.isActive('heading', { level: 3 }),
}"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
>
H3
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()"
>
<list-ul-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('orderedList') }"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<list-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('blockquote') }"
@click="editor.chain().focus().toggleBlockquote().run()"
>
<quote-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('codeBlock') }"
@click="editor.chain().focus().toggleCodeBlock().run()"
>
<code-block-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('undo') }"
@click="editor.chain().focus().undo().run()"
>
<undo-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('redo') }"
@click="editor.chain().focus().redo().run()"
>
<redo-icon class="h-3 cursor-pointer fill-current" />
</span>
</div>
</BaseDropdown>
</div>
<div class="hidden p-2 border-b border-gray-200 md:flex">
<div class="flex flex-wrap space-x-1">
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
<bold-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
<italic-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
>
<strikethrough-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('code') }"
@click="editor.chain().focus().toggleCode().run()"
>
<coding-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('paragraph') }"
@click="editor.chain().focus().setParagraph().run()"
>
<paragraph-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 1 }) }"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
>
H1
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>
H2
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 3 }) }"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
>
H3
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()"
>
<list-ul-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('orderedList') }"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<list-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('blockquote') }"
@click="editor.chain().focus().toggleBlockquote().run()"
>
<quote-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('codeBlock') }"
@click="editor.chain().focus().toggleCodeBlock().run()"
>
<code-block-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('undo') }"
@click="editor.chain().focus().undo().run()"
>
<undo-icon class="h-3 cursor-pointer fill-current" />
</span>
<span
class="
flex
items-center
justify-center
w-6
h-6
rounded-sm
cursor-pointer
hover:bg-gray-100
"
:class="{ 'bg-gray-200': editor.isActive('redo') }"
@click="editor.chain().focus().redo().run()"
>
<redo-icon class="h-3 cursor-pointer fill-current" />
</span>
</div>
</div>
<editor-content
:editor="editor"
class="
box-border
relative
w-full
text-sm
leading-8
text-left
editor__content
"
/>
</div>
</div>
</template>
<script>
import { onUnmounted, watch } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { DotsVerticalIcon } from '@heroicons/vue/outline'
import {
BoldIcon,
CodingIcon,
ItalicIcon,
ListIcon,
ListUlIcon,
ParagraphIcon,
QuoteIcon,
StrikethroughIcon,
UndoIcon,
RedoIcon,
CodeBlockIcon,
} from './icons/index.js'
export default {
components: {
EditorContent,
BoldIcon,
CodingIcon,
ItalicIcon,
ListIcon,
ListUlIcon,
ParagraphIcon,
QuoteIcon,
StrikethroughIcon,
UndoIcon,
RedoIcon,
CodeBlockIcon,
DotsVerticalIcon,
},
props: {
modelValue: {
type: String,
default: '',
},
contentLoading: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const editor = useEditor({
content: props.modelValue,
extensions: [StarterKit],
onUpdate: () => {
emit('update:modelValue', editor.value.getHTML())
},
})
watch(
() => props.modelValue,
(value) => {
const isSame = editor.value.getHTML() === value
if (isSame) {
return
}
editor.value.commands.setContent(props.modelValue, false)
}
)
onUnmounted(() => {
setTimeout(() => {
editor.value.destroy()
}, 500)
})
return {
editor,
}
},
}
</script>
<style lang="scss">
.ProseMirror {
min-height: 200px;
padding: 8px 12px;
outline: none;
@apply rounded-md rounded-tl-none rounded-tr-none border border-transparent;
h1 {
font-size: 2em;
font-weight: bold;
}
h2 {
font-size: 1.5em;
font-weight: bold;
}
h3 {
font-size: 1.17em;
font-weight: bold;
}
ul {
padding: 0 1rem;
list-style: disc !important;
}
ol {
padding: 0 1rem;
list-style: auto !important;
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0d0d0d, 0.1);
}
code {
background-color: rgba(97, 97, 97, 0.1);
color: #616161;
border-radius: 0.4rem;
font-size: 0.9rem;
padding: 0.1rem 0.3rem;
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}
}
.ProseMirror:focus {
@apply border border-primary-400 ring-primary-400;
}
</style>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M17.194 10.962A6.271 6.271 0 0012.844.248H4.3a1.25 1.25 0 000 2.5h1.013a.25.25 0 01.25.25V21a.25.25 0 01-.25.25H4.3a1.25 1.25 0 100 2.5h9.963a6.742 6.742 0 002.93-12.786zm-4.35-8.214a3.762 3.762 0 010 7.523H8.313a.25.25 0 01-.25-.25V3a.25.25 0 01.25-.25zm1.42 18.5H8.313a.25.25 0 01-.25-.25v-7.977a.25.25 0 01.25-.25h5.951a4.239 4.239 0 010 8.477z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M9.147 21.552a1.244 1.244 0 01-.895-.378L.84 13.561a2.257 2.257 0 010-3.125l7.412-7.613a1.25 1.25 0 011.791 1.744l-6.9 7.083a.5.5 0 000 .7l6.9 7.082a1.25 1.25 0 01-.9 2.122zm5.707 0a1.25 1.25 0 01-.9-2.122l6.9-7.083a.5.5 0 000-.7l-6.9-7.082a1.25 1.25 0 011.791-1.744l7.411 7.612a2.257 2.257 0 010 3.125l-7.412 7.614a1.244 1.244 0 01-.89.38zm6.514-9.373z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M9.147 21.552a1.244 1.244 0 01-.895-.378L.84 13.561a2.257 2.257 0 010-3.125l7.412-7.613a1.25 1.25 0 011.791 1.744l-6.9 7.083a.5.5 0 000 .7l6.9 7.082a1.25 1.25 0 01-.9 2.122zm5.707 0a1.25 1.25 0 01-.9-2.122l6.9-7.083a.5.5 0 000-.7l-6.9-7.082a1.25 1.25 0 011.791-1.744l7.411 7.612a2.257 2.257 0 010 3.125l-7.412 7.614a1.244 1.244 0 01-.89.38zm6.514-9.373z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M22.5.248h-7.637a1.25 1.25 0 000 2.5h1.086a.25.25 0 01.211.384L4.78 21.017a.5.5 0 01-.422.231H1.5a1.25 1.25 0 000 2.5h7.637a1.25 1.25 0 000-2.5H8.051a.25.25 0 01-.211-.384L19.22 2.98a.5.5 0 01.422-.232H22.5a1.25 1.25 0 000-2.5z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M7.75 4.5h15a1 1 0 000-2h-15a1 1 0 000 2zm15 6.5h-15a1 1 0 100 2h15a1 1 0 000-2zm0 8.5h-15a1 1 0 000 2h15a1 1 0 000-2zM2.212 17.248a2 2 0 00-1.933 1.484.75.75 0 101.45.386.5.5 0 11.483.63.75.75 0 100 1.5.5.5 0 11-.482.635.75.75 0 10-1.445.4 2 2 0 103.589-1.648.251.251 0 010-.278 2 2 0 00-1.662-3.111zm2.038-6.5a2 2 0 00-4 0 .75.75 0 001.5 0 .5.5 0 011 0 1.031 1.031 0 01-.227.645L.414 14.029A.75.75 0 001 15.248h2.5a.75.75 0 000-1.5h-.419a.249.249 0 01-.195-.406L3.7 12.33a2.544 2.544 0 00.55-1.582zM4 5.248h-.25A.25.25 0 013.5 5V1.623A1.377 1.377 0 002.125.248H1.5a.75.75 0 000 1.5h.25A.25.25 0 012 2v3a.25.25 0 01-.25.25H1.5a.75.75 0 000 1.5H4a.75.75 0 000-1.5z"
></path>
</svg>
</template>

View File

@ -0,0 +1,10 @@
<template>
<svg viewBox="0 0 24 24">
<circle cx="2.5" cy="3.998" r="2.5"></circle>
<path d="M8.5 5H23a1 1 0 000-2H8.5a1 1 0 000 2z"></path>
<circle cx="2.5" cy="11.998" r="2.5"></circle>
<path d="M23 11H8.5a1 1 0 000 2H23a1 1 0 000-2z"></path>
<circle cx="2.5" cy="19.998" r="2.5"></circle>
<path d="M23 19H8.5a1 1 0 000 2H23a1 1 0 000-2z"></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M22.5.248H7.228a6.977 6.977 0 100 13.954h2.318a.25.25 0 01.25.25V22.5a1.25 1.25 0 002.5 0V3a.25.25 0 01.25-.25h3.682a.25.25 0 01.25.25v19.5a1.25 1.25 0 002.5 0V3a.249.249 0 01.25-.25H22.5a1.25 1.25 0 000-2.5zM9.8 11.452a.25.25 0 01-.25.25H7.228a4.477 4.477 0 110-8.954h2.318A.25.25 0 019.8 3z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M18.559 3.932a4.942 4.942 0 100 9.883 4.609 4.609 0 001.115-.141.25.25 0 01.276.368 6.83 6.83 0 01-5.878 3.523 1.25 1.25 0 000 2.5 9.71 9.71 0 009.428-9.95V8.873a4.947 4.947 0 00-4.941-4.941zm-12.323 0a4.942 4.942 0 000 9.883 4.6 4.6 0 001.115-.141.25.25 0 01.277.368 6.83 6.83 0 01-5.878 3.523 1.25 1.25 0 000 2.5 9.711 9.711 0 009.428-9.95V8.873a4.947 4.947 0 00-4.942-4.941z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M22.608.161a.5.5 0 00-.545.108L19.472 2.86a.25.25 0 01-.292.045 12.537 12.537 0 00-12.966.865A12.259 12.259 0 006.1 23.632a1.25 1.25 0 001.476-2.018 9.759 9.759 0 01.091-15.809 10 10 0 019.466-1.1.25.25 0 01.084.409l-1.85 1.85a.5.5 0 00.354.853h6.7a.5.5 0 00.5-.5V.623a.5.5 0 00-.313-.462z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M23.75 12.952A1.25 1.25 0 0022.5 11.7h-8.936a.492.492 0 01-.282-.09c-.722-.513-1.482-.981-2.218-1.432-2.8-1.715-4.5-2.9-4.5-4.863 0-2.235 2.207-2.569 3.523-2.569a4.54 4.54 0 013.081.764 2.662 2.662 0 01.447 1.99v.3a1.25 1.25 0 102.5 0v-.268a4.887 4.887 0 00-1.165-3.777C13.949.741 12.359.248 10.091.248c-3.658 0-6.023 1.989-6.023 5.069 0 2.773 1.892 4.512 4 5.927a.25.25 0 01-.139.458H1.5a1.25 1.25 0 000 2.5h10.977a.251.251 0 01.159.058 4.339 4.339 0 011.932 3.466c0 3.268-3.426 3.522-4.477 3.522-1.814 0-3.139-.405-3.834-1.173a3.394 3.394 0 01-.65-2.7 1.25 1.25 0 00-2.488-.246A5.76 5.76 0 004.4 21.753c1.2 1.324 3.114 2 5.688 2 4.174 0 6.977-2.42 6.977-6.022a6.059 6.059 0 00-.849-3.147.25.25 0 01.216-.377H22.5a1.25 1.25 0 001.25-1.255z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M22.5 21.248h-21a1.25 1.25 0 000 2.5h21a1.25 1.25 0 000-2.5zM1.978 2.748h1.363a.25.25 0 01.25.25v8.523a8.409 8.409 0 0016.818 0V3a.25.25 0 01.25-.25h1.363a1.25 1.25 0 000-2.5H16.3a1.25 1.25 0 000 2.5h1.363a.25.25 0 01.25.25v8.523a5.909 5.909 0 01-11.818 0V3a.25.25 0 01.25-.25H7.7a1.25 1.25 0 100-2.5H1.978a1.25 1.25 0 000 2.5z"
></path>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24">
<path
d="M17.786 3.77a12.542 12.542 0 00-12.965-.865.249.249 0 01-.292-.045L1.937.269A.507.507 0 001.392.16a.5.5 0 00-.308.462v6.7a.5.5 0 00.5.5h6.7a.5.5 0 00.354-.854L6.783 5.115a.253.253 0 01-.068-.228.249.249 0 01.152-.181 10 10 0 019.466 1.1 9.759 9.759 0 01.094 15.809 1.25 1.25 0 001.473 2.016 12.122 12.122 0 005.013-9.961 12.125 12.125 0 00-5.127-9.9z"
></path>
</svg>
</template>

View File

@ -0,0 +1,27 @@
import UnderlineIcon from './UnderlineIcon.vue'
import BoldIcon from './BoldIcon.vue'
import CodingIcon from './CodingIcon.vue'
import ItalicIcon from './ItalicIcon.vue'
import ListIcon from './ListIcon.vue'
import ListUlIcon from './ListUlIcon.vue'
import ParagraphIcon from './ParagraphIcon.vue'
import QuoteIcon from './QuoteIcon.vue'
import StrikethroughIcon from './StrikethroughIcon.vue'
import UndoIcon from './UndoIcon.vue'
import RedoIcon from './RedoIcon.vue'
import CodeBlockIcon from './CodeBlockIcon.vue'
export {
UnderlineIcon,
BoldIcon,
CodingIcon,
ItalicIcon,
ListIcon,
ListUlIcon,
ParagraphIcon,
QuoteIcon,
StrikethroughIcon,
UndoIcon,
RedoIcon,
CodeBlockIcon
}

View File

@ -0,0 +1,351 @@
<template>
<div class="flex flex-col">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8 pb-4 lg:pb-0">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div
class="
relative
overflow-hidden
bg-white
border-b border-gray-200
shadow
sm:rounded-lg
"
>
<slot name="header" />
<table :class="tableClass">
<thead :class="theadClass">
<tr>
<th
v-for="column in tableColumns"
:key="column.key"
:class="[
getThClass(column),
{
'text-bold text-black': sort.fieldName === column.key,
},
]"
@click="changeSorting(column)"
>
{{ column.label }}
<span
v-if="sort.fieldName === column.key && sort.order === 'asc'"
class="asc-direction"
>
</span>
<span
v-if="
sort.fieldName === column.key && sort.order === 'desc'
"
class="desc-direction"
>
</span>
</th>
</tr>
</thead>
<tbody
v-if="loadingType === 'placeholder' && (loading || isLoading)"
>
<tr
v-for="placeRow in placeholderCount"
:key="placeRow"
:class="placeRow % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
>
<td
v-for="column in columns"
:key="column.key"
class=""
:class="getTdClass(column)"
>
<base-content-placeholders
:class="getPlaceholderClass(column)"
:rounded="true"
>
<base-content-placeholders-text
class="w-full h-6"
:lines="1"
/>
</base-content-placeholders>
</td>
</tr>
</tbody>
<tbody v-else>
<tr
v-for="(row, index) in sortedRows"
:key="index"
:class="index % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
>
<td
v-for="column in columns"
:key="column.key"
class=""
:class="getTdClass(column)"
>
<slot :name="'cell-' + column.key" :row="row">
{{ lodashGet(row.data, column.key) }}
</slot>
</td>
</tr>
</tbody>
</table>
<div
v-if="loadingType === 'spinner' && (loading || isLoading)"
class="
absolute
top-0
left-0
z-10
flex
items-center
justify-center
w-full
h-full
bg-white bg-opacity-60
"
>
<SpinnerIcon class="w-10 h-10 text-primary-500" />
</div>
<div
v-else-if="
!loading && !isLoading && sortedRows && sortedRows.length === 0
"
class="
text-center text-gray-500
pb-2
flex
h-[160px]
justify-center
items-center
flex-col
"
>
<BaseIcon
name="ExclamationCircleIcon"
class="w-6 h-6 text-gray-400"
/>
<span class="block mt-1">{{ noResultsMessage }}</span>
</div>
<BaseTablePagination
v-if="pagination"
:pagination="pagination"
@pageChange="pageChange"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch, ref, reactive } from 'vue'
import { get } from 'lodash'
import Row from './Row'
import Column from './Column'
import BaseTablePagination from './BaseTablePagination.vue'
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
const props = defineProps({
columns: {
type: Array,
required: true,
},
data: {
type: [Array, Function],
required: true,
},
sortBy: { type: String, default: '' },
sortOrder: { type: String, default: '' },
tableClass: {
type: String,
default: 'min-w-full divide-y divide-gray-200',
},
theadClass: { type: String, default: 'bg-gray-50' },
tbodyClass: { type: String, default: '' },
noResultsMessage: {
type: String,
default: 'No Results Found',
},
loading: {
type: Boolean,
default: false,
},
loadingType: {
type: String,
default: 'placeholder',
validator: function (value) {
return ['placeholder', 'spinner'].indexOf(value) !== -1
},
},
placeholderCount: {
type: Number,
default: 3,
},
})
let rows = reactive([])
let isLoading = ref(false)
let tableColumns = reactive(props.columns.map((column) => new Column(column)))
let sort = reactive({
fieldName: '',
order: '',
})
let pagination = ref('')
const usesLocalData = computed(() => {
return Array.isArray(props.data)
})
const sortedRows = computed(() => {
if (!usesLocalData.value) {
return rows.value
}
if (sort.fieldName === '') {
return rows.value
}
if (tableColumns.length === 0) {
return rows.value
}
const sortColumn = getColumn(sort.fieldName)
if (!sortColumn) {
return rows.value
}
let sorted = [...rows.value].sort(
sortColumn.getSortPredicate(sort.order, tableColumns)
)
return sorted
})
function getColumn(columnName) {
return tableColumns.find((column) => column.key === columnName)
}
function getThClass(column) {
let classes =
'whitespace-nowrap px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'
if (column.defaultThClass) {
classes = column.defaultThClass
}
if (column.sortable) {
classes = `${classes} cursor-pointer`
} else {
classes = `${classes} pointer-events-none`
}
if (column.thClass) {
classes = `${classes} ${column.thClass}`
}
return classes
}
function getTdClass(column) {
let classes = 'px-6 py-4 text-sm text-gray-500 whitespace-nowrap'
if (column.defaultTdClass) {
classes = column.defaultTdClass
}
if (column.tdClass) {
classes = `${classes} ${column.tdClass}`
}
return classes
}
function getPlaceholderClass(column) {
let classes = 'w-full'
if (column.placeholderClass) {
classes = `${classes} ${column.placeholderClass}`
}
return classes
}
function prepareLocalData() {
pagination.value = null
return props.data
}
async function fetchServerData() {
const page = (pagination.value && pagination.value.currentPage) || 1
isLoading.value = true
const response = await props.data({
sort,
page,
})
isLoading.value = false
pagination.value = response.pagination
return response.data
}
function changeSorting(column) {
if (sort.fieldName !== column.key) {
sort.fieldName = column.key
sort.order = 'asc'
} else {
sort.order = sort.order === 'asc' ? 'desc' : 'asc'
}
if (!usesLocalData.value) {
mapDataToRows()
}
}
async function mapDataToRows() {
const data = usesLocalData.value
? prepareLocalData()
: await fetchServerData()
rows.value = data.map((rowData) => new Row(rowData, tableColumns))
}
async function pageChange(page) {
pagination.value.currentPage = page
await mapDataToRows()
}
async function refresh() {
await mapDataToRows()
}
function lodashGet(array, key) {
return get(array, key)
}
if (usesLocalData.value) {
watch(
() => props.data,
() => {
mapDataToRows()
}
)
}
onMounted(async () => {
await mapDataToRows()
})
defineExpose({ refresh })
</script>

View File

@ -0,0 +1,361 @@
<template>
<div
v-if="shouldShowPagination"
class="
flex
items-center
justify-between
px-4
py-3
bg-white
border-t border-gray-200
sm:px-6
"
>
<div class="flex justify-between flex-1 sm:hidden">
<a
href="#"
:class="{
'disabled cursor-normal pointer-events-none !bg-gray-100 !text-gray-400':
pagination.currentPage === 1,
}"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
text-gray-700
bg-white
border border-gray-300
rounded-md
hover:bg-gray-50
"
@click="pageClicked(pagination.currentPage - 1)"
>
Previous
</a>
<a
href="#"
:class="{
'disabled cursor-default pointer-events-none !bg-gray-100 !text-gray-400':
pagination.currentPage === pagination.totalPages,
}"
class="
relative
inline-flex
items-center
px-4
py-2
ml-3
text-sm
font-medium
text-gray-700
bg-white
border border-gray-300
rounded-md
hover:bg-gray-50
"
@click="pageClicked(pagination.currentPage + 1)"
>
Next
</a>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing
{{ ' ' }}
<span
v-if="pagination.limit && pagination.currentPage"
class="font-medium"
>
{{
pagination.currentPage * pagination.limit - (pagination.limit - 1)
}}
</span>
{{ ' ' }}
to
{{ ' ' }}
<span
v-if="pagination.limit && pagination.currentPage"
class="font-medium"
>
<span
v-if="
pagination.currentPage * pagination.limit <=
pagination.totalCount
"
>
{{ pagination.currentPage * pagination.limit }}
</span>
<span v-else>
{{ pagination.totalCount }}
</span>
</span>
{{ ' ' }}
of
{{ ' ' }}
<span v-if="pagination.totalCount" class="font-medium">
{{ pagination.totalCount }}
</span>
{{ ' ' }}
results
</p>
</div>
<div>
<nav
class="relative z-0 inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
>
<a
href="#"
:class="{
'disabled cursor-normal pointer-events-none !bg-gray-100 !text-gray-400':
pagination.currentPage === 1,
}"
class="
relative
inline-flex
items-center
px-2
py-2
text-sm
font-medium
text-gray-500
bg-white
border border-gray-300
rounded-l-md
hover:bg-gray-50
"
@click="pageClicked(pagination.currentPage - 1)"
>
<span class="sr-only">Previous</span>
<BaseIcon name="ChevronLeftIcon" />
</a>
<a
v-if="hasFirst"
href="#"
aria-current="page"
:class="{
'z-10 bg-primary-50 border-primary-500 text-primary-600':
isActive(1),
'bg-white border-gray-300 text-gray-500 hover:bg-gray-50':
!isActive(1),
}"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
border
"
@click="pageClicked(1)"
>
1
</a>
<span
v-if="hasFirstEllipsis"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
text-gray-700
bg-white
border border-gray-300
"
>
...
</span>
<a
v-for="page in pages"
:key="page"
href="#"
:class="{
'z-10 bg-primary-50 border-primary-500 text-primary-600':
isActive(page),
'bg-white border-gray-300 text-gray-500 hover:bg-gray-50':
!isActive(page),
disabled: page === '...',
}"
class="
relative
items-center
hidden
px-4
py-2
text-sm
font-medium
text-gray-500
bg-white
border border-gray-300
hover:bg-gray-50
md:inline-flex
"
@click="pageClicked(page)"
>
{{ page }}
</a>
<span
v-if="hasLastEllipsis"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
text-gray-700
bg-white
border border-gray-300
"
>
...
</span>
<a
v-if="hasLast"
href="#"
aria-current="page"
:class="{
'z-10 bg-primary-50 border-primary-500 text-primary-600':
isActive(pagination.totalPages),
'bg-white border-gray-300 text-gray-500 hover:bg-gray-50':
!isActive(pagination.totalPages),
}"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
border
"
@click="pageClicked(pagination.totalPages)"
>
{{ pagination.totalPages }}
</a>
<a
href="#"
class="
relative
inline-flex
items-center
px-2
py-2
text-sm
font-medium
text-gray-500
bg-white
border border-gray-300
rounded-r-md
hover:bg-gray-50
"
:class="{
'disabled cursor-default pointer-events-none !bg-gray-100 !text-gray-400':
pagination.currentPage === pagination.totalPages,
}"
@click="pageClicked(pagination.currentPage + 1)"
>
<span class="sr-only">Next</span>
<BaseIcon name="ChevronRightIcon" />
</a>
</nav>
</div>
</div>
</div>
</template>
<script>
// Todo: Need to convert this to Composition API
export default {
props: {
pagination: {
type: Object,
default: () => ({}),
},
},
computed: {
pages() {
return this.pagination.totalPages === undefined ? [] : this.pageLinks()
},
hasFirst() {
return this.pagination.currentPage >= 4 || this.pagination.totalPages < 10
},
hasLast() {
return (
this.pagination.currentPage <= this.pagination.totalPages - 3 ||
this.pagination.totalPages < 10
)
},
hasFirstEllipsis() {
return (
this.pagination.currentPage >= 4 && this.pagination.totalPages >= 10
)
},
hasLastEllipsis() {
return (
this.pagination.currentPage <= this.pagination.totalPages - 3 &&
this.pagination.totalPages >= 10
)
},
shouldShowPagination() {
if (this.pagination.totalPages === undefined) {
return false
}
if (this.pagination.count === 0) {
return false
}
return this.pagination.totalPages > 1
},
},
methods: {
isActive(page) {
const currentPage = this.pagination.currentPage || 1
return currentPage === page
},
pageClicked(page) {
if (
page === '...' ||
page === this.pagination.currentPage ||
page > this.pagination.totalPages ||
page < 1
) {
return
}
this.$emit('pageChange', page)
},
pageLinks() {
const pages = []
let left = 2
let right = this.pagination.totalPages - 1
if (this.pagination.totalPages >= 10) {
left = Math.max(1, this.pagination.currentPage - 2)
right = Math.min(
this.pagination.currentPage + 2,
this.pagination.totalPages
)
}
for (let i = left; i <= right; i++) {
pages.push(i)
}
return pages
},
},
}
</script>

View File

@ -0,0 +1,66 @@
import { pick } from './helpers';
export default class Column {
constructor(columnObject) {
const properties = pick(columnObject, [
'key', 'label', 'thClass', 'tdClass', 'sortBy', 'sortable', 'hidden', 'dataType'
]);
for (const property in properties) {
this[property] = columnObject[property];
}
if (!properties['dataType']) {
this['dataType'] = 'string'
}
if (properties['sortable'] === undefined) {
this['sortable'] = true
}
}
getFilterFieldName() {
return this.filterOn || this.key;
}
isSortable() {
return this.sortable;
}
getSortPredicate(sortOrder, allColumns) {
const sortFieldName = this.getSortFieldName();
const sortColumn = allColumns.find(column => column.key === sortFieldName);
const dataType = sortColumn.dataType;
if (dataType.startsWith('date') || dataType === 'numeric') {
return (row1, row2) => {
const value1 = row1.getSortableValue(sortFieldName);
const value2 = row2.getSortableValue(sortFieldName);
if (sortOrder === 'desc') {
return value2 < value1 ? -1 : 1;
}
return value1 < value2 ? -1 : 1;
};
}
return (row1, row2) => {
const value1 = row1.getSortableValue(sortFieldName);
const value2 = row2.getSortableValue(sortFieldName);
if (sortOrder === 'desc') {
return value2.localeCompare(value1);
}
return value1.localeCompare(value2);
};
}
getSortFieldName() {
return this.sortBy || this.key;
}
}

View File

@ -0,0 +1,43 @@
import moment from 'moment';
import { get } from './helpers';
export default class Row {
constructor(data, columns) {
this.data = data;
this.columns = columns;
}
getValue(columnName) {
return get(this.data, columnName);
}
getColumn(columnName) {
return this.columns.find(column => column.key === columnName);
}
getSortableValue(columnName) {
const dataType = this.getColumn(columnName).dataType;
let value = this.getValue(columnName);
if (value === undefined || value === null) {
return '';
}
if (value instanceof String) {
value = value.toLowerCase();
}
if (dataType.startsWith('date')) {
const format = dataType.replace('date:', '');
return moment(value, format).format('YYYYMMDDHHmmss');
}
if (dataType === 'numeric') {
return value;
}
return value.toString();
}
}

Some files were not shown because too many files have changed in this diff Show More