mirror of
https://github.com/crater-invoice/crater.git
synced 2025-10-27 11:41:09 -04:00
v5.0.0 update
This commit is contained in:
5
resources/scripts/App.vue
Normal file
5
resources/scripts/App.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
<BaseDialog />
|
||||
</template>
|
||||
62
resources/scripts/Crater.js
Normal file
62
resources/scripts/Crater.js
Normal 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')
|
||||
}
|
||||
}
|
||||
217
resources/scripts/components/CompanySwitcher.vue
Normal file
217
resources/scripts/components/CompanySwitcher.vue
Normal 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>
|
||||
207
resources/scripts/components/GlobalSearchBar.vue
Normal file
207
resources/scripts/components/GlobalSearchBar.vue
Normal 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>
|
||||
101
resources/scripts/components/SatelliteIcon.vue
Normal file
101
resources/scripts/components/SatelliteIcon.vue
Normal 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>
|
||||
210
resources/scripts/components/SelectNotePopup.vue
Normal file
210
resources/scripts/components/SelectNotePopup.vue
Normal 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>
|
||||
75
resources/scripts/components/base-select/BaseMultiselect.d.ts
vendored
Normal file
75
resources/scripts/components/base-select/BaseMultiselect.d.ts
vendored
Normal 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;
|
||||
646
resources/scripts/components/base-select/BaseMultiselect.vue
Executable file
646
resources/scripts/components/base-select/BaseMultiselect.vue
Executable 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>
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
1
resources/scripts/components/base-select/index.d.ts
vendored
Normal file
1
resources/scripts/components/base-select/index.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export * from './BaseMultiselect';
|
||||
@ -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];
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function isNullish (val) {
|
||||
return [null, undefined, false].indexOf(val) !== -1
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export default function isObject (variable) {
|
||||
return Object.prototype.toString.call(variable) === '[object Object]'
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
29
resources/scripts/components/base/BaseBadge.vue
Normal file
29
resources/scripts/components/base/BaseBadge.vue
Normal 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>
|
||||
13
resources/scripts/components/base/BaseBreadcrumb.vue
Normal file
13
resources/scripts/components/base/BaseBreadcrumb.vue
Normal 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>
|
||||
41
resources/scripts/components/base/BaseBreadcrumbItem.vue
Normal file
41
resources/scripts/components/base/BaseBreadcrumbItem.vue
Normal 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>
|
||||
155
resources/scripts/components/base/BaseButton.vue
Normal file
155
resources/scripts/components/base/BaseButton.vue
Normal 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>
|
||||
39
resources/scripts/components/base/BaseCard.vue
Normal file
39
resources/scripts/components/base/BaseCard.vue
Normal 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>
|
||||
78
resources/scripts/components/base/BaseCheckbox.vue
Normal file
78
resources/scripts/components/base/BaseCheckbox.vue
Normal 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>
|
||||
190
resources/scripts/components/base/BaseContentPlaceholders.vue
Normal file
190
resources/scripts/components/base/BaseContentPlaceholders.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
253
resources/scripts/components/base/BaseCustomInput.vue
Normal file
253
resources/scripts/components/base/BaseCustomInput.vue
Normal 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>
|
||||
16
resources/scripts/components/base/BaseCustomTag.vue
Normal file
16
resources/scripts/components/base/BaseCustomTag.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
533
resources/scripts/components/base/BaseCustomerSelectPopup.vue
Normal file
533
resources/scripts/components/base/BaseCustomerSelectPopup.vue
Normal 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>
|
||||
177
resources/scripts/components/base/BaseDatePicker.vue
Normal file
177
resources/scripts/components/base/BaseDatePicker.vue
Normal 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>
|
||||
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="grid gap-4 mt-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@ -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>
|
||||
181
resources/scripts/components/base/BaseDialog.vue
Normal file
181
resources/scripts/components/base/BaseDialog.vue
Normal 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"
|
||||
>​</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>
|
||||
3
resources/scripts/components/base/BaseDivider.vue
Normal file
3
resources/scripts/components/base/BaseDivider.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<hr class="w-full text-gray-300" />
|
||||
</template>
|
||||
85
resources/scripts/components/base/BaseDropdown.vue
Normal file
85
resources/scripts/components/base/BaseDropdown.vue
Normal 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>
|
||||
17
resources/scripts/components/base/BaseDropdownItem.vue
Normal file
17
resources/scripts/components/base/BaseDropdownItem.vue
Normal 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>
|
||||
31
resources/scripts/components/base/BaseEmptyPlaceholder.vue
Normal file
31
resources/scripts/components/base/BaseEmptyPlaceholder.vue
Normal 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>
|
||||
36
resources/scripts/components/base/BaseErrorAlert.vue
Normal file
36
resources/scripts/components/base/BaseErrorAlert.vue
Normal 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>
|
||||
@ -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>
|
||||
565
resources/scripts/components/base/BaseFileUploader.vue
Normal file
565
resources/scripts/components/base/BaseFileUploader.vue
Normal 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>
|
||||
51
resources/scripts/components/base/BaseFilterWrapper.vue
Normal file
51
resources/scripts/components/base/BaseFilterWrapper.vue
Normal 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>
|
||||
32
resources/scripts/components/base/BaseFormatMoney.vue
Normal file
32
resources/scripts/components/base/BaseFormatMoney.vue
Normal 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>
|
||||
1127
resources/scripts/components/base/BaseGlobalLoader.vue
Normal file
1127
resources/scripts/components/base/BaseGlobalLoader.vue
Normal file
File diff suppressed because it is too large
Load Diff
25
resources/scripts/components/base/BaseHeading.vue
Normal file
25
resources/scripts/components/base/BaseHeading.vue
Normal 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>
|
||||
20
resources/scripts/components/base/BaseIcon.vue
Normal file
20
resources/scripts/components/base/BaseIcon.vue
Normal 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>
|
||||
103
resources/scripts/components/base/BaseInfoAlert.vue
Normal file
103
resources/scripts/components/base/BaseInfoAlert.vue
Normal 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>
|
||||
285
resources/scripts/components/base/BaseInput.vue
Normal file
285
resources/scripts/components/base/BaseInput.vue
Normal 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>
|
||||
24
resources/scripts/components/base/BaseInputGrid.vue
Normal file
24
resources/scripts/components/base/BaseInputGrid.vue
Normal 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>
|
||||
113
resources/scripts/components/base/BaseInputGroup.vue
Normal file
113
resources/scripts/components/base/BaseInputGroup.vue
Normal 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>
|
||||
47
resources/scripts/components/base/BaseInvoiceStatusBadge.vue
Normal file
47
resources/scripts/components/base/BaseInvoiceStatusBadge.vue
Normal 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>
|
||||
193
resources/scripts/components/base/BaseItemSelect.vue
Normal file
193
resources/scripts/components/base/BaseItemSelect.vue
Normal 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>
|
||||
5
resources/scripts/components/base/BaseLabel.vue
Normal file
5
resources/scripts/components/base/BaseLabel.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<label class="text-sm not-italic font-medium leading-5 text-primary-800">
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
143
resources/scripts/components/base/BaseModal.vue
Normal file
143
resources/scripts/components/base/BaseModal.vue
Normal 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"
|
||||
>​</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>
|
||||
93
resources/scripts/components/base/BaseMoney.vue
Normal file
93
resources/scripts/components/base/BaseMoney.vue
Normal 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>
|
||||
19
resources/scripts/components/base/BaseNewBadge.vue
Normal file
19
resources/scripts/components/base/BaseNewBadge.vue
Normal 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>
|
||||
5
resources/scripts/components/base/BasePage.vue
Normal file
5
resources/scripts/components/base/BasePage.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex-1 p-4 md:p-8 flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
resources/scripts/components/base/BasePageHeader.vue
Normal file
23
resources/scripts/components/base/BasePageHeader.vue
Normal 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>
|
||||
39
resources/scripts/components/base/BasePaidStatusBadge.vue
Normal file
39
resources/scripts/components/base/BasePaidStatusBadge.vue
Normal 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>
|
||||
104
resources/scripts/components/base/BaseRadio.vue
Normal file
104
resources/scripts/components/base/BaseRadio.vue
Normal 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>
|
||||
@ -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>
|
||||
12
resources/scripts/components/base/BaseScrollPane.vue
Normal file
12
resources/scripts/components/base/BaseScrollPane.vue
Normal 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>
|
||||
|
||||
18
resources/scripts/components/base/BaseSelectAction.vue
Normal file
18
resources/scripts/components/base/BaseSelectAction.vue
Normal 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>
|
||||
216
resources/scripts/components/base/BaseSelectInput.vue
Normal file
216
resources/scripts/components/base/BaseSelectInput.vue
Normal 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>
|
||||
42
resources/scripts/components/base/BaseSettingCard.vue
Normal file
42
resources/scripts/components/base/BaseSettingCard.vue
Normal 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>
|
||||
69
resources/scripts/components/base/BaseSwitch.vue
Normal file
69
resources/scripts/components/base/BaseSwitch.vue
Normal 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>
|
||||
64
resources/scripts/components/base/BaseSwitchSection.vue
Normal file
64
resources/scripts/components/base/BaseSwitchSection.vue
Normal 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>
|
||||
29
resources/scripts/components/base/BaseTab.vue
Normal file
29
resources/scripts/components/base/BaseTab.vue
Normal 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>
|
||||
72
resources/scripts/components/base/BaseTabGroup.vue
Normal file
72
resources/scripts/components/base/BaseTabGroup.vue
Normal 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>
|
||||
105
resources/scripts/components/base/BaseTextarea.vue
Normal file
105
resources/scripts/components/base/BaseTextarea.vue
Normal 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>
|
||||
138
resources/scripts/components/base/BaseTimePicker.vue
Normal file
138
resources/scripts/components/base/BaseTimePicker.vue
Normal 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>
|
||||
36
resources/scripts/components/base/BaseWizard.vue
Normal file
36
resources/scripts/components/base/BaseWizard.vue
Normal 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>
|
||||
100
resources/scripts/components/base/BaseWizardNavigation.vue
Normal file
100
resources/scripts/components/base/BaseWizardNavigation.vue
Normal 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>
|
||||
40
resources/scripts/components/base/BaseWizardStep.vue
Normal file
40
resources/scripts/components/base/BaseWizardStep.vue
Normal 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>
|
||||
672
resources/scripts/components/base/base-editor/BaseEditor.vue
Normal file
672
resources/scripts/components/base/base-editor/BaseEditor.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
27
resources/scripts/components/base/base-editor/icons/index.js
Normal file
27
resources/scripts/components/base/base-editor/icons/index.js
Normal 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
|
||||
}
|
||||
351
resources/scripts/components/base/base-table/BaseTable.vue
Normal file
351
resources/scripts/components/base/base-table/BaseTable.vue
Normal 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>
|
||||
@ -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>
|
||||
66
resources/scripts/components/base/base-table/Column.js
Normal file
66
resources/scripts/components/base/base-table/Column.js
Normal 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;
|
||||
}
|
||||
}
|
||||
43
resources/scripts/components/base/base-table/Row.js
Normal file
43
resources/scripts/components/base/base-table/Row.js
Normal 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
Reference in New Issue
Block a user