v5.0.0 update

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
export function classList(...classes) {
return classes
.map(c => Array.isArray(c) ? c : [c])
.reduce((classes, c) => classes.concat(c), []);
}
export function get(object, path) {
if (!path) {
return object;
}
if (object === null || typeof object !== 'object') {
return object;
}
const [pathHead, pathTail] = path.split(/\.(.+)/);
return get(object[pathHead], pathTail);
}
export function pick(object, properties) {
return properties.reduce((pickedObject, property) => {
pickedObject[property] = object[property];
return pickedObject;
}, {});
}
export function range(from, to) {
return [...Array(to - from)].map((_, i) => i + from);
}