Moved to _dev

This commit is contained in:
2025-09-20 16:11:47 +02:00
parent fb1a8753b7
commit b2ba11fcd3
1670 changed files with 224899 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
<template>
<div class="bg-white shadow overflow-hidden rounded-lg mt-6">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{{ $t('invoices.invoice_information') }}
</h3>
</div>
<div v-if="invoice" class="border-t border-gray-200 px-4 py-5 sm:p-0">
<dl class="sm:divide-y sm:divide-gray-200">
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">
{{ $t('general.from') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{{ invoice.company.name }}
</dd>
</div>
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">
{{ $t('general.to') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{{ invoice.customer.name }}
</dd>
</div>
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500 capitalize">
{{ $t('invoices.paid_status').toLowerCase() }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<BaseInvoiceStatusBadge
:status="invoice.paid_status"
class="px-3 py-1"
>
{{ invoice.paid_status }}
</BaseInvoiceStatusBadge>
</dd>
</div>
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">
{{ $t('invoices.total') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<BaseFormatMoney
:currency="invoice.currency"
:amount="invoice.total"
/>
</dd>
</div>
<div
v-if="invoice.formatted_notes"
class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-500">
{{ $t('invoices.notes') }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span v-html="invoice.formatted_notes"></span>
</dd>
</div>
</dl>
</div>
<div v-else class="w-full flex items-center justify-center p-5">
<BaseSpinner class="text-primary-500 h-10 w-10" />
</div>
</div>
</template>
<script setup>
const props = defineProps({
invoice: {
type: [Object, null],
required: true,
},
})
</script>

View File

@@ -0,0 +1,120 @@
I
<template>
<div class="h-screen overflow-y-auto min-h-0">
<div class="bg-gradient-to-r from-primary-500 to-primary-400 h-5"></div>
<div
class="
relative
p-6
pb-28
px-4
md:px-6
w-full
md:w-auto md:max-w-xl
mx-auto
"
>
<BasePageHeader :title="pageTitle || ''">
<template #actions>
<div
class="
flex flex-col
md:flex-row
absolute
md:relative
bottom-2
left-0
px-4
md:px-0
w-full
md:space-x-4 md:space-y-0
space-y-2
"
>
<a :href="shareableLink" target="_blank" class="block w-full">
<BaseButton
variant="primary-outline"
class="justify-center w-full"
>
{{ $t('general.download_pdf') }}
</BaseButton>
</a>
<BaseButton
v-if="
invoiceData &&
invoiceData.paid_status !== 'PAID' &&
invoiceData.payment_module_enabled
"
variant="primary"
class="justify-center"
@click="payInvoice"
>
{{ $t('general.pay_invoice') }}
</BaseButton>
</div>
</template>
</BasePageHeader>
<InvoiceInformationCard :invoice="invoiceData" />
<div
v-if="!customerLogo"
class="flex items-center justify-center mt-4 text-gray-500 font-normal"
>
Powered by
<a href="https://craterapp.com" target="_blank">
<img :src="getLogo()" class="h-4 ml-1 mb-1" />
</a>
</div>
</div>
</div>
</template>
<script setup>
import axios from 'axios'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import InvoiceInformationCard from '@/scripts/components/InvoiceInformationCard.vue'
let invoiceData = ref(null)
const route = useRoute()
const router = useRouter()
loadInvoice()
async function loadInvoice() {
let res = await axios.get(`/customer/invoices/${route.params.hash}`)
invoiceData.value = res.data.data
}
const shareableLink = computed(() => {
return route.path + '?pdf'
})
function getLogo() {
const imgUrl = new URL('/img/crater-logo-gray.png', import.meta.url)
return imgUrl
}
const customerLogo = computed(() => {
if (window.customer_logo) {
return window.customer_logo
}
return false
})
const pageTitle = computed(() => invoiceData.value?.invoice_number)
function payInvoice() {
router.push({
name: 'invoice.pay',
params: {
hash: route.params.hash,
company: invoiceData.value.company.slug,
},
})
}
</script>

View File

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

View File

@@ -0,0 +1,645 @@
<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: 'grow 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 grow 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 shrink-0 grow-0 transition-transform',
caretOpen: 'rotate-180 pointer-events-auto',
clear:
'pr-3.5 relative z-10 opacity-40 transition duration-300 shrink-0 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 shrink-0 grow-0',
dropdown:
'max-h-60 shadow-lg absolute -left-px -right-px -bottom-1 translate-y-full border border-gray-300 mt-1 overflow-y-auto z-50 bg-white flex flex-col rounded-md',
dropdownTop:
'-translate-y-full -top-2 bottom-auto flex-col-reverse rounded-md',
dropdownHidden: 'hidden',
options: 'flex flex-col p-0 m-0 list-none',
optionsTop: 'flex-col-reverse',
group: 'p-0 m-0',
groupLabel:
'flex text-sm box-border items-center justify-start text-left py-1 px-3 font-semibold bg-gray-200 cursor-default leading-normal',
groupLabelPointable: 'cursor-pointer',
groupLabelPointed: 'bg-gray-300 text-gray-700',
groupLabelSelected: 'bg-primary-600 text-white',
groupLabelDisabled: 'bg-gray-100 text-gray-300 cursor-not-allowed',
groupLabelSelectedPointed: 'bg-primary-600 text-white opacity-90',
groupLabelSelectedDisabled:
'text-primary-100 bg-primary-600 bg-opacity-50 cursor-not-allowed',
groupOptions: 'p-0 m-0',
option:
'flex items-center justify-start box-border text-left cursor-pointer text-sm leading-snug py-2 px-3',
optionPointed: 'text-gray-800 bg-gray-100',
optionSelected: 'text-white bg-primary-500',
optionDisabled: 'text-gray-300 cursor-not-allowed',
optionSelectedPointed: 'text-white bg-primary-500 opacity-90',
optionSelectedDisabled:
'text-primary-100 bg-primary-500 bg-opacity-50 cursor-not-allowed',
noOptions: 'py-2 px-3 text-gray-600 bg-white',
noResults: 'py-2 px-3 text-gray-600 bg-white',
fakeInput:
'bg-transparent absolute left-0 right-0 -bottom-px w-full h-px border-0 p-0 appearance-none outline-none text-transparent',
spacer: 'h-9 py-px box-content',
}),
},
strict: {
type: Boolean,
required: false,
default: true,
},
closeOnSelect: {
type: Boolean,
required: false,
default: true,
},
autocomplete: {
type: String,
required: false,
},
groups: {
type: Boolean,
required: false,
default: false,
},
groupLabel: {
type: String,
required: false,
default: 'label',
},
groupOptions: {
type: String,
required: false,
default: 'options',
},
groupHideEmpty: {
type: Boolean,
required: false,
default: false,
},
groupSelect: {
type: Boolean,
required: false,
default: true,
},
inputType: {
type: String,
required: false,
default: 'text',
},
},
emits: [
'open',
'close',
'select',
'deselect',
'input',
'search-change',
'tag',
'update:modelValue',
'change',
'clear',
],
setup(props, context) {
const value = useValue(props, context)
const pointer = usePointer(props, context)
const dropdown = useDropdown(props, context)
const search = useSearch(props, context)
const data = useData(props, context, {
iv: value.iv,
})
const multiselect = useMultiselect(props, context, {
input: search.input,
open: dropdown.open,
close: dropdown.close,
clearSearch: search.clearSearch,
})
const options = useOptions(props, context, {
ev: value.ev,
iv: value.iv,
search: search.search,
clearSearch: search.clearSearch,
update: data.update,
pointer: pointer.pointer,
clearPointer: pointer.clearPointer,
blur: multiselect.blur,
deactivate: multiselect.deactivate,
})
const pointerAction = usePointerAction(props, context, {
fo: options.fo,
fg: options.fg,
handleOptionClick: options.handleOptionClick,
handleGroupClick: options.handleGroupClick,
search: search.search,
pointer: pointer.pointer,
setPointer: pointer.setPointer,
clearPointer: pointer.clearPointer,
multiselect: multiselect.multiselect,
})
const keyboard = useKeyboard(props, context, {
iv: value.iv,
update: data.update,
search: search.search,
setPointer: pointer.setPointer,
selectPointer: pointerAction.selectPointer,
backwardPointer: pointerAction.backwardPointer,
forwardPointer: pointerAction.forwardPointer,
blur: multiselect.blur,
fo: options.fo,
})
const classes = useClasses(props, context, {
isOpen: dropdown.isOpen,
isPointed: pointerAction.isPointed,
canPointGroups: pointerAction.canPointGroups,
isSelected: options.isSelected,
isDisabled: options.isDisabled,
isActive: multiselect.isActive,
resolving: options.resolving,
fo: options.fo,
})
return {
...value,
...dropdown,
...multiselect,
...pointer,
...data,
...search,
...options,
...pointerAction,
...keyboard,
...classes,
}
},
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
<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>
<p v-if="description" class="text-gray-500">{{ description }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
modelValue: {
type: [Boolean, Array],
default: false,
},
id: {
type: [Number, String],
default: () => `check_${Math.random().toString(36).substr(2, 9)}`,
},
disabled: {
type: Boolean,
default: false,
},
checkboxClass: {
type: String,
default: 'w-4 h-4 border-gray-300 rounded cursor-pointer',
},
setInitialValue: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'change'])
if (props.setInitialValue) {
emit('update:modelValue', props.modelValue)
}
const checked = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
emit('change', value)
},
})
const disabledClass = computed(() => {
if (props.disabled) {
return 'text-gray-300 cursor-not-allowed'
}
return 'text-primary-600 focus:ring-primary-500'
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,258 @@
<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, onMounted } from 'vue'
import { useCustomFieldStore } from '@/scripts/admin/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()
}
)
onMounted(() => {
fetchFields()
})
const value = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value)
},
})
async function fetchFields() {
await customFieldsStore.fetchCustomFields()
}
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' },
...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' },
...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' },
...paymentFields.value.map((i) => ({
label: i.label,
value: i.slug,
})),
],
})
}
if (props.fields.find((field) => field == 'company')) {
fieldList.value.push({
label: 'Company',
fields: [
{ label: 'Company Name', value: 'COMPANY_NAME' },
{ label: 'Country', value: 'COMPANY_COUNTRY' },
{ label: 'State', value: 'COMPANY_STATE' },
{ label: 'City', value: 'COMPANY_CITY' },
{ label: 'Address Street 1', value: 'COMPANY_ADDRESS_STREET_1' },
{ label: 'Address Street 2', value: 'COMPANY_ADDRESS_STREET_2' },
{ label: 'Phone', value: 'COMPANY_PHONE' },
{ label: 'Zip Code', value: 'COMPANY_ZIP_CODE' },
],
})
}
}
}
getFields()
</script>

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
<template>
<BaseMultiselect
v-model="selectedCustomer"
v-bind="$attrs"
track-by="name"
value-prop="id"
label="name"
:filter-results="false"
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/admin/stores/customer'
import { computed, watch } from 'vue'
import { useModalStore } from '@/scripts/stores/modal'
import { useI18n } from 'vue-i18n'
import CustomerModal from '@/scripts/admin/components/modal-components/CustomerModal.vue'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/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)
if(res.data.data.length>0 && customerStore.editCustomer) {
let customerFound = res.data.data.find((c) => c.id==customerStore.editCustomer.id)
if(!customerFound) {
let edit_customer = Object.assign({}, customerStore.editCustomer)
res.data.data.unshift(edit_customer)
}
}
return res.data.data
}
async function addCustomer() {
customerStore.resetCurrentCustomer()
modalStore.openModal({
title: t('customers.add_new_customer'),
componentName: 'CustomerModal',
})
}
</script>

View File

@@ -0,0 +1,535 @@
<template>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox
:rounded="true"
class="w-full"
style="min-height: 170px"
/>
</BaseContentPlaceholders>
<div v-else class="max-h-[173px]">
<CustomerModal />
<!-- <SalesTax :type="type" /> -->
<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">
<BaseText
:text="selectedCustomer.name"
:length="30"
class="flex-1 text-base font-medium text-left text-gray-900"
/>
<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">
<BaseText
v-if="customer.name"
:text="customer.name"
:length="30"
class="
m-0
text-base
font-normal
leading-tight
cursor-pointer
"
/>
<BaseText
v-if="customer.contact_name"
:text="customer.contact_name"
:length="30"
class="
m-0
text-sm
font-medium
text-gray-400
cursor-pointer
"
/>
</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/admin/stores/estimate'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import { useModalStore } from '@/scripts/stores/modal'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useCustomerStore } from '@/scripts/admin/stores/customer'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDebounceFn } from '@vueuse/core'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
import { useRoute } from 'vue-router'
import CustomerModal from '@/scripts/admin/components/modal-components/CustomerModal.vue'
const props = defineProps({
valid: {
type: Object,
default: () => {},
},
customerId: {
type: Number,
default: null,
},
type: {
type: String,
default: null,
},
contentLoading: {
type: Boolean,
default: false,
},
})
const modalStore = useModalStore()
const estimateStore = useEstimateStore()
const customerStore = useCustomerStore()
const globalStore = useGlobalStore()
const invoiceStore = useInvoiceStore()
const recurringInvoiceStore = useRecurringInvoiceStore()
const userStore = useUserStore()
const routes = useRoute()
const { t } = useI18n()
const search = ref(null)
const isSearchingCustomer = ref(false)
const selectedCustomer = computed(() => {
switch (props.type) {
case 'estimate':
return estimateStore.newEstimate.customer
case 'invoice':
return invoiceStore.newInvoice.customer
case 'recurring-invoice':
return recurringInvoiceStore.newRecurringInvoice.customer
default:
return ''
}
})
function resetSelectedCustomer() {
if (props.type === 'estimate') {
estimateStore.resetSelectedCustomer()
} else if (props.type === 'invoice') {
invoiceStore.resetSelectedCustomer()
} else {
recurringInvoiceStore.resetSelectedCustomer()
}
}
if (props.customerId && props.type === 'estimate') {
estimateStore.selectCustomer(props.customerId)
} else if (props.customerId && props.type === 'invoice') {
invoiceStore.selectCustomer(props.customerId)
} else {
if (props.customerId) recurringInvoiceStore.selectCustomer(props.customerId)
}
async function editCustomer() {
await customerStore.fetchCustomer(selectedCustomer.value.id)
modalStore.openModal({
title: t('customers.edit_customer'),
componentName: 'CustomerModal',
})
}
async function fetchInitialCustomers() {
await customerStore.fetchCustomers({
filter: {},
orderByField: '',
orderBy: '',
customer_id: props.customerId,
})
}
const debounceSearchCustomer = useDebounceFn(() => {
isSearchingCustomer.value = true
searchCustomer()
}, 500)
async function searchCustomer() {
let data = {
display_name: search.value,
page: 1,
}
await customerStore.fetchCustomers(data)
isSearchingCustomer.value = false
}
function openCustomerModal() {
modalStore.openModal({
title: t('customers.add_customer'),
componentName: 'CustomerModal',
variant: 'md',
})
}
function initGenerator(name) {
if (name) {
let nameSplit = name.split(' ')
let initials = nameSplit[0].charAt(0).toUpperCase()
return initials
}
}
function selectNewCustomer(id, close) {
let params = {
userId: id,
}
if (routes.params.id) params.model_id = routes.params.id
if (props.type === 'estimate') {
estimateStore.getNextNumber(params, true)
estimateStore.selectCustomer(id)
} else if (props.type === 'invoice') {
invoiceStore.getNextNumber(params, true)
invoiceStore.selectCustomer(id)
} else {
recurringInvoiceStore.selectCustomer(id)
}
close()
search.value = null
}
globalStore.fetchCurrencies()
globalStore.fetchCountries()
fetchInitialCustomers()
</script>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,178 @@
<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
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"
aria-hidden="true"
>&#8203;</span
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
class="
inline-block
px-4
pt-5
pb-4
overflow-hidden
text-left
align-bottom
transition-all
bg-white
rounded-lg
shadow-xl
sm:my-8 sm:align-middle sm:w-full sm:p-6
relative
"
: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 grid gap-3"
:class="{
'sm:grid-cols-2 sm:grid-flow-row-dense':
!dialogStore.hideNoButton,
}"
>
<base-button
class="justify-center"
:variant="dialogStore.variant"
:class="{ 'w-full': dialogStore.hideNoButton }"
@click="resolveDialog(true)"
>
{{ dialogStore.yesLabel }}
</base-button>
<base-button
v-if="!dialogStore.hideNoButton"
class="justify-center"
variant="white"
@click="resolveDialog(false)"
>
{{ dialogStore.noLabel }}
</base-button>
</div>
</div>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { computed } from 'vue'
import { useDialogStore } from '@/scripts/stores/dialog'
import {
Dialog,
DialogOverlay,
DialogTitle,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue'
const dialogStore = useDialogStore()
function resolveDialog(resValue) {
dialogStore.resolve(resValue)
dialogStore.closeDialog()
}
const dialogSizeClasses = computed(() => {
const size = dialogStore.size
switch (size) {
case 'sm':
return 'sm:max-w-sm'
case 'md':
return 'sm:max-w-md'
case 'lg':
return 'sm:max-w-lg'
default:
return 'sm:max-w-md'
}
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,579 @@
<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"
@click="$event.target.value=null"
@change="
onChange(
$event.target.name,
$event.target.files,
$event.target.files.length
)
"
/>
<!-- Avatar Not Selected -->
<div v-if="!localFiles.length && avatar" class="">
<img :src="getDefaultAvatar()" 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>
<p class="text-xs leading-4 text-center text-gray-400 mt-2">
{{ recommendedText }}
</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>
<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: () => [],
},
recommendedText: {
type: String,
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)
emit('remove', index)
}
function getDefaultAvatar() {
const imgUrl = new URL('/img/default-avatar.jpg', import.meta.url)
return imgUrl
}
onMounted(() => {
reset()
})
watch(
() => props.modelValue,
(v) => {
localFiles.value = [...v]
}
)
</script>

View File

@@ -0,0 +1,56 @@
<template>
<transition
enter-active-class="transition duration-500 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in-out"
leave-from-class="opacity-100"
leave-to-class="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"
:class="
rowOnXl
? 'xl:flex-row xl:space-x-4 xl:space-y-0 xl:items-center'
: '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,
},
rowOnXl: {
type: Boolean,
default: false,
},
})
defineEmits(['clear'])
</script>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,21 @@
<template>
<component :is="heroIcons[name]" v-if="isLoaded" 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,
required: true,
},
})
onMounted(() => {
isLoaded.value = true
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,193 @@
<template>
<div class="flex-1 text-sm">
<!-- Selected Item Field -->
<div
v-if="item.item_id"
class="
relative
flex
items-center
h-10
pl-2
bg-gray-200
border border-gray-200 border-solid
rounded
"
>
{{ item.name }}
<span
class="absolute text-gray-400 cursor-pointer top-[8px] right-[10px]"
@click="deselectItem(index)"
>
<BaseIcon name="XCircleIcon" />
</span>
</div>
<!-- Select Item Field -->
<BaseMultiselect
v-else
v-model="itemSelect"
:content-loading="contentLoading"
value-prop="id"
track-by="name"
: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/admin/stores/estimate'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useItemStore } from '@/scripts/admin/stores/item'
import { useModalStore } from '@/scripts/stores/modal'
import { useUserStore } from '@/scripts/admin/stores/user'
import abilities from '@/scripts/admin/stub/abilities'
const props = defineProps({
contentLoading: {
type: Boolean,
default: false,
},
type: {
type: String,
default: null,
},
item: {
type: Object,
required: true,
},
index: {
type: Number,
default: 0,
},
invalid: {
type: Boolean,
required: false,
default: false,
},
invalidDescription: {
type: Boolean,
required: false,
default: false,
},
taxPerItem: {
type: String,
default: '',
},
taxes: {
type: Array,
default: null,
},
store: {
type: Object,
default: null,
},
storeProp: {
type: String,
default: '',
},
})
const emit = defineEmits(['search', 'select'])
const itemStore = useItemStore()
const estimateStore = useEstimateStore()
const invoiceStore = useInvoiceStore()
const modalStore = useModalStore()
const userStore = useUserStore()
let route = useRoute()
const { t } = useI18n()
const itemSelect = ref(null)
const loading = ref(false)
let itemData = reactive({ ...props.item })
Object.assign(itemData, props.item)
const taxAmount = computed(() => {
return 0
})
const description = computed({
get: () => props.item.description,
set: (value) => {
props.store[props.storeProp].items[props.index].description = value
},
})
async function searchItems(search) {
let res = await itemStore.fetchItems({ search })
return res.data.data
}
function onTextChange(val) {
searchItems(val)
emit('search', val)
}
function openItemModal() {
modalStore.openModal({
title: t('items.add_item'),
componentName: 'ItemModal',
refreshData: (val) => emit('select', val),
data: {
taxPerItem: props.taxPerItem,
taxes: props.taxes,
itemIndex: props.index,
store: props.store,
storeProps: props.storeProp,
},
})
}
function deselectItem(index) {
props.store.deselectItem(index)
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
<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'
case 'OVERDUE':
return 'bg-red-300 bg-opacity-50 px-2 py-1 text-sm text-red-900 uppercase font-normal text-center'
default:
return 'bg-gray-500 bg-opacity-25 text-gray-900 uppercase font-normal text-center'
}
})
return { badgeColorClasses }
},
}
</script>

View File

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

View File

@@ -0,0 +1,200 @@
<template>
<div class="star-rating">
<div
v-for="(star, index) in stars"
:key="index"
:title="rating"
class="star-container"
>
<svg
:style="[
{ fill: `url(#gradient${star.raw})` },
{ width: style.starWidth },
{ height: style.starHeight },
]"
class="star-svg"
>
<polygon :points="getStarPoints" style="fill-rule: nonzero" />
<defs>
<!--
id has to be unique to each star fullness(dynamic offset) - it indicates fullness above
-->
<linearGradient :id="`gradient${star.raw}`">
<stop
id="stop1"
:offset="star.percent"
:stop-color="getFullFillColor(star)"
stop-opacity="1"
></stop>
<stop
id="stop2"
:offset="star.percent"
:stop-color="getFullFillColor(star)"
stop-opacity="0"
></stop>
<stop
id="stop3"
:offset="star.percent"
:stop-color="style.emptyStarColor"
stop-opacity="1"
></stop>
<stop
id="stop4"
:stop-color="style.emptyStarColor"
offset="100%"
stop-opacity="1"
></stop>
</linearGradient>
</defs>
</svg>
</div>
<div v-if="isIndicatorActive" class="indicator">{{ rating }}</div>
</div>
</template>
<script>
export default {
name: 'StarsRating',
components: {},
directives: {},
props: {
config: {
type: Object,
default: null,
},
rating: {
type: [Number],
default: 0,
},
},
data: function () {
return {
stars: [],
emptyStar: 0,
fullStar: 1,
totalStars: 5,
isIndicatorActive: false,
style: {
fullStarColor: '#F1C644',
emptyStarColor: '#D4D4D4',
starWidth: 20,
starHeight: 20,
},
}
},
computed: {
getStarPoints: function () {
let centerX = this.style.starWidth / 2
let centerY = this.style.starHeight / 2
let innerCircleArms = 5 // a 5 arms star
let innerRadius = this.style.starWidth / innerCircleArms
let innerOuterRadiusRatio = 2.5 // Unique value - determines fatness/sharpness of star
let outerRadius = innerRadius * innerOuterRadiusRatio
return this.calcStarPoints(
centerX,
centerY,
innerCircleArms,
innerRadius,
outerRadius
)
},
},
created() {
this.initStars()
this.setStars()
this.setConfigData()
},
methods: {
calcStarPoints(
centerX,
centerY,
innerCircleArms,
innerRadius,
outerRadius
) {
let angle = Math.PI / innerCircleArms
let angleOffsetToCenterStar = 60
let totalArms = innerCircleArms * 2
let points = ''
for (let i = 0; i < totalArms; i++) {
let isEvenIndex = i % 2 == 0
let r = isEvenIndex ? outerRadius : innerRadius
let currX = centerX + Math.cos(i * angle + angleOffsetToCenterStar) * r
let currY = centerY + Math.sin(i * angle + angleOffsetToCenterStar) * r
points += currX + ',' + currY + ' '
}
return points
},
initStars() {
for (let i = 0; i < this.totalStars; i++) {
this.stars.push({
raw: this.emptyStar,
percent: this.emptyStar + '%',
})
}
},
setStars() {
let fullStarsCounter = Math.floor(this.rating)
for (let i = 0; i < this.stars.length; i++) {
if (fullStarsCounter !== 0) {
this.stars[i].raw = this.fullStar
this.stars[i].percent = this.calcStarFullness(this.stars[i])
fullStarsCounter--
} else {
let surplus = Math.round((this.rating % 1) * 10) / 10 // Support just one decimal
let roundedOneDecimalPoint = Math.round(surplus * 10) / 10
this.stars[i].raw = roundedOneDecimalPoint
return (this.stars[i].percent = this.calcStarFullness(this.stars[i]))
}
}
},
setConfigData() {
if (this.config) {
this.setBindedProp(this.style, this.config.style, 'fullStarColor')
this.setBindedProp(this.style, this.config.style, 'emptyStarColor')
this.setBindedProp(this.style, this.config.style, 'starWidth')
this.setBindedProp(this.style, this.config.style, 'starHeight')
if (this.config.isIndicatorActive) {
this.isIndicatorActive = this.config.isIndicatorActive
}
console.log('isIndicatorActive: ', this.isIndicatorActive)
}
},
getFullFillColor(starData) {
return starData.raw !== this.emptyStar
? this.style.fullStarColor
: this.style.emptyStarColor
},
calcStarFullness(starData) {
let starFullnessPercent = starData.raw * 100 + '%'
return starFullnessPercent
},
setBindedProp(localProp, propParent, propToBind) {
if (propParent[propToBind]) {
localProp[propToBind] = propParent[propToBind]
}
},
},
}
</script>
<style scoped lang="scss">
.star-rating {
display: flex;
align-items: center;
.star-container {
display: flex;
.star-svg {
}
}
.indicator {
}
.star-container:not(:last-child) {
margin-right: 5px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex flex-col">
<div class="-my-2 overflow-x-auto lg:overflow-visible 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 lg:overflow-visible sm:px-2 lg:p-2">
<slot />
</div>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
<template>
<svg
class="animate-spin"
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>
</template>

View File

@@ -0,0 +1,68 @@
<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
bg-white
rounded-full
"
/>
</Switch>
<SwitchLabel v-if="labelRight" class="ml-4 cursor-pointer">{{
labelRight
}}</SwitchLabel>
</div>
</SwitchGroup>
</template>
<script setup>
import { computed } from 'vue'
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue'
const props = defineProps({
labelLeft: {
type: String,
default: '',
},
labelRight: {
type: String,
default: '',
},
modelValue: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const enabled = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
</script>

View File

@@ -0,0 +1,69 @@
<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
:disabled="disabled"
:model-value="modelValue"
:class="[
modelValue ? 'bg-primary-500' : 'bg-gray-200',
'ml-4 relative inline-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-primary-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 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,
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
function onUpdate(value) {
emit('update:modelValue', value)
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
<template>
<BaseCustomTag :tag="tag" :title="text">
{{ displayText }}
</BaseCustomTag>
</template>
<script setup>
import { computed } from "vue"
const props = defineProps({
tag: {
type: String,
default: 'div',
},
text: {
type: String,
default: '',
},
length: {
type: Number,
default: 0,
}
})
const displayText = computed(() => {
return props.text.length < props.length ? props.text : `${props.text.substring(0 , props.length)}...`
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,755 @@
<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>
<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({ textAlign: 'left' }) }"
@click="editor.chain().focus().setTextAlign('left').run()"
>
<menu-alt2-icon class="h-5 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({ textAlign: 'right' }) }"
@click="editor.chain().focus().setTextAlign('right').run()"
>
<menu-alt3-icon class="h-5 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({ textAlign: 'justify' }),
}"
@click="editor.chain().focus().setTextAlign('justify').run()"
>
<menu-icon class="h-5 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({ textAlign: 'center' }) }"
@click="editor.chain().focus().setTextAlign('center').run()"
>
<menu-center-icon class="h-5 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,
MenuAlt2Icon,
MenuAlt3Icon,
MenuIcon,
} from '@heroicons/vue/outline'
import TextAlign from '@tiptap/extension-text-align'
import {
BoldIcon,
CodingIcon,
ItalicIcon,
ListIcon,
ListUlIcon,
ParagraphIcon,
QuoteIcon,
StrikethroughIcon,
UndoIcon,
RedoIcon,
CodeBlockIcon,
MenuCenterIcon,
} from './icons/index.js'
export default {
components: {
EditorContent,
BoldIcon,
CodingIcon,
ItalicIcon,
ListIcon,
ListUlIcon,
ParagraphIcon,
QuoteIcon,
StrikethroughIcon,
UndoIcon,
RedoIcon,
CodeBlockIcon,
DotsVerticalIcon,
MenuCenterIcon,
MenuAlt2Icon,
MenuAlt3Icon,
MenuIcon,
},
props: {
modelValue: {
type: String,
default: '',
},
contentLoading: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'right', 'center', 'justify'],
}),
],
onUpdate: () => {
emit('update:modelValue', editor.value.getHTML())
},
})
watch(
() => props.modelValue,
(value) => {
const isSame = editor.value.getHTML() === value
if (isSame) {
return
}
editor.value.commands.setContent(props.modelValue, false)
}
)
onUnmounted(() => {
setTimeout(() => {
editor.value.destroy()
}, 500)
})
return {
editor,
}
},
}
</script>
<style lang="scss">
.ProseMirror {
min-height: 200px;
padding: 8px 12px;
outline: none;
@apply rounded-md rounded-tl-none rounded-tr-none border border-transparent;
h1 {
font-size: 2em;
font-weight: bold;
}
h2 {
font-size: 1.5em;
font-weight: bold;
}
h3 {
font-size: 1.17em;
font-weight: bold;
}
ul {
padding: 0 1rem;
list-style: disc !important;
}
ol {
padding: 0 1rem;
list-style: auto !important;
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0d0d0d, 0.1);
}
code {
background-color: rgba(97, 97, 97, 0.1);
color: #616161;
border-radius: 0.4rem;
font-size: 0.9rem;
padding: 0.1rem 0.3rem;
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}
}
.ProseMirror:focus {
@apply border border-primary-400 ring-primary-400;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
<template>
<svg viewBox="0 0 24 24">
<path
fill="currentColor"
fill-rule="evenodd"
d="M3.75 5.25h16.5a.75.75 0 1 1 0 1.5H3.75a.75.75 0 0 1 0-1.5zm4 4h8.5a.75.75 0 1 1 0 1.5h-8.5a.75.75 0 1 1 0-1.5zm-4 4h16.5a.75.75 0 1 1 0 1.5H3.75a.75.75 0 1 1 0-1.5zm4 4h8.5a.75.75 0 1 1 0 1.5h-8.5a.75.75 0 1 1 0-1.5z"
></path>
</svg>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
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'
import MenuCenterIcon from './MenuCenterIcon.vue'
export {
UnderlineIcon,
BoldIcon,
CodingIcon,
ItalicIcon,
ListIcon,
ListUlIcon,
ParagraphIcon,
QuoteIcon,
StrikethroughIcon,
UndoIcon,
RedoIcon,
CodeBlockIcon,
MenuCenterIcon
}

View File

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

View File

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

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