mirror of
https://github.com/crater-invoice/crater.git
synced 2025-10-27 19:51:09 -04:00
v5.0.0 update
This commit is contained in:
29
resources/scripts/components/base/BaseBadge.vue
Normal file
29
resources/scripts/components/base/BaseBadge.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<span
|
||||
class="
|
||||
px-2
|
||||
py-1
|
||||
text-sm
|
||||
font-normal
|
||||
text-center text-green-800
|
||||
uppercase
|
||||
bg-success
|
||||
"
|
||||
:style="{ backgroundColor: bgColor, color }"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
13
resources/scripts/components/base/BaseBreadcrumb.vue
Normal file
13
resources/scripts/components/base/BaseBreadcrumb.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<nav>
|
||||
<ol class="flex flex-wrap py-4 text-gray-900 rounded list-reset">
|
||||
<slot />
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BaseBreadcrumb',
|
||||
}
|
||||
</script>
|
||||
41
resources/scripts/components/base/BaseBreadcrumbItem.vue
Normal file
41
resources/scripts/components/base/BaseBreadcrumbItem.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<li class="pr-2 text-sm">
|
||||
<router-link
|
||||
class="
|
||||
m-0
|
||||
mr-2
|
||||
text-sm
|
||||
font-medium
|
||||
leading-5
|
||||
text-gray-900
|
||||
outline-none
|
||||
focus:ring-2 focus:ring-offset-2 focus:ring-primary-400
|
||||
"
|
||||
:to="to"
|
||||
>
|
||||
{{ title }}
|
||||
</router-link>
|
||||
|
||||
<span v-if="!active" class="px-1">/</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
let name = 'BaseBreadcrumItem'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: String,
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: '#',
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
155
resources/scripts/components/base/BaseButton.vue
Normal file
155
resources/scripts/components/base/BaseButton.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultClass: {
|
||||
type: String,
|
||||
default:
|
||||
'inline-flex whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
},
|
||||
tag: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: function (value) {
|
||||
return ['xs', 'sm', 'md', 'lg', 'xl'].indexOf(value) !== -1
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: function (value) {
|
||||
return (
|
||||
[
|
||||
'primary',
|
||||
'secondary',
|
||||
'primary-outline',
|
||||
'white',
|
||||
'danger',
|
||||
'gray',
|
||||
].indexOf(value) !== -1
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
return {
|
||||
'px-2.5 py-1.5 text-xs leading-4 rounded': props.size === 'xs',
|
||||
'px-3 py-2 text-sm leading-4 rounded-md': props.size == 'sm',
|
||||
'px-4 py-2 text-sm leading-5 rounded-md': props.size === 'md',
|
||||
'px-4 py-2 text-base leading-6 rounded-md': props.size === 'lg',
|
||||
'px-6 py-3 text-base leading-6 rounded-md': props.size === 'xl',
|
||||
}
|
||||
})
|
||||
|
||||
const placeHolderSize = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'xs':
|
||||
return '32'
|
||||
case 'sm':
|
||||
return '38'
|
||||
case 'md':
|
||||
return '42'
|
||||
case 'lg':
|
||||
return '42'
|
||||
case 'xl':
|
||||
return '46'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const variantClass = computed(() => {
|
||||
return {
|
||||
'border-transparent shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:ring-primary-500':
|
||||
props.variant === 'primary',
|
||||
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500':
|
||||
props.variant === 'secondary',
|
||||
'border-transparent border-solid border-primary-500 font-normal transition ease-in-out duration-150 text-primary-500 hover:bg-primary-200 shadow-inner ':
|
||||
props.variant == 'primary-outline',
|
||||
'border-gray-200 text-gray-700 bg-white hover:bg-gray-50 focus:ring-primary-500 focus:ring-offset-0':
|
||||
props.variant == 'white',
|
||||
'border-transparent shadow-sm text-white bg-red-600 hover:bg-red-700 focus:ring-red-500':
|
||||
props.variant === 'danger',
|
||||
'border-transparent bg-gray-200 border hover:bg-opacity-60 focus:ring-gray-500 focus:ring-offset-0':
|
||||
props.variant === 'gray',
|
||||
}
|
||||
})
|
||||
|
||||
const roundedClass = computed(() => {
|
||||
return props.rounded ? '!rounded-full' : ''
|
||||
})
|
||||
|
||||
const iconLeftClass = computed(() => {
|
||||
return {
|
||||
'-ml-0.5 mr-2 h-4 w-4': props.size == 'sm',
|
||||
'-ml-1 mr-2 h-5 w-5': props.size === 'md',
|
||||
'-ml-1 mr-3 h-5 w-5': props.size === 'lg' || props.size === 'xl',
|
||||
}
|
||||
})
|
||||
|
||||
const iconVariantClass = computed(() => {
|
||||
return {
|
||||
'text-white': props.variant === 'primary',
|
||||
'text-primary-700': props.variant === 'secondary',
|
||||
'text-gray-700': props.variant === 'white',
|
||||
'text-gray-400': props.variant === 'gray',
|
||||
}
|
||||
})
|
||||
|
||||
const iconRightClass = computed(() => {
|
||||
return {
|
||||
'ml-2 -mr-0.5 h-4 w-4': props.size == 'sm',
|
||||
'ml-2 -mr-1 h-5 w-5': props.size === 'md',
|
||||
'ml-3 -mr-1 h-5 w-5': props.size === 'lg' || props.size === 'xl',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContentPlaceholders
|
||||
v-if="contentLoading"
|
||||
class="disabled cursor-normal pointer-events-none"
|
||||
>
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
style="width: 96px"
|
||||
:style="`height: ${placeHolderSize}px;`"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<BaseCustomTag
|
||||
v-else
|
||||
:tag="tag"
|
||||
:disabled="disabled"
|
||||
:class="[defaultClass, sizeClass, variantClass, roundedClass]"
|
||||
>
|
||||
<SpinnerIcon v-if="loading" :class="[iconLeftClass, iconVariantClass]" />
|
||||
|
||||
<slot v-else name="left" :class="iconLeftClass"></slot>
|
||||
|
||||
<slot />
|
||||
|
||||
<slot name="right" :class="[iconRightClass, iconVariantClass]"></slot>
|
||||
</BaseCustomTag>
|
||||
</template>
|
||||
39
resources/scripts/components/base/BaseCard.vue
Normal file
39
resources/scripts/components/base/BaseCard.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="px-5 py-4 text-black border-b border-gray-100 border-solid"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div :class="containerClass">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="hasFooterSlot"
|
||||
class="px-5 py-4 border-t border-gray-100 border-solid sm:px-6"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: 'px-4 py-5 sm:px-8 sm:py-8',
|
||||
},
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasHeaderSlot = computed(() => {
|
||||
return !!slots.header
|
||||
})
|
||||
const hasFooterSlot = computed(() => {
|
||||
return !!slots.footer
|
||||
})
|
||||
</script>
|
||||
78
resources/scripts/components/base/BaseCheckbox.vue
Normal file
78
resources/scripts/components/base/BaseCheckbox.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
:id="id"
|
||||
v-model="checked"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
type="checkbox"
|
||||
:class="[checkboxClass, disabledClass]"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
:class="`font-medium ${
|
||||
disabled ? 'text-gray-400 cursor-not-allowed' : 'text-gray-600'
|
||||
} cursor-pointer `"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: [Boolean, Array],
|
||||
default: false,
|
||||
},
|
||||
id: {
|
||||
type: [Number, String],
|
||||
default: () => `check_${Math.random().toString(36).substr(2, 9)}`,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
checkboxClass: {
|
||||
type: String,
|
||||
default: 'w-4 h-4 border-gray-300 rounded cursor-pointer',
|
||||
},
|
||||
setInitialValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
if (props.setInitialValue) {
|
||||
emit('update:modelValue', props.modelValue)
|
||||
}
|
||||
|
||||
const checked = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
},
|
||||
})
|
||||
|
||||
const disabledClass = computed(() => {
|
||||
if (props.disabled) {
|
||||
return 'text-gray-300 cursor-not-allowed'
|
||||
}
|
||||
|
||||
return 'text-primary-600 focus:ring-primary-500'
|
||||
})
|
||||
</script>
|
||||
190
resources/scripts/components/base/BaseContentPlaceholders.vue
Normal file
190
resources/scripts/components/base/BaseContentPlaceholders.vue
Normal file
@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div :class="classObject">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
centered: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
animated: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const classObject = computed(() => {
|
||||
return {
|
||||
'base-content-placeholders': true,
|
||||
'base-content-placeholders-is-rounded': props.rounded,
|
||||
'base-content-placeholders-is-centered': props.centered,
|
||||
'base-content-placeholders-is-animated': props.animated,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$base-content-placeholders-primary-color: #ccc !default;
|
||||
$base-content-placeholders-secondary-color: #eee !default;
|
||||
$base-content-placeholders-border-radius: 6px !default;
|
||||
$base-content-placeholders-line-height: 15px !default;
|
||||
$base-content-placeholders-spacing: 10px !default;
|
||||
|
||||
// Animations
|
||||
@keyframes vueContentPlaceholdersAnimation {
|
||||
0% {
|
||||
transform: translate3d(-30%, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Mixins
|
||||
@mixin base-content-placeholders {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: $base-content-placeholders-line-height;
|
||||
background: $base-content-placeholders-secondary-color;
|
||||
|
||||
.base-content-placeholders-is-rounded & {
|
||||
border-radius: $base-content-placeholders-border-radius;
|
||||
}
|
||||
|
||||
.base-content-placeholders-is-centered & {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.base-content-placeholders-is-animated &::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
max-width: 1000px;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
darken($base-content-placeholders-secondary-color, 5%) 15%,
|
||||
transparent 30%
|
||||
);
|
||||
animation-duration: 1.5s;
|
||||
animation-fill-mode: forwards;
|
||||
animation-iteration-count: infinite;
|
||||
animation-name: vueContentPlaceholdersAnimation;
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin base-content-placeholders-spacing {
|
||||
[class^='base-content-placeholders-'] + & {
|
||||
margin-top: 2 * $base-content-placeholders-spacing;
|
||||
}
|
||||
}
|
||||
|
||||
// Styles
|
||||
.base-content-placeholders-heading {
|
||||
@include base-content-placeholders-spacing;
|
||||
display: flex;
|
||||
|
||||
&__img {
|
||||
@include base-content-placeholders;
|
||||
margin-right: 1.5 * $base-content-placeholders-spacing;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include base-content-placeholders;
|
||||
width: 85%;
|
||||
margin-bottom: $base-content-placeholders-spacing;
|
||||
background: $base-content-placeholders-primary-color;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
@include base-content-placeholders;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-text {
|
||||
@include base-content-placeholders-spacing;
|
||||
|
||||
&__line {
|
||||
@include base-content-placeholders;
|
||||
width: 100%;
|
||||
margin-bottom: $base-content-placeholders-spacing;
|
||||
|
||||
&:first-child {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: $base-content-placeholders-line-height;
|
||||
background: $base-content-placeholders-secondary-color;
|
||||
|
||||
.base-content-placeholders-is-animated &::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
max-width: 1000px;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
darken($base-content-placeholders-secondary-color, 5%) 15%,
|
||||
transparent 30%
|
||||
);
|
||||
animation-duration: 1.5s;
|
||||
animation-fill-mode: forwards;
|
||||
animation-iteration-count: infinite;
|
||||
animation-name: vueContentPlaceholdersAnimation;
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
// @include base-content-placeholders-spacing;
|
||||
}
|
||||
|
||||
.base-content-circle {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.base-content-placeholders-is-rounded {
|
||||
border-radius: $base-content-placeholders-border-radius;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="base-content-placeholders-box" :class="circleClass" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
circle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const circleClass = computed(() => {
|
||||
return {
|
||||
'base-content-circle': props.circle,
|
||||
'base-content-placeholders-is-rounded': props.rounded,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="base-content-placeholders-heading">
|
||||
<div v-if="box" class="base-content-placeholders-heading__box" />
|
||||
<div class="base-content-placeholders-heading__content">
|
||||
<div
|
||||
class="base-content-placeholders-heading__title"
|
||||
style="background: #eee"
|
||||
/>
|
||||
<div class="base-content-placeholders-heading__subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
box: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="base-content-placeholders-text">
|
||||
<div
|
||||
v-for="n in lines"
|
||||
:key="n"
|
||||
:class="lineClass"
|
||||
class="w-full h-full base-content-placeholders-text__line"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
lines: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const lineClass = computed(() => {
|
||||
return {
|
||||
'base-content-placeholders-is-rounded': props.rounded,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
253
resources/scripts/components/base/BaseCustomInput.vue
Normal file
253
resources/scripts/components/base/BaseCustomInput.vue
Normal file
@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
style="height: 200px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<div v-else class="relative">
|
||||
<div class="absolute bottom-0 right-0 z-10">
|
||||
<BaseDropdown
|
||||
:close-on-select="true"
|
||||
max-height="220"
|
||||
position="top-end"
|
||||
width-class="w-92"
|
||||
class="mb-2"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton type="button" variant="primary-outline" class="mr-4">
|
||||
{{ $t('settings.customization.insert_fields') }}
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="PlusSmIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<div class="flex p-2">
|
||||
<ul v-for="(type, index) in fieldList" :key="index" class="list-none">
|
||||
<li class="mb-1 ml-2 text-xs font-semibold text-gray-500 uppercase">
|
||||
{{ type.label }}
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-for="(field, fieldIndex) in type.fields"
|
||||
:key="fieldIndex"
|
||||
class="
|
||||
w-48
|
||||
text-sm
|
||||
font-normal
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
rounded
|
||||
ml-1
|
||||
py-0.5
|
||||
"
|
||||
@click="value += `{${field.value}}`"
|
||||
>
|
||||
<div class="flex pl-1">
|
||||
<BaseIcon
|
||||
name="ChevronDoubleRightIcon"
|
||||
class="h-3 mt-1 mr-2 text-gray-400"
|
||||
/>
|
||||
|
||||
{{ field.label }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</BaseDropdown>
|
||||
</div>
|
||||
<BaseEditor v-model="value" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useCustomFieldStore } from '@/scripts/stores/custom-field'
|
||||
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const customFieldsStore = useCustomFieldStore()
|
||||
|
||||
let fieldList = ref([])
|
||||
let invoiceFields = ref([])
|
||||
let estimateFields = ref([])
|
||||
let paymentFields = ref([])
|
||||
let customerFields = ref([])
|
||||
const position = null
|
||||
|
||||
watch(
|
||||
() => props.fields,
|
||||
(val) => {
|
||||
if (props.fields && props.fields.length > 0) {
|
||||
getFields()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => customFieldsStore.customFields,
|
||||
(newValue) => {
|
||||
invoiceFields.value = newValue
|
||||
? newValue.filter((field) => field.model_type === 'Invoice')
|
||||
: []
|
||||
customerFields.value = newValue
|
||||
? newValue.filter((field) => field.model_type === 'Customer')
|
||||
: []
|
||||
paymentFields.value = newValue
|
||||
? newValue.filter((field) => field.model_type === 'Payment')
|
||||
: []
|
||||
estimateFields.value = newValue.filter(
|
||||
(field) => field.model_type === 'Estimate'
|
||||
)
|
||||
getFields()
|
||||
}
|
||||
)
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
async function getFields() {
|
||||
fieldList.value = []
|
||||
if (props.fields && props.fields.length > 0) {
|
||||
if (props.fields.find((field) => field == 'shipping')) {
|
||||
fieldList.value.push({
|
||||
label: 'Shipping Address',
|
||||
fields: [
|
||||
{ label: 'Address name', value: 'SHIPPING_ADDRESS_NAME' },
|
||||
{ label: 'Country', value: 'SHIPPING_COUNTRY' },
|
||||
{ label: 'State', value: 'SHIPPING_STATE' },
|
||||
{ label: 'City', value: 'SHIPPING_CITY' },
|
||||
{ label: 'Address Street 1', value: 'SHIPPING_ADDRESS_STREET_1' },
|
||||
{ label: 'Address Street 2', value: 'SHIPPING_ADDRESS_STREET_2' },
|
||||
{ label: 'Phone', value: 'SHIPPING_PHONE' },
|
||||
{ label: 'Zip Code', value: 'SHIPPING_ZIP_CODE' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.find((field) => field == 'billing')) {
|
||||
fieldList.value.push({
|
||||
label: 'Billing Address',
|
||||
fields: [
|
||||
{ label: 'Address name', value: 'BILLING_ADDRESS_NAME' },
|
||||
{ label: 'Country', value: 'BILLING_COUNTRY' },
|
||||
{ label: 'State', value: 'BILLING_STATE' },
|
||||
{ label: 'City', value: 'BILLING_CITY' },
|
||||
{ label: 'Address Street 1', value: 'BILLING_ADDRESS_STREET_1' },
|
||||
{ label: 'Address Street 2', value: 'BILLING_ADDRESS_STREET_2' },
|
||||
{ label: 'Phone', value: 'BILLING_PHONE' },
|
||||
{ label: 'Zip Code', value: 'BILLING_ZIP_CODE' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.find((field) => field == 'customer')) {
|
||||
fieldList.value.push({
|
||||
label: 'Customer',
|
||||
fields: [
|
||||
{ label: 'Display Name', value: 'CONTACT_DISPLAY_NAME' },
|
||||
{ label: 'Contact Name', value: 'PRIMARY_CONTACT_NAME' },
|
||||
{ label: 'Email', value: 'CONTACT_EMAIL' },
|
||||
{ label: 'Phone', value: 'CONTACT_PHONE' },
|
||||
{ label: 'Website', value: 'CONTACT_WEBSITE' },
|
||||
...customerFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.find((field) => field == 'invoice')) {
|
||||
fieldList.value.push({
|
||||
label: 'Invoice',
|
||||
fields: [
|
||||
{ label: 'Date', value: 'INVOICE_DATE' },
|
||||
{ label: 'Due Date', value: 'INVOICE_DUE_DATE' },
|
||||
{ label: 'Number', value: 'INVOICE_NUMBER' },
|
||||
{ label: 'Ref Number', value: 'INVOICE_REF_NUMBER' },
|
||||
{ label: 'Invoice Link', value: 'INVOICE_LINK' },
|
||||
...invoiceFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.find((field) => field == 'estimate')) {
|
||||
fieldList.value.push({
|
||||
label: 'Estimate',
|
||||
fields: [
|
||||
{ label: 'Date', value: 'ESTIMATE_DATE' },
|
||||
{ label: 'Expiry Date', value: 'ESTIMATE_EXPIRY_DATE' },
|
||||
{ label: 'Number', value: 'ESTIMATE_NUMBER' },
|
||||
{ label: 'Ref Number', value: 'ESTIMATE_REF_NUMBER' },
|
||||
{ label: 'Estimate Link', value: 'ESTIMATE_LINK' },
|
||||
...estimateFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.find((field) => field == 'payment')) {
|
||||
fieldList.value.push({
|
||||
label: 'Payment',
|
||||
fields: [
|
||||
{ label: 'Date', value: 'PAYMENT_DATE' },
|
||||
{ label: 'Number', value: 'PAYMENT_NUMBER' },
|
||||
{ label: 'Mode', value: 'PAYMENT_MODE' },
|
||||
{ label: 'Amount', value: 'PAYMENT_AMOUNT' },
|
||||
{ label: 'Payment Link', value: 'PAYMENT_LINK' },
|
||||
...paymentFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.find((field) => field == 'company')) {
|
||||
fieldList.value.push({
|
||||
label: 'Company',
|
||||
fields: [
|
||||
{ label: 'Company Name', value: 'COMPANY_NAME' },
|
||||
{ label: 'Country', value: 'COMPANY_COUNTRY' },
|
||||
{ label: 'State', value: 'COMPANY_STATE' },
|
||||
{ label: 'City', value: 'COMPANY_CITY' },
|
||||
{ label: 'Address Street 1', value: 'COMPANY_ADDRESS_STREET_1' },
|
||||
{ label: 'Address Street 2', value: 'COMPANY_ADDRESS_STREET_2' },
|
||||
{ label: 'Phone', value: 'COMPANY_PHONE' },
|
||||
{ label: 'Zip Code', value: 'COMPANY_ZIP_CODE' },
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFields()
|
||||
</script>
|
||||
16
resources/scripts/components/base/BaseCustomTag.vue
Normal file
16
resources/scripts/components/base/BaseCustomTag.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { h } from 'vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
tag: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
},
|
||||
setup(props, { slots, attrs, emit }) {
|
||||
// return the render function
|
||||
return () => h(`${props.tag}`, attrs, slots)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="address"
|
||||
class="text-sm font-bold leading-5 text-black non-italic space-y-1"
|
||||
>
|
||||
<p v-if="address?.address_street_1">{{ address?.address_street_1 }},</p>
|
||||
|
||||
<p v-if="address?.address_street_2">{{ address?.address_street_2 }},</p>
|
||||
|
||||
<p v-if="address?.city">{{ address?.city }},</p>
|
||||
|
||||
<p v-if="address?.state">{{ address?.state }},</p>
|
||||
|
||||
<p v-if="address?.country?.name">{{ address?.country?.name }},</p>
|
||||
|
||||
<p v-if="address?.zip">{{ address?.zip }}.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
address: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<BaseMultiselect
|
||||
v-model="selectedCustomer"
|
||||
v-bind="$attrs"
|
||||
track-by="name"
|
||||
value-prop="id"
|
||||
label="name"
|
||||
:filter-results="false"
|
||||
:min-chars="1"
|
||||
resolve-on-load
|
||||
:delay="500"
|
||||
:searchable="true"
|
||||
:options="searchCustomers"
|
||||
label-value="name"
|
||||
:placeholder="$t('customers.type_or_click')"
|
||||
:can-deselect="false"
|
||||
class="w-full"
|
||||
>
|
||||
<template v-if="showAction" #action>
|
||||
<BaseSelectAction
|
||||
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
|
||||
@click="addCustomer"
|
||||
>
|
||||
<BaseIcon
|
||||
name="UserAddIcon"
|
||||
class="h-4 mr-2 -ml-2 text-center text-primary-400"
|
||||
/>
|
||||
|
||||
{{ $t('customers.add_new_customer') }}
|
||||
</BaseSelectAction>
|
||||
</template>
|
||||
</BaseMultiselect>
|
||||
|
||||
<CustomerModal />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useCustomerStore } from '@/scripts/stores/customer'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useModalStore } from '@/scripts/stores/modal'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CustomerModal from '@/scripts/components/modal-components/CustomerModal.vue'
|
||||
import { useUserStore } from '@/scripts/stores/user'
|
||||
import abilities from '@/scripts/stub/abilities'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: '',
|
||||
},
|
||||
fetchAll: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const customerStore = useCustomerStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const selectedCustomer = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
async function searchCustomers(search) {
|
||||
let data = {
|
||||
search,
|
||||
}
|
||||
|
||||
if (props.fetchAll) {
|
||||
data.limit = 'all'
|
||||
}
|
||||
|
||||
let res = await customerStore.fetchCustomers(data)
|
||||
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
async function addCustomer() {
|
||||
customerStore.resetCurrentCustomer()
|
||||
|
||||
modalStore.openModal({
|
||||
title: t('customers.add_new_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
533
resources/scripts/components/base/BaseCustomerSelectPopup.vue
Normal file
533
resources/scripts/components/base/BaseCustomerSelectPopup.vue
Normal file
@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
style="min-height: 170px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<div v-else class="max-h-[173px]">
|
||||
<CustomerModal />
|
||||
|
||||
<div
|
||||
v-if="selectedCustomer"
|
||||
class="
|
||||
flex flex-col
|
||||
p-4
|
||||
bg-white
|
||||
border border-gray-200 border-solid
|
||||
min-h-[170px]
|
||||
rounded-md
|
||||
"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex relative justify-between mb-2">
|
||||
<label class="flex-1 text-base font-medium text-left text-gray-900">
|
||||
{{ selectedCustomer.name }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<a
|
||||
class="
|
||||
relative
|
||||
my-0
|
||||
ml-6
|
||||
text-sm
|
||||
font-medium
|
||||
cursor-pointer
|
||||
text-primary-500
|
||||
items-center
|
||||
flex
|
||||
"
|
||||
@click.stop="editCustomer"
|
||||
>
|
||||
<BaseIcon name="PencilIcon" class="text-gray-500 h-4 w-4 mr-1" />
|
||||
|
||||
{{ $t('general.edit') }}
|
||||
</a>
|
||||
<a
|
||||
class="
|
||||
relative
|
||||
my-0
|
||||
ml-6
|
||||
text-sm
|
||||
flex
|
||||
items-center
|
||||
font-medium
|
||||
cursor-pointer
|
||||
text-primary-500
|
||||
"
|
||||
@click="resetSelectedCustomer"
|
||||
>
|
||||
<BaseIcon name="XCircleIcon" class="text-gray-500 h-4 w-4 mr-1" />
|
||||
{{ $t('general.deselect') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-8 mt-2">
|
||||
<div v-if="selectedCustomer.billing" class="flex flex-col">
|
||||
<label
|
||||
class="
|
||||
mb-1
|
||||
text-sm
|
||||
font-medium
|
||||
text-left text-gray-400
|
||||
uppercase
|
||||
whitespace-nowrap
|
||||
"
|
||||
>
|
||||
{{ $t('general.bill_to') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="selectedCustomer.billing"
|
||||
class="flex flex-col flex-1 p-0 text-left"
|
||||
>
|
||||
<label
|
||||
v-if="selectedCustomer.billing.name"
|
||||
class="relative w-11/12 text-sm truncate"
|
||||
>
|
||||
{{ selectedCustomer.billing.name }}
|
||||
</label>
|
||||
|
||||
<label class="relative w-11/12 text-sm truncate">
|
||||
<span v-if="selectedCustomer.billing.city">
|
||||
{{ selectedCustomer.billing.city }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
selectedCustomer.billing.city &&
|
||||
selectedCustomer.billing.state
|
||||
"
|
||||
>
|
||||
,
|
||||
</span>
|
||||
<span v-if="selectedCustomer.billing.state">
|
||||
{{ selectedCustomer.billing.state }}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
v-if="selectedCustomer.billing.zip"
|
||||
class="relative w-11/12 text-sm truncate"
|
||||
>
|
||||
{{ selectedCustomer.billing.zip }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCustomer.shipping" class="flex flex-col">
|
||||
<label
|
||||
class="
|
||||
mb-1
|
||||
text-sm
|
||||
font-medium
|
||||
text-left text-gray-400
|
||||
uppercase
|
||||
whitespace-nowrap
|
||||
"
|
||||
>
|
||||
{{ $t('general.ship_to') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="selectedCustomer.shipping"
|
||||
class="flex flex-col flex-1 p-0 text-left"
|
||||
>
|
||||
<label
|
||||
v-if="selectedCustomer.shipping.name"
|
||||
class="relative w-11/12 text-sm truncate"
|
||||
>
|
||||
{{ selectedCustomer.shipping.name }}
|
||||
</label>
|
||||
|
||||
<label class="relative w-11/12 text-sm truncate">
|
||||
<span v-if="selectedCustomer.shipping.city">
|
||||
{{ selectedCustomer.shipping.city }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
selectedCustomer.shipping.city &&
|
||||
selectedCustomer.shipping.state
|
||||
"
|
||||
>
|
||||
,
|
||||
</span>
|
||||
<span v-if="selectedCustomer.shipping.state">
|
||||
{{ selectedCustomer.shipping.state }}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
v-if="selectedCustomer.shipping.zip"
|
||||
class="relative w-11/12 text-sm truncate"
|
||||
>
|
||||
{{ selectedCustomer.shipping.zip }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popover v-else v-slot="{ open }" class="relative flex flex-col rounded-md">
|
||||
<PopoverButton
|
||||
:class="{
|
||||
'text-opacity-90': open,
|
||||
'border border-solid border-red-500 focus:ring-red-500 rounded':
|
||||
valid.$error,
|
||||
'focus:ring-2 focus:ring-primary-400': !valid.$error,
|
||||
}"
|
||||
class="w-full outline-none rounded-md"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
relative
|
||||
flex
|
||||
justify-center
|
||||
px-0
|
||||
p-0
|
||||
py-16
|
||||
bg-white
|
||||
border border-gray-200 border-solid
|
||||
rounded-md
|
||||
min-h-[170px]
|
||||
"
|
||||
>
|
||||
<BaseIcon
|
||||
name="UserIcon"
|
||||
class="
|
||||
flex
|
||||
justify-center
|
||||
w-10
|
||||
h-10
|
||||
p-2
|
||||
mr-5
|
||||
text-sm text-white
|
||||
bg-gray-200
|
||||
rounded-full
|
||||
font-base
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="mt-1">
|
||||
<label class="text-lg font-medium text-gray-900">
|
||||
{{ $t('customers.new_customer') }}
|
||||
<span class="text-red-500"> * </span>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="valid.$error && valid.$errors[0].$message"
|
||||
class="text-red-500 text-sm absolute right-3 bottom-3"
|
||||
>
|
||||
{{ $t('estimates.errors.required') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverButton>
|
||||
|
||||
<!-- Customer Select Popup -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-1 opacity-0"
|
||||
>
|
||||
<div v-if="open" class="absolute min-w-full z-10">
|
||||
<PopoverPanel
|
||||
v-slot="{ close }"
|
||||
focus
|
||||
static
|
||||
class="
|
||||
overflow-hidden
|
||||
rounded-md
|
||||
shadow-lg
|
||||
ring-1 ring-black ring-opacity-5
|
||||
bg-white
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<BaseInput
|
||||
v-model="search"
|
||||
container-class="m-4"
|
||||
:placeholder="$t('general.search')"
|
||||
type="text"
|
||||
icon="search"
|
||||
@update:modelValue="(val) => debounceSearchCustomer(val)"
|
||||
/>
|
||||
|
||||
<ul
|
||||
class="
|
||||
max-h-80
|
||||
flex flex-col
|
||||
overflow-auto
|
||||
list
|
||||
border-t border-gray-200
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="(customer, index) in customerStore.customers"
|
||||
:key="index"
|
||||
href="#"
|
||||
class="
|
||||
flex
|
||||
px-6
|
||||
py-2
|
||||
border-b border-gray-200 border-solid
|
||||
cursor-pointer
|
||||
hover:cursor-pointer hover:bg-gray-100
|
||||
focus:outline-none focus:bg-gray-100
|
||||
last:border-b-0
|
||||
"
|
||||
@click="selectNewCustomer(customer.id, close)"
|
||||
>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
content-center
|
||||
justify-center
|
||||
w-10
|
||||
h-10
|
||||
mr-4
|
||||
text-xl
|
||||
font-semibold
|
||||
leading-9
|
||||
text-white
|
||||
bg-gray-300
|
||||
rounded-full
|
||||
avatar
|
||||
"
|
||||
>
|
||||
{{ initGenerator(customer.name) }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col justify-center text-left">
|
||||
<label
|
||||
v-if="customer.name"
|
||||
class="
|
||||
m-0
|
||||
text-base
|
||||
font-normal
|
||||
leading-tight
|
||||
cursor-pointer
|
||||
"
|
||||
>
|
||||
{{ customer.name }}
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="customer.contact_name"
|
||||
class="
|
||||
m-0
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-400
|
||||
cursor-pointer
|
||||
"
|
||||
>
|
||||
{{ customer.contact_name }}
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="customerStore.customers.length === 0"
|
||||
class="flex justify-center p-5 text-gray-400"
|
||||
>
|
||||
<label class="text-base text-gray-500 cursor-pointer">
|
||||
{{ $t('customers.no_customers_found') }}
|
||||
</label>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="userStore.hasAbilities(abilities.CREATE_CUSTOMER)"
|
||||
type="button"
|
||||
class="
|
||||
h-10
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-full
|
||||
px-2
|
||||
py-3
|
||||
bg-gray-200
|
||||
border-none
|
||||
outline-none
|
||||
focus:bg-gray-300
|
||||
"
|
||||
@click="openCustomerModal"
|
||||
>
|
||||
<BaseIcon name="UserAddIcon" class="text-primary-400" />
|
||||
|
||||
<label
|
||||
class="
|
||||
m-0
|
||||
ml-3
|
||||
text-sm
|
||||
leading-none
|
||||
cursor-pointer
|
||||
font-base
|
||||
text-primary-400
|
||||
"
|
||||
>
|
||||
{{ $t('customers.add_new_customer') }}
|
||||
</label>
|
||||
</button>
|
||||
</PopoverPanel>
|
||||
</div>
|
||||
</transition>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { useEstimateStore } from '@/scripts/stores/estimate'
|
||||
import { useInvoiceStore } from '@/scripts/stores/invoice'
|
||||
import { useRecurringInvoiceStore } from '@/scripts/stores/recurring-invoice'
|
||||
import { useModalStore } from '@/scripts/stores/modal'
|
||||
import { useGlobalStore } from '@/scripts/stores/global'
|
||||
import { useCustomerStore } from '@/scripts/stores/customer'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useUserStore } from '@/scripts/stores/user'
|
||||
import abilities from '@/scripts/stub/abilities'
|
||||
import { useRoute } from 'vue-router'
|
||||
import CustomerModal from '@/scripts/components/modal-components/CustomerModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
valid: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
customerId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const estimateStore = useEstimateStore()
|
||||
const customerStore = useCustomerStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const invoiceStore = useInvoiceStore()
|
||||
const recurringInvoiceStore = useRecurringInvoiceStore()
|
||||
const userStore = useUserStore()
|
||||
const routes = useRoute()
|
||||
const { t } = useI18n()
|
||||
const search = ref(null)
|
||||
const isSearchingCustomer = ref(false)
|
||||
|
||||
const selectedCustomer = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'estimate':
|
||||
return estimateStore.newEstimate.customer
|
||||
case 'invoice':
|
||||
return invoiceStore.newInvoice.customer
|
||||
case 'recurring-invoice':
|
||||
return recurringInvoiceStore.newRecurringInvoice.customer
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function resetSelectedCustomer() {
|
||||
if (props.type === 'estimate') {
|
||||
estimateStore.resetSelectedCustomer()
|
||||
} else if (props.type === 'invoice') {
|
||||
invoiceStore.resetSelectedCustomer()
|
||||
} else {
|
||||
recurringInvoiceStore.resetSelectedCustomer()
|
||||
}
|
||||
}
|
||||
|
||||
if (props.customerId && props.type === 'estimate') {
|
||||
estimateStore.selectCustomer(props.customerId)
|
||||
} else if (props.customerId && props.type === 'invoice') {
|
||||
invoiceStore.selectCustomer(props.customerId)
|
||||
} else {
|
||||
if (props.customerId) recurringInvoiceStore.selectCustomer(props.customerId)
|
||||
}
|
||||
|
||||
async function editCustomer() {
|
||||
await customerStore.fetchCustomer(selectedCustomer.value.id)
|
||||
modalStore.openModal({
|
||||
title: t('customers.edit_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchInitialCustomers() {
|
||||
await customerStore.fetchCustomers({
|
||||
filter: {},
|
||||
orderByField: '',
|
||||
orderBy: '',
|
||||
customer_id: props.customerId,
|
||||
})
|
||||
}
|
||||
|
||||
const debounceSearchCustomer = useDebounceFn(() => {
|
||||
isSearchingCustomer.value = true
|
||||
|
||||
searchCustomer()
|
||||
}, 500)
|
||||
|
||||
async function searchCustomer() {
|
||||
let data = {
|
||||
display_name: search.value,
|
||||
page: 1,
|
||||
}
|
||||
|
||||
await customerStore.fetchCustomers(data)
|
||||
isSearchingCustomer.value = false
|
||||
}
|
||||
|
||||
function openCustomerModal() {
|
||||
modalStore.openModal({
|
||||
title: t('customers.add_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
variant: 'md',
|
||||
})
|
||||
}
|
||||
|
||||
function initGenerator(name) {
|
||||
if (name) {
|
||||
let nameSplit = name.split(' ')
|
||||
let initials = nameSplit[0].charAt(0).toUpperCase()
|
||||
return initials
|
||||
}
|
||||
}
|
||||
|
||||
function selectNewCustomer(id, close) {
|
||||
let params = {
|
||||
userId: id,
|
||||
}
|
||||
if (routes.params.id) params.model_id = routes.params.id
|
||||
|
||||
if (props.type === 'estimate') {
|
||||
estimateStore.getNextNumber(params, true)
|
||||
estimateStore.selectCustomer(id)
|
||||
} else if (props.type === 'invoice') {
|
||||
invoiceStore.getNextNumber(params, true)
|
||||
invoiceStore.selectCustomer(id)
|
||||
} else {
|
||||
recurringInvoiceStore.selectCustomer(id)
|
||||
}
|
||||
close()
|
||||
search.value = null
|
||||
}
|
||||
|
||||
globalStore.fetchCurrencies()
|
||||
globalStore.fetchCountries()
|
||||
fetchInitialCustomers()
|
||||
</script>
|
||||
177
resources/scripts/components/base/BaseDatePicker.vue
Normal file
177
resources/scripts/components/base/BaseDatePicker.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
:class="`w-full ${computedContainerClass}`"
|
||||
style="height: 38px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<div v-else :class="computedContainerClass" class="relative flex flex-row">
|
||||
<svg
|
||||
v-if="showCalendarIcon && !hasIconSlot"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="
|
||||
absolute
|
||||
w-4
|
||||
h-4
|
||||
mx-2
|
||||
my-2.5
|
||||
text-sm
|
||||
not-italic
|
||||
font-black
|
||||
text-gray-400
|
||||
cursor-pointer
|
||||
"
|
||||
@click="onClickDp"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
<slot v-if="showCalendarIcon && hasIconSlot" name="icon" />
|
||||
|
||||
<FlatPickr
|
||||
ref="dp"
|
||||
v-model="date"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:config="config"
|
||||
:class="[defaultInputClass, inputInvalidClass, inputDisabledClass]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="text/babel" setup>
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, reactive, watch, ref, useSlots } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company'
|
||||
|
||||
const dp = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Date],
|
||||
default: () => new Date(),
|
||||
},
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
invalid: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableTime: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showCalendarIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultInputClass: {
|
||||
type: String,
|
||||
default:
|
||||
'font-base pl-8 py-2 outline-none focus:ring-primary-400 focus:outline-none focus:border-primary-400 block w-full sm:text-sm border-gray-200 rounded-md text-black',
|
||||
},
|
||||
time24hr: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
let config = reactive({
|
||||
altInput: true,
|
||||
enableTime: props.enableTime,
|
||||
time_24hr: props.time24hr,
|
||||
})
|
||||
|
||||
const date = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const carbonFormat = computed(() => {
|
||||
return companyStore.selectedCompanySettings?.carbon_date_format
|
||||
})
|
||||
|
||||
const hasIconSlot = computed(() => {
|
||||
return !!slots.icon
|
||||
})
|
||||
|
||||
const computedContainerClass = computed(() => {
|
||||
let containerClass = `${props.containerClass} `
|
||||
|
||||
return containerClass
|
||||
})
|
||||
|
||||
const inputInvalidClass = computed(() => {
|
||||
if (props.invalid) {
|
||||
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputDisabledClass = computed(() => {
|
||||
if (props.disabled) {
|
||||
return 'border border-solid rounded-md outline-none input-field box-border-2 base-date-picker-input placeholder-gray-400 bg-gray-200 text-gray-600 border-gray-200'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
function onClickDp(params) {
|
||||
dp.value.fp.open()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.enableTime,
|
||||
(val) => {
|
||||
if (props.enableTime) {
|
||||
config.enableTime = props.enableTime
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => carbonFormat,
|
||||
() => {
|
||||
if (!props.enableTime) {
|
||||
config.altFormat = carbonFormat.value ? carbonFormat.value : 'd M Y'
|
||||
} else {
|
||||
config.altFormat = carbonFormat.value
|
||||
? `${carbonFormat.value} H:i `
|
||||
: 'd M Y H:i'
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="grid gap-4 mt-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox class="w-20 h-5 mb-1" />
|
||||
<BaseContentPlaceholdersBox class="w-40 h-5" />
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<div v-else>
|
||||
<BaseLabel class="font-normal mb-1">
|
||||
{{ label }}
|
||||
</BaseLabel>
|
||||
|
||||
<p class="text-sm font-bold leading-5 text-black non-italic">
|
||||
{{ value }}
|
||||
|
||||
<slot />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
181
resources/scripts/components/base/BaseDialog.vue
Normal file
181
resources/scripts/components/base/BaseDialog.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="dialogStore.active">
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
class="fixed inset-0 z-20 overflow-y-auto"
|
||||
:open="dialogStore.active"
|
||||
@close="dialogStore.closeDialog"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-end
|
||||
justify-center
|
||||
min-h-screen min-h-screen-ios
|
||||
px-4
|
||||
pt-4
|
||||
pb-20
|
||||
text-center
|
||||
sm:block sm:p-0
|
||||
"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span
|
||||
class="
|
||||
hidden
|
||||
sm:inline-block sm:align-middle sm:h-screen sm:h-screen-ios
|
||||
"
|
||||
aria-hidden="true"
|
||||
>​</span
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
inline-block
|
||||
px-4
|
||||
pt-5
|
||||
pb-4
|
||||
overflow-hidden
|
||||
text-left
|
||||
align-bottom
|
||||
transition-all
|
||||
transform
|
||||
bg-white
|
||||
rounded-lg
|
||||
shadow-xl
|
||||
sm:my-8 sm:align-middle sm:w-full sm:p-6
|
||||
"
|
||||
:class="dialogSizeClasses"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-12
|
||||
h-12
|
||||
mx-auto
|
||||
bg-green-100
|
||||
rounded-full
|
||||
"
|
||||
:class="{
|
||||
'bg-green-100': dialogStore.variant === 'primary',
|
||||
'bg-red-100': dialogStore.variant === 'danger',
|
||||
}"
|
||||
>
|
||||
<BaseIcon
|
||||
v-if="dialogStore.variant === 'primary'"
|
||||
name="CheckIcon"
|
||||
class="w-6 h-6 text-green-600"
|
||||
/>
|
||||
<BaseIcon
|
||||
v-else
|
||||
name="ExclamationIcon"
|
||||
class="w-6 h-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
class="text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
{{ dialogStore.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ dialogStore.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 sm:mt-6"
|
||||
:class="{
|
||||
'sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense':
|
||||
!dialogStore.hideNoButton,
|
||||
}"
|
||||
>
|
||||
<base-button
|
||||
class="justify-center"
|
||||
:variant="dialogStore.variant"
|
||||
:class="{ 'w-full': dialogStore.hideNoButton }"
|
||||
@click="resolveDialog(true)"
|
||||
>
|
||||
{{ dialogStore.yesLabel }}
|
||||
</base-button>
|
||||
|
||||
<base-button
|
||||
v-if="!dialogStore.hideNoButton"
|
||||
class="justify-center"
|
||||
variant="white"
|
||||
@click="resolveDialog(false)"
|
||||
>
|
||||
{{ dialogStore.noLabel }}
|
||||
</base-button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function resolveDialog(resValue) {
|
||||
dialogStore.resolve(resValue)
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
|
||||
const dialogSizeClasses = computed(() => {
|
||||
const size = dialogStore.size
|
||||
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'sm:max-w-sm'
|
||||
case 'md':
|
||||
return 'sm:max-w-md'
|
||||
case 'lg':
|
||||
return 'sm:max-w-lg'
|
||||
|
||||
default:
|
||||
return 'sm:max-w-md'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
3
resources/scripts/components/base/BaseDivider.vue
Normal file
3
resources/scripts/components/base/BaseDivider.vue
Normal file
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<hr class="w-full text-gray-300" />
|
||||
</template>
|
||||
85
resources/scripts/components/base/BaseDropdown.vue
Normal file
85
resources/scripts/components/base/BaseDropdown.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="relative" :class="wrapperClass">
|
||||
<BaseContentPlaceholders
|
||||
v-if="contentLoading"
|
||||
class="disabled cursor-normal pointer-events-none"
|
||||
>
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-14"
|
||||
style="height: 42px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
<Menu v-else>
|
||||
<MenuButton ref="trigger" class="focus:outline-none" @click="onClick">
|
||||
<slot name="activator" />
|
||||
</MenuButton>
|
||||
|
||||
<div ref="container" class="z-10" :class="widthClass">
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<MenuItems :class="containerClasses">
|
||||
<div class="py-1">
|
||||
<slot />
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/vue'
|
||||
import { computed, onMounted, ref, onUpdated } from 'vue'
|
||||
import { usePopper } from '@/scripts/helpers/use-popper'
|
||||
|
||||
const props = defineProps({
|
||||
containerClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
widthClass: {
|
||||
type: String,
|
||||
default: 'w-56',
|
||||
},
|
||||
positionClass: {
|
||||
type: String,
|
||||
default: 'absolute z-10 right-0',
|
||||
},
|
||||
position: {
|
||||
type: String,
|
||||
default: 'bottom-end',
|
||||
},
|
||||
wrapperClass: {
|
||||
type: String,
|
||||
default: 'inline-block h-full text-left',
|
||||
},
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClass = `origin-top-right rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-100 focus:outline-none`
|
||||
return `${baseClass} ${props.containerClass}`
|
||||
})
|
||||
|
||||
let [trigger, container, popper] = usePopper({
|
||||
placement: 'bottom-end',
|
||||
strategy: 'fixed',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
|
||||
})
|
||||
|
||||
function onClick() {
|
||||
popper.value.update()
|
||||
}
|
||||
</script>
|
||||
17
resources/scripts/components/base/BaseDropdownItem.vue
Normal file
17
resources/scripts/components/base/BaseDropdownItem.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<MenuItem v-slot="{ active }" v-bind="$attrs">
|
||||
<a
|
||||
href="#"
|
||||
:class="[
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
|
||||
'group flex items-center px-4 py-2 text-sm font-normal',
|
||||
]"
|
||||
>
|
||||
<slot :active="active" />
|
||||
</a>
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { MenuItem } from '@headlessui/vue'
|
||||
</script>
|
||||
31
resources/scripts/components/base/BaseEmptyPlaceholder.vue
Normal file
31
resources/scripts/components/base/BaseEmptyPlaceholder.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center mt-16">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="font-medium">{{ title }}</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="text-gray-500">
|
||||
{{ description }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: String,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: String,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
36
resources/scripts/components/base/BaseErrorAlert.vue
Normal file
36
resources/scripts/components/base/BaseErrorAlert.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
{{ errorTitle }}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul role="list" class="list-disc pl-5 space-y-1">
|
||||
<li v-for="(error, key) in errors" :key="key">
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { XCircleIcon } from '@heroicons/vue/solid'
|
||||
|
||||
const props = defineProps({
|
||||
errorTitle: {
|
||||
type: String,
|
||||
default: 'There were some errors with your submission',
|
||||
},
|
||||
errors: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const badgeColorClasses = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'DRAFT':
|
||||
return 'bg-yellow-300 bg-opacity-25 px-2 py-1 text-sm text-yellow-800 uppercase font-normal text-center '
|
||||
case 'SENT':
|
||||
return ' bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center '
|
||||
case 'VIEWED':
|
||||
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
|
||||
case 'EXPIRED':
|
||||
return 'bg-red-300 bg-opacity-25 px-2 py-1 text-sm text-red-800 uppercase font-normal text-center'
|
||||
case 'ACCEPTED':
|
||||
return 'bg-green-400 bg-opacity-25 px-2 py-1 text-sm text-green-800 uppercase font-normal text-center'
|
||||
case 'REJECTED':
|
||||
return 'bg-purple-300 bg-opacity-25 px-2 py-1 text-sm text-purple-800 uppercase font-normal text-center'
|
||||
default:
|
||||
return 'bg-gray-500 bg-opacity-25 px-2 py-1 text-sm text-gray-900 uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
565
resources/scripts/components/base/BaseFileUploader.vue
Normal file
565
resources/scripts/components/base/BaseFileUploader.vue
Normal file
@ -0,0 +1,565 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import utils from '@/scripts/helpers/utilities'
|
||||
|
||||
const props = defineProps({
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
avatar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autoProcess: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
uploadUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
preserveLocalFiles: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: 'image/*',
|
||||
},
|
||||
inputFieldName: {
|
||||
type: String,
|
||||
default: 'photos',
|
||||
},
|
||||
base64: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'remove', 'update:modelValue'])
|
||||
|
||||
// status
|
||||
const STATUS_INITIAL = 0
|
||||
const STATUS_SAVING = 1
|
||||
const STATUS_SUCCESS = 2
|
||||
const STATUS_FAILED = 3
|
||||
|
||||
let uploadedFiles = ref([])
|
||||
const localFiles = ref([])
|
||||
const inputRef = ref(null)
|
||||
let uploadError = ref(null)
|
||||
let currentStatus = ref(null)
|
||||
|
||||
function reset() {
|
||||
// reset form to initial state
|
||||
currentStatus = STATUS_INITIAL
|
||||
|
||||
uploadedFiles.value = []
|
||||
|
||||
if (props.modelValue && props.modelValue.length) {
|
||||
localFiles.value = [...props.modelValue]
|
||||
} else {
|
||||
localFiles.value = []
|
||||
}
|
||||
|
||||
uploadError = null
|
||||
}
|
||||
|
||||
function upload(formData) {
|
||||
return (
|
||||
axios
|
||||
.post(props.uploadUrl, formData)
|
||||
// get data
|
||||
.then((x) => x.data)
|
||||
// add url field
|
||||
.then((x) => x.map((img) => ({ ...img, url: `/images/${img.id}` })))
|
||||
)
|
||||
}
|
||||
|
||||
// upload data to the server
|
||||
function save(formData) {
|
||||
currentStatus = STATUS_SAVING
|
||||
|
||||
upload(formData)
|
||||
.then((x) => {
|
||||
uploadedFiles = [].concat(x)
|
||||
currentStatus = STATUS_SUCCESS
|
||||
})
|
||||
.catch((err) => {
|
||||
uploadError = err.response
|
||||
currentStatus = STATUS_FAILED
|
||||
})
|
||||
}
|
||||
|
||||
function getBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = (error) => reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
function onChange(fieldName, fileList, fileCount) {
|
||||
if (!fileList.length) return
|
||||
|
||||
if (props.multiple) {
|
||||
emit('change', fieldName, fileList, fileCount)
|
||||
} else {
|
||||
if (props.base64) {
|
||||
getBase64(fileList[0]).then((res) => {
|
||||
emit('change', fieldName, res, fileCount, fileList[0])
|
||||
})
|
||||
} else {
|
||||
emit('change', fieldName, fileList[0], fileCount)
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.preserveLocalFiles) {
|
||||
localFiles.value = []
|
||||
}
|
||||
|
||||
Array.from(Array(fileList.length).keys()).forEach((x) => {
|
||||
const file = fileList[x]
|
||||
|
||||
if (utils.isImageFile(file.type)) {
|
||||
getBase64(file).then((image) => {
|
||||
localFiles.value.push({
|
||||
fileObject: file,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
image,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
localFiles.value.push({
|
||||
fileObject: file,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
emit('update:modelValue', localFiles.value)
|
||||
|
||||
if (!props.autoProcess) return
|
||||
|
||||
// append the files to FormData
|
||||
const formData = new FormData()
|
||||
|
||||
Array.from(Array(fileList.length).keys()).forEach((x) => {
|
||||
formData.append(fieldName, fileList[x], fileList[x].name)
|
||||
})
|
||||
|
||||
// save it
|
||||
save(formData)
|
||||
}
|
||||
|
||||
function onBrowse() {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function onAvatarRemove(image) {
|
||||
localFiles.value = []
|
||||
emit('remove', image)
|
||||
}
|
||||
|
||||
function onFileRemove(index) {
|
||||
localFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
localFiles.value = [...v]
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
enctype="multipart/form-data"
|
||||
class="
|
||||
relative
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
p-2
|
||||
border-2 border-dashed
|
||||
rounded-md
|
||||
cursor-pointer
|
||||
avatar-upload
|
||||
border-gray-200
|
||||
transition-all
|
||||
duration-300
|
||||
ease-in-out
|
||||
isolate
|
||||
w-full
|
||||
hover:border-gray-300
|
||||
group
|
||||
min-h-[100px]
|
||||
bg-gray-50
|
||||
"
|
||||
:class="avatar ? 'w-32 h-32' : 'w-full'"
|
||||
>
|
||||
<input
|
||||
id="file-upload"
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
tabindex="-1"
|
||||
:multiple="multiple"
|
||||
:name="inputFieldName"
|
||||
:accept="accept"
|
||||
class="absolute z-10 w-full h-full opacity-0 cursor-pointer"
|
||||
@change="
|
||||
onChange(
|
||||
$event.target.name,
|
||||
$event.target.files,
|
||||
$event.target.files.length
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Avatar Not Selected -->
|
||||
<div v-if="!localFiles.length && avatar" class="">
|
||||
<img src="/img/default-avatar.jpg" class="rounded" alt="Default Avatar" />
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="absolute z-30 bg-white rounded-full -bottom-3 -right-3 group"
|
||||
@click.prevent.stop="onBrowse"
|
||||
>
|
||||
<BaseIcon
|
||||
name="PlusCircleIcon"
|
||||
class="
|
||||
h-8
|
||||
text-xl
|
||||
leading-6
|
||||
text-primary-500
|
||||
group-hover:text-primary-600
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Not Selected -->
|
||||
<div v-else-if="!localFiles.length" class="flex flex-col items-center">
|
||||
<BaseIcon
|
||||
name="CloudUploadIcon"
|
||||
class="h-6 mb-2 text-xl leading-6 text-gray-400"
|
||||
/>
|
||||
<p class="text-xs leading-4 text-center text-gray-400">
|
||||
Drag a file here or
|
||||
<a
|
||||
class="
|
||||
cursor-pointer
|
||||
text-primary-500
|
||||
hover:text-primary-600 hover:font-medium
|
||||
relative
|
||||
z-20
|
||||
"
|
||||
href="#"
|
||||
@click.prevent.stop="onBrowse"
|
||||
>
|
||||
browse
|
||||
</a>
|
||||
to choose a file
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="localFiles.length && avatar && !multiple"
|
||||
class="flex w-full h-full border border-gray-200 rounded"
|
||||
>
|
||||
<img
|
||||
v-if="localFiles[0].image"
|
||||
for="file-upload"
|
||||
:src="localFiles[0].image"
|
||||
class="block object-cover w-full h-full rounded opacity-100"
|
||||
style="animation: fadeIn 2s ease"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="
|
||||
flex
|
||||
justify-center
|
||||
items-center
|
||||
text-gray-400
|
||||
flex-col
|
||||
space-y-2
|
||||
px-2
|
||||
py-4
|
||||
w-full
|
||||
"
|
||||
>
|
||||
<!-- DocumentText Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.25"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p
|
||||
v-if="localFiles[0].name"
|
||||
class="
|
||||
text-gray-600
|
||||
font-medium
|
||||
text-sm
|
||||
truncate
|
||||
overflow-hidden
|
||||
w-full
|
||||
"
|
||||
>
|
||||
{{ localFiles[0].name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="
|
||||
box-border
|
||||
absolute
|
||||
z-30
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-8
|
||||
h-8
|
||||
bg-white
|
||||
border border-gray-200
|
||||
rounded-full
|
||||
shadow-md
|
||||
-bottom-3
|
||||
-right-3
|
||||
group
|
||||
hover:border-gray-300
|
||||
"
|
||||
@click.prevent.stop="onAvatarRemove(localFiles[0])"
|
||||
>
|
||||
<BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Preview Files Multiple -->
|
||||
<div
|
||||
v-else-if="localFiles.length && multiple"
|
||||
class="flex flex-wrap w-full"
|
||||
>
|
||||
<a
|
||||
v-for="(localFile, index) in localFiles"
|
||||
:key="localFile"
|
||||
href="#"
|
||||
class="
|
||||
block
|
||||
p-2
|
||||
m-2
|
||||
bg-white
|
||||
border border-gray-200
|
||||
rounded
|
||||
hover:border-gray-500
|
||||
relative
|
||||
max-w-md
|
||||
"
|
||||
@click.prevent
|
||||
>
|
||||
<img
|
||||
v-if="localFile.image"
|
||||
for="file-upload"
|
||||
:src="localFile.image"
|
||||
class="block object-cover w-20 h-20 opacity-100"
|
||||
style="animation: fadeIn 2s ease"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="
|
||||
flex
|
||||
justify-center
|
||||
items-center
|
||||
text-gray-400
|
||||
flex-col
|
||||
space-y-2
|
||||
px-2
|
||||
py-4
|
||||
w-full
|
||||
"
|
||||
>
|
||||
<!-- DocumentText Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.25"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p
|
||||
v-if="localFile.name"
|
||||
class="
|
||||
text-gray-600
|
||||
font-medium
|
||||
text-sm
|
||||
truncate
|
||||
overflow-hidden
|
||||
w-full
|
||||
"
|
||||
>
|
||||
{{ localFile.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="
|
||||
box-border
|
||||
absolute
|
||||
z-30
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-8
|
||||
h-8
|
||||
bg-white
|
||||
border border-gray-200
|
||||
rounded-full
|
||||
shadow-md
|
||||
-bottom-3
|
||||
-right-3
|
||||
group
|
||||
hover:border-gray-300
|
||||
"
|
||||
@click.prevent.stop="onFileRemove(index)"
|
||||
>
|
||||
<BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black" />
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex w-full items-center justify-center">
|
||||
<a
|
||||
v-for="(localFile, index) in localFiles"
|
||||
:key="localFile"
|
||||
href="#"
|
||||
class="
|
||||
block
|
||||
p-2
|
||||
m-2
|
||||
bg-white
|
||||
border border-gray-200
|
||||
rounded
|
||||
hover:border-gray-500
|
||||
relative
|
||||
max-w-md
|
||||
"
|
||||
@click.prevent
|
||||
>
|
||||
<img
|
||||
v-if="localFile.image"
|
||||
for="file-upload"
|
||||
:src="localFile.image"
|
||||
class="block object-contain h-20 opacity-100 min-w-[5rem]"
|
||||
style="animation: fadeIn 2s ease"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="
|
||||
flex
|
||||
justify-center
|
||||
items-center
|
||||
text-gray-400
|
||||
flex-col
|
||||
space-y-2
|
||||
px-2
|
||||
py-4
|
||||
w-full
|
||||
"
|
||||
>
|
||||
<!-- DocumentText Icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.25"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p
|
||||
v-if="localFile.name"
|
||||
class="
|
||||
text-gray-600
|
||||
font-medium
|
||||
text-sm
|
||||
truncate
|
||||
overflow-hidden
|
||||
w-full
|
||||
"
|
||||
>
|
||||
{{ localFile.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="
|
||||
box-border
|
||||
absolute
|
||||
z-30
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-8
|
||||
h-8
|
||||
bg-white
|
||||
border border-gray-200
|
||||
rounded-full
|
||||
shadow-md
|
||||
-bottom-3
|
||||
-right-3
|
||||
group
|
||||
hover:border-gray-300
|
||||
"
|
||||
@click.prevent.stop="onFileRemove(index)"
|
||||
>
|
||||
<BaseIcon name="XIcon" class="h-4 text-xl leading-6 text-black" />
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
51
resources/scripts/components/base/BaseFilterWrapper.vue
Normal file
51
resources/scripts/components/base/BaseFilterWrapper.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<transition
|
||||
enter-active-class="transition duration-500 ease-in-out"
|
||||
enter-from-class="transform opacity-0"
|
||||
enter-to-class="transform opacity-100"
|
||||
leave-active-class="transition ease-in-out"
|
||||
leave-from-class="transform opacity-100"
|
||||
leave-to-class="transform opacity-0"
|
||||
>
|
||||
<div v-show="show" class="relative z-10 p-4 md:p-8 bg-gray-200 rounded">
|
||||
<slot name="filter-header" />
|
||||
|
||||
<label
|
||||
class="
|
||||
absolute
|
||||
text-sm
|
||||
leading-snug
|
||||
text-gray-900
|
||||
cursor-pointer
|
||||
hover:text-gray-700
|
||||
top-2.5
|
||||
right-3.5
|
||||
"
|
||||
@click="$emit('clear')"
|
||||
>
|
||||
{{ $t('general.clear_all') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="
|
||||
flex flex-col
|
||||
space-y-3
|
||||
lg:flex-row lg:space-x-4 lg:space-y-0 lg:items-center
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['clear'])
|
||||
</script>
|
||||
32
resources/scripts/components/base/BaseFormatMoney.vue
Normal file
32
resources/scripts/components/base/BaseFormatMoney.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<span style="font-family: sans-serif">{{ formattedAmount }}</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useCompanyStore } from '@/scripts/stores/company'
|
||||
import { inject, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
amount: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
currency: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return null
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const utils = inject('utils')
|
||||
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
return utils.formatMoney(
|
||||
props.amount,
|
||||
props.currency || companyStore.selectedCompanyCurrency
|
||||
)
|
||||
})
|
||||
</script>
|
||||
1127
resources/scripts/components/base/BaseGlobalLoader.vue
Normal file
1127
resources/scripts/components/base/BaseGlobalLoader.vue
Normal file
File diff suppressed because it is too large
Load Diff
25
resources/scripts/components/base/BaseHeading.vue
Normal file
25
resources/scripts/components/base/BaseHeading.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<h6 :class="typeClass">
|
||||
<slot />
|
||||
</h6>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'section-title',
|
||||
validator: function (value) {
|
||||
return ['section-title', 'heading-title'].indexOf(value) !== -1
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const typeClass = computed(() => {
|
||||
return {
|
||||
'text-gray-900 text-lg font-medium': props.type === 'heading-title',
|
||||
'text-gray-500 uppercase text-base': props.type === 'section-title',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
20
resources/scripts/components/base/BaseIcon.vue
Normal file
20
resources/scripts/components/base/BaseIcon.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<component v-if="isLoaded" :is="heroIcons[name]" class="h-5 w-5" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as heroIcons from '@heroicons/vue/outline'
|
||||
|
||||
const isLoaded = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
isLoaded.value = true
|
||||
})
|
||||
</script>
|
||||
103
resources/scripts/components/base/BaseInfoAlert.vue
Normal file
103
resources/scripts/components/base/BaseInfoAlert.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="rounded-md bg-yellow-50 p-4 relative">
|
||||
<BaseIcon
|
||||
name="XIcon"
|
||||
class="h-5 w-5 text-yellow-500 absolute right-4 cursor-pointer"
|
||||
@click="$emit('hide')"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<BaseIcon
|
||||
name="ExclamationIcon"
|
||||
class="h-5 w-5 text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<ul role="list" class="list-disc pl-5 space-y-1">
|
||||
<li v-for="(list, key) in lists" :key="key">
|
||||
{{ list }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="actions.length" class="mt-4 ml-3">
|
||||
<div class="-mx-2 -my-1.5 flex flex-row-reverse">
|
||||
<button
|
||||
v-for="(action, i) in actions"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="
|
||||
bg-yellow-50
|
||||
px-2
|
||||
py-1.5
|
||||
rounded-md
|
||||
text-sm
|
||||
font-medium
|
||||
text-yellow-800
|
||||
hover:bg-yellow-100
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-offset-yellow-50
|
||||
focus:ring-yellow-600
|
||||
mr-3
|
||||
"
|
||||
@click="$emit(`${action}`)"
|
||||
>
|
||||
{{ action }}
|
||||
</button>
|
||||
<!-- <button
|
||||
v-if="actions[1]"
|
||||
type="button"
|
||||
class="
|
||||
ml-3
|
||||
bg-yellow-50
|
||||
px-2
|
||||
py-1.5
|
||||
rounded-md
|
||||
text-sm
|
||||
font-medium
|
||||
text-yellow-800
|
||||
hover:bg-yellow-100
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-offset-yellow-50
|
||||
focus:ring-yellow-600
|
||||
"
|
||||
@click="$emit('action2')"
|
||||
>
|
||||
{{ actions[1] }}
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { XCircleIcon } from '@heroicons/vue/solid'
|
||||
|
||||
const emits = defineEmits(['hide'])
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'There were some errors with your submission',
|
||||
},
|
||||
lists: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => ['Dismiss'],
|
||||
},
|
||||
})
|
||||
</script>
|
||||
285
resources/scripts/components/base/BaseInput.vue
Normal file
285
resources/scripts/components/base/BaseInput.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
:class="`w-full ${contentLoadClass}`"
|
||||
style="height: 38px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="[containerClass, computedContainerClass]"
|
||||
class="relative rounded-md shadow-sm font-base"
|
||||
>
|
||||
<div
|
||||
v-if="loading && loadingPosition === 'left'"
|
||||
class="
|
||||
absolute
|
||||
inset-y-0
|
||||
left-0
|
||||
flex
|
||||
items-center
|
||||
pl-3
|
||||
pointer-events-none
|
||||
"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin !text-primary-500"
|
||||
:class="[iconLeftClass]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hasLeftIconSlot"
|
||||
class="absolute inset-y-0 left-0 flex items-center pl-3"
|
||||
>
|
||||
<slot name="left" :class="iconLeftClass" />
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="addon"
|
||||
class="
|
||||
inline-flex
|
||||
items-center
|
||||
px-3
|
||||
text-gray-500
|
||||
border border-r-0 border-gray-200
|
||||
rounded-l-md
|
||||
bg-gray-50
|
||||
sm:text-sm
|
||||
"
|
||||
>
|
||||
{{ addon }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="inlineAddon"
|
||||
class="
|
||||
absolute
|
||||
inset-y-0
|
||||
left-0
|
||||
flex
|
||||
items-center
|
||||
pl-3
|
||||
pointer-events-none
|
||||
"
|
||||
>
|
||||
<span class="text-gray-500 sm:text-sm">
|
||||
{{ inlineAddon }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
defaultInputClass,
|
||||
inputPaddingClass,
|
||||
inputAddonClass,
|
||||
inputInvalidClass,
|
||||
inputDisabledClass,
|
||||
]"
|
||||
@input="emitValue"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="loading && loadingPosition === 'right'"
|
||||
class="
|
||||
absolute
|
||||
inset-y-0
|
||||
right-0
|
||||
flex
|
||||
items-center
|
||||
pr-3
|
||||
pointer-events-none
|
||||
"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin !text-primary-500"
|
||||
:class="[iconRightClass]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasRightIconSlot"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<slot name="right" :class="iconRightClass" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, useSlots } from 'vue'
|
||||
|
||||
let inheritAttrs = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: [Number, String],
|
||||
default: 'text',
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingPosition: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
},
|
||||
addon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
inlineAddon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
invalid: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
contentLoadClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
defaultInputClass: {
|
||||
type: String,
|
||||
default:
|
||||
'font-base block w-full sm:text-sm border-gray-200 rounded-md text-black',
|
||||
},
|
||||
iconLeftClass: {
|
||||
type: String,
|
||||
default: 'h-5 w-5 text-gray-400',
|
||||
},
|
||||
iconRightClass: {
|
||||
type: String,
|
||||
default: 'h-5 w-5 text-gray-400',
|
||||
},
|
||||
modelModifiers: {
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const hasLeftIconSlot = computed(() => {
|
||||
return !!slots.left || (props.loading && props.loadingPosition === 'left')
|
||||
})
|
||||
|
||||
const hasRightIconSlot = computed(() => {
|
||||
return !!slots.right || (props.loading && props.loadingPosition === 'right')
|
||||
})
|
||||
|
||||
const inputPaddingClass = computed(() => {
|
||||
if (hasLeftIconSlot.value && hasRightIconSlot.value) {
|
||||
return 'px-10'
|
||||
} else if (hasLeftIconSlot.value) {
|
||||
return 'pl-10'
|
||||
} else if (hasRightIconSlot.value) {
|
||||
return 'pr-10'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputAddonClass = computed(() => {
|
||||
if (props.addon) {
|
||||
return 'flex-1 min-w-0 block w-full px-3 py-2 !rounded-none !rounded-r-md'
|
||||
} else if (props.inlineAddon) {
|
||||
return 'pl-7'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputInvalidClass = computed(() => {
|
||||
if (props.invalid) {
|
||||
return 'border-red-500 ring-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
}
|
||||
|
||||
return 'focus:ring-primary-400 focus:border-primary-400'
|
||||
})
|
||||
|
||||
const inputDisabledClass = computed(() => {
|
||||
if (props.disabled) {
|
||||
return `border-gray-100 bg-gray-100 !text-gray-400 ring-gray-200 focus:ring-gray-200 focus:border-gray-100`
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const computedContainerClass = computed(() => {
|
||||
let containerClass = `${props.containerClass} `
|
||||
|
||||
if (props.addon) {
|
||||
return `${props.containerClass} flex`
|
||||
}
|
||||
|
||||
return containerClass
|
||||
})
|
||||
|
||||
function emitValue(e) {
|
||||
let val = e.target.value
|
||||
if (props.modelModifiers.uppercase) {
|
||||
val = val.toUpperCase()
|
||||
}
|
||||
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
</script>
|
||||
24
resources/scripts/components/base/BaseInputGrid.vue
Normal file
24
resources/scripts/components/base/BaseInputGrid.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'two-column',
|
||||
},
|
||||
})
|
||||
|
||||
const formLayout = computed(() => {
|
||||
if (props.layout === 'two-column') {
|
||||
return 'grid gap-y-6 gap-x-4 md:grid-cols-2'
|
||||
}
|
||||
|
||||
return 'grid gap-y-6 gap-x-4 grid-cols-1'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="formLayout">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
113
resources/scripts/components/base/BaseInputGroup.vue
Normal file
113
resources/scripts/components/base/BaseInputGroup.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div :class="containerClasses" class="relative w-full text-left">
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersText :lines="1" :class="contentLoadClass" />
|
||||
</BaseContentPlaceholders>
|
||||
<label
|
||||
v-else-if="label"
|
||||
:class="labelClasses"
|
||||
class="
|
||||
flex
|
||||
text-sm
|
||||
not-italic
|
||||
items-center
|
||||
font-medium
|
||||
text-primary-800
|
||||
whitespace-nowrap
|
||||
justify-between
|
||||
"
|
||||
>
|
||||
<div>
|
||||
{{ label }}
|
||||
<span v-show="required" class="text-sm text-red-500"> * </span>
|
||||
</div>
|
||||
<slot v-if="hasRightLabelSlot" name="labelRight" />
|
||||
<BaseIcon
|
||||
v-if="tooltip"
|
||||
v-tooltip="{ content: tooltip }"
|
||||
name="InformationCircleIcon"
|
||||
class="h-4 text-gray-400 cursor-pointer hover:text-gray-600"
|
||||
/>
|
||||
</label>
|
||||
<div :class="inputContainerClasses">
|
||||
<slot></slot>
|
||||
<span v-if="helpText" class="text-gray-400 text-xs mt-1 font-light">
|
||||
{{ helpText }}
|
||||
</span>
|
||||
<span v-if="error" class="block mt-0.5 text-sm text-red-500">
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
contentLoadClass: {
|
||||
type: String,
|
||||
default: 'w-16 h-5',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'vertical',
|
||||
},
|
||||
error: {
|
||||
type: [String, Boolean],
|
||||
default: null,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
helpText: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
if (props.variant === 'horizontal') {
|
||||
return 'grid md:grid-cols-12 items-center'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
if (props.variant === 'horizontal') {
|
||||
return 'relative pr-0 pt-1 mr-3 text-sm md:col-span-4 md:text-right mb-1 md:mb-0'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputContainerClasses = computed(() => {
|
||||
if (props.variant === 'horizontal') {
|
||||
return 'md:col-span-8 md:col-start-5 md:col-ends-12'
|
||||
}
|
||||
|
||||
return 'flex flex-col mt-1'
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasRightLabelSlot = computed(() => {
|
||||
return !!slots.labelRight
|
||||
})
|
||||
</script>
|
||||
47
resources/scripts/components/base/BaseInvoiceStatusBadge.vue
Normal file
47
resources/scripts/components/base/BaseInvoiceStatusBadge.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const badgeColorClasses = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'DRAFT':
|
||||
return 'bg-yellow-300 bg-opacity-25 px-2 py-1 text-sm text-yellow-800 uppercase font-normal text-center'
|
||||
case 'SENT':
|
||||
return ' bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center '
|
||||
case 'VIEWED':
|
||||
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-500 bg-opacity-25 px-2 py-1 text-sm text-green-900 uppercase font-normal text-center'
|
||||
case 'DUE':
|
||||
return 'bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center'
|
||||
case 'OVERDUE':
|
||||
return 'bg-red-300 bg-opacity-50 px-2 py-1 text-sm text-red-900 uppercase font-normal text-center'
|
||||
case 'UNPAID':
|
||||
return 'bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center'
|
||||
case 'PARTIALLY_PAID':
|
||||
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
|
||||
case 'PAID':
|
||||
return 'bg-green-500 bg-opacity-25 px-2 py-1 text-sm text-green-900 uppercase font-normal text-center'
|
||||
default:
|
||||
return 'bg-gray-500 bg-opacity-25 px-2 py-1 text-sm text-gray-900 uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
return { badgeColorClasses }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
193
resources/scripts/components/base/BaseItemSelect.vue
Normal file
193
resources/scripts/components/base/BaseItemSelect.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="flex-1 text-sm">
|
||||
<!-- Selected Item Field -->
|
||||
<div
|
||||
v-if="item.item_id"
|
||||
class="
|
||||
relative
|
||||
flex
|
||||
items-center
|
||||
h-10
|
||||
pl-2
|
||||
bg-gray-200
|
||||
border border-gray-200 border-solid
|
||||
rounded
|
||||
"
|
||||
>
|
||||
{{ item.name }}
|
||||
|
||||
<span
|
||||
class="absolute text-gray-400 cursor-pointer top-[8px] right-[10px]"
|
||||
@click="deselectItem(index)"
|
||||
>
|
||||
<BaseIcon name="XCircleIcon" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Select Item Field -->
|
||||
<BaseMultiselect
|
||||
v-else
|
||||
v-model="itemSelect"
|
||||
:content-loading="contentLoading"
|
||||
value-prop="id"
|
||||
track-by="id"
|
||||
:invalid="invalid"
|
||||
preserve-search
|
||||
:initial-search="itemData.name"
|
||||
label="name"
|
||||
:filterResults="false"
|
||||
resolve-on-load
|
||||
:delay="500"
|
||||
searchable
|
||||
:options="searchItems"
|
||||
object
|
||||
@update:modelValue="(val) => $emit('select', val)"
|
||||
@searchChange="(val) => $emit('search', val)"
|
||||
>
|
||||
<!-- Add Item Action -->
|
||||
<template #action>
|
||||
<BaseSelectAction
|
||||
v-if="userStore.hasAbilities(abilities.CREATE_ITEM)"
|
||||
@click="openItemModal"
|
||||
>
|
||||
<BaseIcon
|
||||
name="PlusCircleIcon"
|
||||
class="h-4 mr-2 -ml-2 text-center text-primary-400"
|
||||
/>
|
||||
{{ $t('general.add_new_item') }}
|
||||
</BaseSelectAction>
|
||||
</template>
|
||||
</BaseMultiselect>
|
||||
|
||||
<!-- Item Description -->
|
||||
<div class="w-full pt-1 text-xs text-light">
|
||||
<BaseTextarea
|
||||
v-model="description"
|
||||
:content-loading="contentLoading"
|
||||
:autosize="true"
|
||||
class="text-xs"
|
||||
:borderless="true"
|
||||
:placeholder="$t('estimates.item.type_item_description')"
|
||||
:invalid="invalidDescription"
|
||||
/>
|
||||
<div v-if="invalidDescription">
|
||||
<span class="text-red-600">
|
||||
{{ $tc('validation.description_maxlength') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useEstimateStore } from '@/scripts/stores/estimate'
|
||||
import { useInvoiceStore } from '@/scripts/stores/invoice'
|
||||
import { useItemStore } from '@/scripts/stores/item'
|
||||
import { useModalStore } from '@/scripts/stores/modal'
|
||||
import { useUserStore } from '@/scripts/stores/user'
|
||||
import abilities from '@/scripts/stub/abilities'
|
||||
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
invalid: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
invalidDescription: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
taxPerItem: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
taxes: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
store: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
storeProp: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search', 'select'])
|
||||
|
||||
const itemStore = useItemStore()
|
||||
const estimateStore = useEstimateStore()
|
||||
const invoiceStore = useInvoiceStore()
|
||||
const modalStore = useModalStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
let route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const itemSelect = ref(null)
|
||||
const loading = ref(false)
|
||||
let itemData = reactive({ ...props.item })
|
||||
Object.assign(itemData, props.item)
|
||||
|
||||
const taxAmount = computed(() => {
|
||||
return 0
|
||||
})
|
||||
|
||||
const description = computed({
|
||||
get: () => props.item.description,
|
||||
set: (value) => {
|
||||
props.store[props.storeProp].items[props.index].description = value
|
||||
},
|
||||
})
|
||||
|
||||
async function searchItems(search) {
|
||||
let res = await itemStore.fetchItems({ search })
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
function onTextChange(val) {
|
||||
searchItems(val)
|
||||
emit('search', val)
|
||||
}
|
||||
|
||||
function openItemModal() {
|
||||
modalStore.openModal({
|
||||
title: t('items.add_item'),
|
||||
componentName: 'ItemModal',
|
||||
refreshData: (val) => emit('select', val),
|
||||
data: {
|
||||
taxPerItem: props.taxPerItem,
|
||||
taxes: props.taxes,
|
||||
itemIndex: props.index,
|
||||
store: props.store,
|
||||
storeProps: props.storeProp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function deselectItem(index) {
|
||||
props.store.deselectItem(index)
|
||||
}
|
||||
</script>
|
||||
5
resources/scripts/components/base/BaseLabel.vue
Normal file
5
resources/scripts/components/base/BaseLabel.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<label class="text-sm not-italic font-medium leading-5 text-primary-800">
|
||||
<slot />
|
||||
</label>
|
||||
</template>
|
||||
143
resources/scripts/components/base/BaseModal.vue
Normal file
143
resources/scripts/components/base/BaseModal.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionRoot appear as="template" :show="show">
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
class="fixed inset-0 z-20 overflow-y-auto"
|
||||
:open="show"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-end
|
||||
justify-center
|
||||
min-h-screen min-h-screen-ios
|
||||
px-4
|
||||
text-center
|
||||
sm:block sm:px-2
|
||||
"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 transition-opacity bg-gray-700 bg-opacity-25"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span
|
||||
class="
|
||||
hidden
|
||||
sm:inline-block sm:align-middle sm:h-screen sm:h-screen-ios
|
||||
"
|
||||
aria-hidden="true"
|
||||
>​</span
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div
|
||||
:class="`inline-block
|
||||
align-middle
|
||||
bg-white
|
||||
rounded-lg
|
||||
text-left
|
||||
overflow-hidden
|
||||
shadow-xl
|
||||
transform
|
||||
transition-all
|
||||
my-4
|
||||
${modalSize}
|
||||
sm:w-full
|
||||
border-t-8 border-solid rounded shadow-xl border-primary-500`"
|
||||
>
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
px-6
|
||||
py-4
|
||||
text-lg
|
||||
font-medium
|
||||
text-black
|
||||
border-b border-gray-200 border-solid
|
||||
"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useModalStore } from '@/scripts/stores/modal'
|
||||
import { computed, watchEffect, useSlots } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const slots = useSlots()
|
||||
|
||||
const emit = defineEmits(['close', 'open'])
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.show) {
|
||||
emit('open', props.show)
|
||||
}
|
||||
})
|
||||
|
||||
const modalSize = computed(() => {
|
||||
const size = modalStore.size
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'sm:max-w-2xl w-full'
|
||||
case 'md':
|
||||
return 'sm:max-w-4xl w-full'
|
||||
case 'lg':
|
||||
return 'sm:max-w-6xl w-full'
|
||||
|
||||
default:
|
||||
return 'sm:max-w-2xl w-full'
|
||||
}
|
||||
})
|
||||
|
||||
const hasHeaderSlot = computed(() => {
|
||||
return !!slots.header
|
||||
})
|
||||
</script>
|
||||
93
resources/scripts/components/base/BaseMoney.vue
Normal file
93
resources/scripts/components/base/BaseMoney.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
style="height: 38px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
<money3
|
||||
v-else
|
||||
v-model="money"
|
||||
v-bind="currencyBindings"
|
||||
:class="[inputClass, invalidClass]"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { Money3Component } from 'v-money3'
|
||||
import { useCompanyStore } from '@/scripts/stores/company'
|
||||
|
||||
let money3 = Money3Component
|
||||
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
invalid: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default:
|
||||
'font-base block w-full sm:text-sm border-gray-200 rounded-md text-black',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
percent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currency: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const companyStore = useCompanyStore()
|
||||
let hasInitialValueSet = false
|
||||
|
||||
const money = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!hasInitialValueSet) {
|
||||
hasInitialValueSet = true
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const currencyBindings = computed(() => {
|
||||
const currency = props.currency
|
||||
? props.currency
|
||||
: companyStore.selectedCompanyCurrency
|
||||
|
||||
return {
|
||||
decimal: currency.decimal_separator,
|
||||
thousands: currency.thousand_separator,
|
||||
prefix: currency.symbol + ' ',
|
||||
precision: currency.precision,
|
||||
masked: false,
|
||||
}
|
||||
})
|
||||
|
||||
const invalidClass = computed(() => {
|
||||
if (props.invalid) {
|
||||
return 'border-red-500 ring-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
}
|
||||
return 'focus:ring-primary-400 focus:border-primary-400'
|
||||
})
|
||||
</script>
|
||||
19
resources/scripts/components/base/BaseNewBadge.vue
Normal file
19
resources/scripts/components/base/BaseNewBadge.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
sucess ? 'bg-green-100 text-green-700 ' : 'bg-red-100 text-red-700',
|
||||
'px-2 py-1 text-sm font-normal text-center uppercase',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
sucess: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
5
resources/scripts/components/base/BasePage.vue
Normal file
5
resources/scripts/components/base/BasePage.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex-1 p-4 md:p-8 flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
resources/scripts/components/base/BasePageHeader.vue
Normal file
23
resources/scripts/components/base/BasePageHeader.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-left text-black">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
39
resources/scripts/components/base/BasePaidStatusBadge.vue
Normal file
39
resources/scripts/components/base/BasePaidStatusBadge.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<span :class="[badgeColorClasses, defaultClass]" class="">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
defaultClass: {
|
||||
type: String,
|
||||
default: 'px-1 py-0.5 text-xs',
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const badgeColorClasses = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'PAID':
|
||||
return 'bg-primary-300 bg-opacity-25 text-primary-800 uppercase font-normal text-center'
|
||||
case 'UNPAID':
|
||||
return ' bg-yellow-500 bg-opacity-25 text-yellow-900 uppercase font-normal text-center '
|
||||
case 'PARTIALLY_PAID':
|
||||
return 'bg-blue-400 bg-opacity-25 text-blue-900 uppercase font-normal text-center'
|
||||
default:
|
||||
return 'bg-gray-500 bg-opacity-25 text-gray-900 uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
return { badgeColorClasses }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
104
resources/scripts/components/base/BaseRadio.vue
Normal file
104
resources/scripts/components/base/BaseRadio.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<RadioGroup v-model="selected">
|
||||
<RadioGroupLabel class="sr-only"> Privacy setting </RadioGroupLabel>
|
||||
<div class="-space-y-px rounded-md">
|
||||
<RadioGroupOption
|
||||
:id="id"
|
||||
v-slot="{ checked, active }"
|
||||
as="template"
|
||||
:value="value"
|
||||
:name="name"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div class="relative flex cursor-pointer focus:outline-none">
|
||||
<span
|
||||
:class="[
|
||||
checked ? checkedStateClass : unCheckedStateClass,
|
||||
active ? optionGroupActiveStateClass : '',
|
||||
optionGroupClass,
|
||||
]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="rounded-full bg-white w-1.5 h-1.5" />
|
||||
</span>
|
||||
<div class="flex flex-col ml-3">
|
||||
<RadioGroupLabel
|
||||
as="span"
|
||||
:class="[
|
||||
checked ? checkedStateLabelClass : unCheckedStateLabelClass,
|
||||
optionGroupLabelClass,
|
||||
]"
|
||||
>
|
||||
{{ label }}
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupOption>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from '@headlessui/vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: [String, Number],
|
||||
required: false,
|
||||
default: () => `radio_${Math.random().toString(36).substr(2, 9)}`,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
checkedStateClass: {
|
||||
type: String,
|
||||
default: 'bg-primary-600',
|
||||
},
|
||||
unCheckedStateClass: {
|
||||
type: String,
|
||||
default: 'bg-white ',
|
||||
},
|
||||
optionGroupActiveStateClass: {
|
||||
type: String,
|
||||
default: 'ring-2 ring-offset-2 ring-primary-500',
|
||||
},
|
||||
checkedStateLabelClass: {
|
||||
type: String,
|
||||
default: 'text-primary-900 ',
|
||||
},
|
||||
unCheckedStateLabelClass: {
|
||||
type: String,
|
||||
default: 'text-gray-900',
|
||||
},
|
||||
optionGroupClass: {
|
||||
type: String,
|
||||
default:
|
||||
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center',
|
||||
},
|
||||
optionGroupLabelClass: {
|
||||
type: String,
|
||||
default: 'block text-sm font-light',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (modelValue) => emit('update:modelValue', modelValue),
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const badgeColorClasses = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-500 bg-opacity-25 px-2 py-1 text-sm text-green-900 uppercase font-normal text-center'
|
||||
case 'ON_HOLD':
|
||||
return 'bg-yellow-500 bg-opacity-25 px-2 py-1 text-sm text-yellow-900 uppercase font-normal text-center'
|
||||
case 'ACTIVE':
|
||||
return 'bg-blue-400 bg-opacity-25 px-2 py-1 text-sm text-blue-900 uppercase font-normal text-center'
|
||||
default:
|
||||
return 'bg-gray-500 bg-opacity-25 px-2 py-1 text-sm text-gray-900 uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
return { badgeColorClasses }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
12
resources/scripts/components/base/BaseScrollPane.vue
Normal file
12
resources/scripts/components/base/BaseScrollPane.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="py-2 align-middle inline-block min-w-full sm:px-4 lg:px-6">
|
||||
<div class="overflow-hidden sm:px-2 lg:p-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
18
resources/scripts/components/base/BaseSelectAction.vue
Normal file
18
resources/scripts/components/base/BaseSelectAction.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-full
|
||||
px-6
|
||||
py-2
|
||||
text-sm
|
||||
bg-gray-200
|
||||
cursor-pointer
|
||||
text-primary-400
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
216
resources/scripts/components/base/BaseSelectInput.vue
Normal file
216
resources/scripts/components/base/BaseSelectInput.vue
Normal file
@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox :rounded="true" class="w-full h-10" />
|
||||
</BaseContentPlaceholders>
|
||||
<Listbox
|
||||
v-else
|
||||
v-model="selectedValue"
|
||||
as="div"
|
||||
v-bind="{
|
||||
...$attrs,
|
||||
}"
|
||||
>
|
||||
<ListboxLabel
|
||||
v-if="label"
|
||||
class="block text-sm not-italic font-medium text-primary-800 mb-0.5"
|
||||
>
|
||||
{{ label }}
|
||||
</ListboxLabel>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Select Input button -->
|
||||
<ListboxButton
|
||||
class="
|
||||
relative
|
||||
w-full
|
||||
py-2
|
||||
pl-3
|
||||
pr-10
|
||||
text-left
|
||||
bg-white
|
||||
border border-gray-200
|
||||
rounded-md
|
||||
shadow-sm
|
||||
cursor-default
|
||||
focus:outline-none
|
||||
focus:ring-1 focus:ring-primary-500
|
||||
focus:border-primary-500
|
||||
sm:text-sm
|
||||
"
|
||||
>
|
||||
<span v-if="getValue(selectedValue)" class="block truncate">
|
||||
{{ getValue(selectedValue) }}
|
||||
</span>
|
||||
<span v-else-if="placeholder" class="block text-gray-400 truncate">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
<span v-else class="block text-gray-400 truncate">
|
||||
Please select an option
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="
|
||||
absolute
|
||||
inset-y-0
|
||||
right-0
|
||||
flex
|
||||
items-center
|
||||
pr-2
|
||||
pointer-events-none
|
||||
"
|
||||
>
|
||||
<BaseIcon
|
||||
name="SelectorIcon"
|
||||
class="text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="
|
||||
absolute
|
||||
z-10
|
||||
w-full
|
||||
py-1
|
||||
mt-1
|
||||
overflow-auto
|
||||
text-base
|
||||
bg-white
|
||||
rounded-md
|
||||
shadow-lg
|
||||
max-h-60
|
||||
ring-1 ring-black ring-opacity-5
|
||||
focus:outline-none
|
||||
sm:text-sm
|
||||
"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="option in options"
|
||||
v-slot="{ active, selected }"
|
||||
:key="option.id"
|
||||
:value="option"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'text-white bg-primary-600' : 'text-gray-900',
|
||||
'cursor-default select-none relative py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>
|
||||
{{ getValue(option) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-primary-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<BaseIcon name="CheckIcon" aria-hidden="true" />
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<slot />
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Object, Array],
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
labelKey: {
|
||||
type: [String],
|
||||
default: 'label',
|
||||
},
|
||||
valueProp: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
let selectedValue = ref(props.modelValue)
|
||||
|
||||
function isObject(val) {
|
||||
return typeof val === 'object' && val !== null
|
||||
}
|
||||
|
||||
function getValue(val) {
|
||||
if (isObject(val)) {
|
||||
return val[props.labelKey]
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.valueProp && props.options.length) {
|
||||
selectedValue.value = props.options.find((val) => {
|
||||
if (val[props.valueProp]) {
|
||||
return val[props.valueProp] === props.modelValue
|
||||
}
|
||||
})
|
||||
} else {
|
||||
selectedValue.value = props.modelValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedValue, (val) => {
|
||||
if (props.valueProp) {
|
||||
emit('update:modelValue', val[props.valueProp])
|
||||
} else {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
42
resources/scripts/components/base/BaseSettingCard.vue
Normal file
42
resources/scripts/components/base/BaseSettingCard.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<BaseCard>
|
||||
<div class="flex flex-wrap justify-between lg:flex-nowrap mb-5">
|
||||
<div>
|
||||
<h6 class="font-medium text-lg text-left">
|
||||
{{ title }}
|
||||
</h6>
|
||||
|
||||
<p
|
||||
class="
|
||||
mt-2
|
||||
text-sm
|
||||
leading-snug
|
||||
text-left text-gray-500
|
||||
max-w-[680px]
|
||||
"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 lg:mt-0 lg:ml-2">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
69
resources/scripts/components/base/BaseSwitch.vue
Normal file
69
resources/scripts/components/base/BaseSwitch.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<SwitchGroup>
|
||||
<div class="flex flex-row items-start">
|
||||
<SwitchLabel v-if="labelLeft" class="mr-4 cursor-pointer">{{
|
||||
labelLeft
|
||||
}}</SwitchLabel>
|
||||
|
||||
<Switch
|
||||
v-model="enabled"
|
||||
:class="enabled ? 'bg-primary-500' : 'bg-gray-300'"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
h-6
|
||||
transition-colors
|
||||
rounded-full
|
||||
w-11
|
||||
focus:outline-none focus:ring-primary-500
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<span
|
||||
:class="enabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
class="
|
||||
inline-block
|
||||
w-4
|
||||
h-4
|
||||
transition-transform
|
||||
transform
|
||||
bg-white
|
||||
rounded-full
|
||||
"
|
||||
/>
|
||||
</Switch>
|
||||
|
||||
<SwitchLabel v-if="labelRight" class="ml-4 cursor-pointer">{{
|
||||
labelRight
|
||||
}}</SwitchLabel>
|
||||
</div>
|
||||
</SwitchGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue'
|
||||
|
||||
const props = defineProps({
|
||||
labelLeft: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
labelRight: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const enabled = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
</script>
|
||||
64
resources/scripts/components/base/BaseSwitchSection.vue
Normal file
64
resources/scripts/components/base/BaseSwitchSection.vue
Normal file
@ -0,0 +1,64 @@
|
||||
|
||||
|
||||
<template>
|
||||
<SwitchGroup as="li" class="py-4 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<SwitchLabel
|
||||
as="p"
|
||||
class="p-0 mb-1 text-sm leading-snug text-black font-medium"
|
||||
passive
|
||||
>
|
||||
{{ title }}
|
||||
</SwitchLabel>
|
||||
<SwitchDescription class="text-sm text-gray-500">
|
||||
{{ description }}
|
||||
</SwitchDescription>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="modelValue"
|
||||
:class="[
|
||||
modelValue ? 'bg-primary-500' : 'bg-gray-200',
|
||||
'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500',
|
||||
]"
|
||||
@update:modelValue="onUpdate"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
modelValue ? 'translate-x-5' : 'translate-x-0',
|
||||
'inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function onUpdate(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
29
resources/scripts/components/base/BaseTab.vue
Normal file
29
resources/scripts/components/base/BaseTab.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<TabPanel :class="[tabPanelContainer, 'focus:outline-none']">
|
||||
<!-- focus:ring-1 focus:ring-jet focus:ring-opacity-60 -->
|
||||
<slot />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TabPanel } from '@headlessui/vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: [String, Number],
|
||||
default: 'Tab',
|
||||
},
|
||||
count: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
countVariant: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
tabPanelContainer: {
|
||||
type: String,
|
||||
default: 'py-4 mt-px',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
72
resources/scripts/components/base/BaseTabGroup.vue
Normal file
72
resources/scripts/components/base/BaseTabGroup.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div>
|
||||
<TabGroup :default-index="defaultIndex" @change="onChange">
|
||||
<TabList
|
||||
:class="[
|
||||
'flex border-b border-grey-light',
|
||||
'relative overflow-x-auto overflow-y-hidden',
|
||||
'lg:pb-0 lg:ml-0',
|
||||
]"
|
||||
>
|
||||
<Tab
|
||||
v-for="(tab, index) in tabs"
|
||||
v-slot="{ selected }"
|
||||
:key="index"
|
||||
as="template"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'px-8 py-2 text-sm leading-5 font-medium flex items-center relative border-b-2 mt-4 focus:outline-none whitespace-nowrap',
|
||||
selected
|
||||
? ' border-primary-400 text-black font-medium'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||
]"
|
||||
>
|
||||
{{ tab.title }}
|
||||
|
||||
<BaseBadge
|
||||
v-if="tab.count"
|
||||
class="!rounded-full overflow-hidden ml-2"
|
||||
:variant="tab['count-variant']"
|
||||
default-class="flex items-center justify-center w-5 h-5 p-1 rounded-full text-medium"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</BaseBadge>
|
||||
</button>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<slot name="before-tabs" />
|
||||
|
||||
<TabPanels>
|
||||
<slot />
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { TabGroup, TabList, Tab, TabPanels } from '@headlessui/vue'
|
||||
|
||||
const props = defineProps({
|
||||
defaultIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
filter: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const tabs = computed(() => slots.default().map((tab) => tab.props))
|
||||
|
||||
function onChange(d) {
|
||||
emit('change', tabs.value[d])
|
||||
}
|
||||
</script>
|
||||
105
resources/scripts/components/base/BaseTextarea.vue
Normal file
105
resources/scripts/components/base/BaseTextarea.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
:style="`height: ${loadingPlaceholderSize}px`"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<textarea
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
ref="textarea"
|
||||
:value="modelValue"
|
||||
:class="[defaultInputClass, inputBorderClass]"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
row: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
invalid: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
defaultInputClass: {
|
||||
type: String,
|
||||
default:
|
||||
'box-border w-full px-3 py-2 text-sm not-italic font-normal leading-snug text-left text-black placeholder-gray-400 bg-white border border-gray-200 border-solid rounded outline-none',
|
||||
},
|
||||
autosize: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
borderless: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const textarea = ref(null)
|
||||
|
||||
const inputBorderClass = computed(() => {
|
||||
if (props.invalid && !props.borderless) {
|
||||
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
|
||||
} else if (!props.borderless) {
|
||||
return 'focus:ring-primary-400 focus:border-primary-400'
|
||||
}
|
||||
|
||||
return 'border-none outline-none focus:ring-primary-400 focus:border focus:border-primary-400'
|
||||
})
|
||||
|
||||
const loadingPlaceholderSize = computed(() => {
|
||||
switch (props.row) {
|
||||
case 2:
|
||||
return '56'
|
||||
case 4:
|
||||
return '94'
|
||||
default:
|
||||
return '56'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function onInput(e) {
|
||||
emit('update:modelValue', e.target.value)
|
||||
|
||||
if (props.autosize) {
|
||||
e.target.style.height = 'auto'
|
||||
e.target.style.height = `${e.target.scrollHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (textarea.value && props.autosize) {
|
||||
textarea.value.style.height = textarea.value.scrollHeight + 'px'
|
||||
|
||||
if (textarea.value.style.overflow && textarea.value.style.overflow.y) {
|
||||
textarea.value.style.overflow.y = 'hidden'
|
||||
}
|
||||
|
||||
textarea.value.style.resize = 'none'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
138
resources/scripts/components/base/BaseTimePicker.vue
Normal file
138
resources/scripts/components/base/BaseTimePicker.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
:class="`w-full ${computedContainerClass}`"
|
||||
style="height: 38px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<div v-else :class="computedContainerClass" class="relative flex flex-row">
|
||||
<svg
|
||||
v-if="clockIcon && !hasIconSlot"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="
|
||||
absolute
|
||||
top-px
|
||||
w-4
|
||||
h-4
|
||||
mx-2
|
||||
my-2.5
|
||||
text-sm
|
||||
not-italic
|
||||
font-black
|
||||
text-gray-400
|
||||
cursor-pointer
|
||||
"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
@click="onClickPicker"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<slot v-if="clockIcon && hasIconSlot" name="icon" />
|
||||
|
||||
<FlatPickr
|
||||
ref="dpt"
|
||||
v-model="time"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:config="config"
|
||||
:class="[defaultInputClass, inputInvalidClass, inputDisabledClass]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, reactive, useSlots, ref } from 'vue'
|
||||
const dpt = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Date],
|
||||
default: () => moment(new Date()),
|
||||
},
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
invalid: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
clockIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultInputClass: {
|
||||
type: String,
|
||||
default:
|
||||
'font-base pl-8 py-2 outline-none focus:ring-primary-400 focus:outline-none focus:border-primary-400 block w-full sm:text-sm border-gray-300 rounded-md text-black',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
let config = reactive({
|
||||
enableTime: true,
|
||||
noCalendar: true,
|
||||
dateFormat: 'H:i',
|
||||
time_24hr: true,
|
||||
})
|
||||
|
||||
const time = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const hasIconSlot = computed(() => {
|
||||
return !!slots.icon
|
||||
})
|
||||
|
||||
function onClickPicker(params) {
|
||||
dpt.value.fp.open()
|
||||
}
|
||||
|
||||
const computedContainerClass = computed(() => {
|
||||
let containerClass = `${props.containerClass} `
|
||||
|
||||
return containerClass
|
||||
})
|
||||
|
||||
const inputInvalidClass = computed(() => {
|
||||
if (props.invalid) {
|
||||
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputDisabledClass = computed(() => {
|
||||
if (props.disabled) {
|
||||
return 'border border-solid rounded-md outline-none input-field box-border-2 base-date-picker-input placeholder-gray-400 bg-gray-300 text-gray-600 border-gray-300'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
36
resources/scripts/components/base/BaseWizard.vue
Normal file
36
resources/scripts/components/base/BaseWizard.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<slot name="nav">
|
||||
<WizardNavigation
|
||||
:current-step="currentStep"
|
||||
:steps="steps"
|
||||
@click="(stepIndex) => $emit('click', stepIndex)"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<div :class="wizardStepsContainerClass">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import WizardNavigation from './BaseWizardNavigation.vue'
|
||||
|
||||
const props = defineProps({
|
||||
wizardStepsContainerClass: {
|
||||
type: String,
|
||||
default: 'relative flex items-center justify-center',
|
||||
},
|
||||
currentStep: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
steps: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
</script>
|
||||
100
resources/scripts/components/base/BaseWizardNavigation.vue
Normal file
100
resources/scripts/components/base/BaseWizardNavigation.vue
Normal file
@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div
|
||||
:class="containerClass"
|
||||
class="
|
||||
relative
|
||||
after:bg-gray-200
|
||||
after:absolute
|
||||
after:transform
|
||||
after:top-1/2
|
||||
after:-translate-y-1/2
|
||||
after:h-2
|
||||
after:w-full
|
||||
"
|
||||
>
|
||||
<a
|
||||
v-for="(number, index) in steps"
|
||||
:key="index"
|
||||
:class="stepStyle(number)"
|
||||
class="z-10"
|
||||
href="#"
|
||||
@click.prevent="$emit('click', index)"
|
||||
>
|
||||
<svg
|
||||
v-if="currentStep > number"
|
||||
:class="iconClass"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@click="$emit('click', index)"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
currentStep: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
steps: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: 'flex justify-between w-full my-10 max-w-xl mx-auto',
|
||||
},
|
||||
progress: {
|
||||
type: String,
|
||||
default: 'rounded-full float-left w-6 h-6 border-4 cursor-pointer',
|
||||
},
|
||||
currentStepClass: {
|
||||
type: String,
|
||||
default: 'bg-white border-primary-500',
|
||||
},
|
||||
nextStepClass: {
|
||||
type: String,
|
||||
default: 'border-gray-200 bg-white',
|
||||
},
|
||||
previousStepClass: {
|
||||
type: String,
|
||||
default:
|
||||
'bg-primary-500 border-primary-500 flex justify-center items-center',
|
||||
},
|
||||
iconClass: {
|
||||
type: String,
|
||||
default:
|
||||
'flex items-center justify-center w-full h-full text-sm font-black text-center text-white',
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['click'],
|
||||
|
||||
setup(props) {
|
||||
function stepStyle(number) {
|
||||
if (props.currentStep === number) {
|
||||
return [props.currentStepClass, props.progress]
|
||||
}
|
||||
if (props.currentStep > number) {
|
||||
return [props.previousStepClass, props.progress]
|
||||
}
|
||||
if (props.currentStep < number) {
|
||||
return [props.nextStepClass, props.progress]
|
||||
}
|
||||
return [props.progress]
|
||||
}
|
||||
|
||||
return {
|
||||
stepStyle,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
40
resources/scripts/components/base/BaseWizardStep.vue
Normal file
40
resources/scripts/components/base/BaseWizardStep.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div :class="stepContainerClass">
|
||||
<div v-if="title || description">
|
||||
<p v-if="title" :class="stepTitleClass">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p v-if="description" :class="stepDescriptionClass">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
stepContainerClass: {
|
||||
type: String,
|
||||
default:
|
||||
'w-full p-8 mb-8 bg-white border border-gray-200 border-solid rounded',
|
||||
},
|
||||
stepTitleClass: {
|
||||
type: String,
|
||||
default: 'text-2xl not-italic font-semibold leading-7 text-black',
|
||||
},
|
||||
stepDescriptionClass: {
|
||||
type: String,
|
||||
default:
|
||||
'w-full mt-2.5 mb-8 text-sm not-italic leading-snug text-gray-500 lg:w-7/12 md:w-7/12 sm:w-7/12',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
672
resources/scripts/components/base/base-editor/BaseEditor.vue
Normal file
672
resources/scripts/components/base/base-editor/BaseEditor.vue
Normal file
@ -0,0 +1,672 @@
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
style="height: 200px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
<div
|
||||
v-else
|
||||
class="
|
||||
box-border
|
||||
w-full
|
||||
text-sm
|
||||
leading-8
|
||||
text-left
|
||||
bg-white
|
||||
border border-gray-200
|
||||
rounded-md
|
||||
min-h-[200px]
|
||||
overflow-hidden
|
||||
"
|
||||
>
|
||||
<div v-if="editor" class="editor-content">
|
||||
<div class="flex justify-end p-2 border-b border-gray-200 md:hidden">
|
||||
<BaseDropdown width-class="w-48">
|
||||
<template #activator>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
ml-2
|
||||
text-sm text-black
|
||||
bg-white
|
||||
rounded-sm
|
||||
md:h-9 md:w-9
|
||||
"
|
||||
>
|
||||
<dots-vertical-icon class="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-wrap space-x-1">
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('bold') }"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<bold-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('italic') }"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<italic-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('strike') }"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
<strikethrough-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('code') }"
|
||||
@click="editor.chain().focus().toggleCode().run()"
|
||||
>
|
||||
<coding-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('paragraph') }"
|
||||
@click="editor.chain().focus().setParagraph().run()"
|
||||
>
|
||||
<paragraph-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{
|
||||
'bg-gray-200': editor.isActive('heading', { level: 1 }),
|
||||
}"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
>
|
||||
H1
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{
|
||||
'bg-gray-200': editor.isActive('heading', { level: 2 }),
|
||||
}"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||
>
|
||||
H2
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{
|
||||
'bg-gray-200': editor.isActive('heading', { level: 3 }),
|
||||
}"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||
>
|
||||
H3
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('bulletList') }"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
<list-ul-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('orderedList') }"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
>
|
||||
<list-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('blockquote') }"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
>
|
||||
<quote-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('codeBlock') }"
|
||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||
>
|
||||
<code-block-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('undo') }"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
>
|
||||
<undo-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('redo') }"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
>
|
||||
<redo-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
</BaseDropdown>
|
||||
</div>
|
||||
<div class="hidden p-2 border-b border-gray-200 md:flex">
|
||||
<div class="flex flex-wrap space-x-1">
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('bold') }"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
>
|
||||
<bold-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('italic') }"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
>
|
||||
<italic-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('strike') }"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
>
|
||||
<strikethrough-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('code') }"
|
||||
@click="editor.chain().focus().toggleCode().run()"
|
||||
>
|
||||
<coding-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('paragraph') }"
|
||||
@click="editor.chain().focus().setParagraph().run()"
|
||||
>
|
||||
<paragraph-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 1 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
>
|
||||
H1
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 2 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||
>
|
||||
H2
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 3 }) }"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||
>
|
||||
H3
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('bulletList') }"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
>
|
||||
<list-ul-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('orderedList') }"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
>
|
||||
<list-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('blockquote') }"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
>
|
||||
<quote-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('codeBlock') }"
|
||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||
>
|
||||
<code-block-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('undo') }"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
>
|
||||
<undo-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-6
|
||||
h-6
|
||||
rounded-sm
|
||||
cursor-pointer
|
||||
hover:bg-gray-100
|
||||
"
|
||||
:class="{ 'bg-gray-200': editor.isActive('redo') }"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
>
|
||||
<redo-icon class="h-3 cursor-pointer fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<editor-content
|
||||
:editor="editor"
|
||||
class="
|
||||
box-border
|
||||
relative
|
||||
w-full
|
||||
text-sm
|
||||
leading-8
|
||||
text-left
|
||||
editor__content
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { DotsVerticalIcon } from '@heroicons/vue/outline'
|
||||
|
||||
import {
|
||||
BoldIcon,
|
||||
CodingIcon,
|
||||
ItalicIcon,
|
||||
ListIcon,
|
||||
ListUlIcon,
|
||||
ParagraphIcon,
|
||||
QuoteIcon,
|
||||
StrikethroughIcon,
|
||||
UndoIcon,
|
||||
RedoIcon,
|
||||
CodeBlockIcon,
|
||||
} from './icons/index.js'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditorContent,
|
||||
BoldIcon,
|
||||
CodingIcon,
|
||||
ItalicIcon,
|
||||
ListIcon,
|
||||
ListUlIcon,
|
||||
ParagraphIcon,
|
||||
QuoteIcon,
|
||||
StrikethroughIcon,
|
||||
UndoIcon,
|
||||
RedoIcon,
|
||||
CodeBlockIcon,
|
||||
DotsVerticalIcon,
|
||||
},
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
contentLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
extensions: [StarterKit],
|
||||
|
||||
onUpdate: () => {
|
||||
emit('update:modelValue', editor.value.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const isSame = editor.value.getHTML() === value
|
||||
|
||||
if (isSame) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.value.commands.setContent(props.modelValue, false)
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
setTimeout(() => {
|
||||
editor.value.destroy()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return {
|
||||
editor,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.ProseMirror {
|
||||
min-height: 200px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
@apply rounded-md rounded-tl-none rounded-tr-none border border-transparent;
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0 1rem;
|
||||
list-style: disc !important;
|
||||
}
|
||||
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
list-style: auto !important;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(#0d0d0d, 0.1);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(97, 97, 97, 0.1);
|
||||
color: #616161;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
@apply border border-primary-400 ring-primary-400;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.194 10.962A6.271 6.271 0 0012.844.248H4.3a1.25 1.25 0 000 2.5h1.013a.25.25 0 01.25.25V21a.25.25 0 01-.25.25H4.3a1.25 1.25 0 100 2.5h9.963a6.742 6.742 0 002.93-12.786zm-4.35-8.214a3.762 3.762 0 010 7.523H8.313a.25.25 0 01-.25-.25V3a.25.25 0 01.25-.25zm1.42 18.5H8.313a.25.25 0 01-.25-.25v-7.977a.25.25 0 01.25-.25h5.951a4.239 4.239 0 010 8.477z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M9.147 21.552a1.244 1.244 0 01-.895-.378L.84 13.561a2.257 2.257 0 010-3.125l7.412-7.613a1.25 1.25 0 011.791 1.744l-6.9 7.083a.5.5 0 000 .7l6.9 7.082a1.25 1.25 0 01-.9 2.122zm5.707 0a1.25 1.25 0 01-.9-2.122l6.9-7.083a.5.5 0 000-.7l-6.9-7.082a1.25 1.25 0 011.791-1.744l7.411 7.612a2.257 2.257 0 010 3.125l-7.412 7.614a1.244 1.244 0 01-.89.38zm6.514-9.373z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M9.147 21.552a1.244 1.244 0 01-.895-.378L.84 13.561a2.257 2.257 0 010-3.125l7.412-7.613a1.25 1.25 0 011.791 1.744l-6.9 7.083a.5.5 0 000 .7l6.9 7.082a1.25 1.25 0 01-.9 2.122zm5.707 0a1.25 1.25 0 01-.9-2.122l6.9-7.083a.5.5 0 000-.7l-6.9-7.082a1.25 1.25 0 011.791-1.744l7.411 7.612a2.257 2.257 0 010 3.125l-7.412 7.614a1.244 1.244 0 01-.89.38zm6.514-9.373z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.5.248h-7.637a1.25 1.25 0 000 2.5h1.086a.25.25 0 01.211.384L4.78 21.017a.5.5 0 01-.422.231H1.5a1.25 1.25 0 000 2.5h7.637a1.25 1.25 0 000-2.5H8.051a.25.25 0 01-.211-.384L19.22 2.98a.5.5 0 01.422-.232H22.5a1.25 1.25 0 000-2.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M7.75 4.5h15a1 1 0 000-2h-15a1 1 0 000 2zm15 6.5h-15a1 1 0 100 2h15a1 1 0 000-2zm0 8.5h-15a1 1 0 000 2h15a1 1 0 000-2zM2.212 17.248a2 2 0 00-1.933 1.484.75.75 0 101.45.386.5.5 0 11.483.63.75.75 0 100 1.5.5.5 0 11-.482.635.75.75 0 10-1.445.4 2 2 0 103.589-1.648.251.251 0 010-.278 2 2 0 00-1.662-3.111zm2.038-6.5a2 2 0 00-4 0 .75.75 0 001.5 0 .5.5 0 011 0 1.031 1.031 0 01-.227.645L.414 14.029A.75.75 0 001 15.248h2.5a.75.75 0 000-1.5h-.419a.249.249 0 01-.195-.406L3.7 12.33a2.544 2.544 0 00.55-1.582zM4 5.248h-.25A.25.25 0 013.5 5V1.623A1.377 1.377 0 002.125.248H1.5a.75.75 0 000 1.5h.25A.25.25 0 012 2v3a.25.25 0 01-.25.25H1.5a.75.75 0 000 1.5H4a.75.75 0 000-1.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="2.5" cy="3.998" r="2.5"></circle>
|
||||
<path d="M8.5 5H23a1 1 0 000-2H8.5a1 1 0 000 2z"></path>
|
||||
<circle cx="2.5" cy="11.998" r="2.5"></circle>
|
||||
<path d="M23 11H8.5a1 1 0 000 2H23a1 1 0 000-2z"></path>
|
||||
<circle cx="2.5" cy="19.998" r="2.5"></circle>
|
||||
<path d="M23 19H8.5a1 1 0 000 2H23a1 1 0 000-2z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.5.248H7.228a6.977 6.977 0 100 13.954h2.318a.25.25 0 01.25.25V22.5a1.25 1.25 0 002.5 0V3a.25.25 0 01.25-.25h3.682a.25.25 0 01.25.25v19.5a1.25 1.25 0 002.5 0V3a.249.249 0 01.25-.25H22.5a1.25 1.25 0 000-2.5zM9.8 11.452a.25.25 0 01-.25.25H7.228a4.477 4.477 0 110-8.954h2.318A.25.25 0 019.8 3z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18.559 3.932a4.942 4.942 0 100 9.883 4.609 4.609 0 001.115-.141.25.25 0 01.276.368 6.83 6.83 0 01-5.878 3.523 1.25 1.25 0 000 2.5 9.71 9.71 0 009.428-9.95V8.873a4.947 4.947 0 00-4.941-4.941zm-12.323 0a4.942 4.942 0 000 9.883 4.6 4.6 0 001.115-.141.25.25 0 01.277.368 6.83 6.83 0 01-5.878 3.523 1.25 1.25 0 000 2.5 9.711 9.711 0 009.428-9.95V8.873a4.947 4.947 0 00-4.942-4.941z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.608.161a.5.5 0 00-.545.108L19.472 2.86a.25.25 0 01-.292.045 12.537 12.537 0 00-12.966.865A12.259 12.259 0 006.1 23.632a1.25 1.25 0 001.476-2.018 9.759 9.759 0 01.091-15.809 10 10 0 019.466-1.1.25.25 0 01.084.409l-1.85 1.85a.5.5 0 00.354.853h6.7a.5.5 0 00.5-.5V.623a.5.5 0 00-.313-.462z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M23.75 12.952A1.25 1.25 0 0022.5 11.7h-8.936a.492.492 0 01-.282-.09c-.722-.513-1.482-.981-2.218-1.432-2.8-1.715-4.5-2.9-4.5-4.863 0-2.235 2.207-2.569 3.523-2.569a4.54 4.54 0 013.081.764 2.662 2.662 0 01.447 1.99v.3a1.25 1.25 0 102.5 0v-.268a4.887 4.887 0 00-1.165-3.777C13.949.741 12.359.248 10.091.248c-3.658 0-6.023 1.989-6.023 5.069 0 2.773 1.892 4.512 4 5.927a.25.25 0 01-.139.458H1.5a1.25 1.25 0 000 2.5h10.977a.251.251 0 01.159.058 4.339 4.339 0 011.932 3.466c0 3.268-3.426 3.522-4.477 3.522-1.814 0-3.139-.405-3.834-1.173a3.394 3.394 0 01-.65-2.7 1.25 1.25 0 00-2.488-.246A5.76 5.76 0 004.4 21.753c1.2 1.324 3.114 2 5.688 2 4.174 0 6.977-2.42 6.977-6.022a6.059 6.059 0 00-.849-3.147.25.25 0 01.216-.377H22.5a1.25 1.25 0 001.25-1.255z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.5 21.248h-21a1.25 1.25 0 000 2.5h21a1.25 1.25 0 000-2.5zM1.978 2.748h1.363a.25.25 0 01.25.25v8.523a8.409 8.409 0 0016.818 0V3a.25.25 0 01.25-.25h1.363a1.25 1.25 0 000-2.5H16.3a1.25 1.25 0 000 2.5h1.363a.25.25 0 01.25.25v8.523a5.909 5.909 0 01-11.818 0V3a.25.25 0 01.25-.25H7.7a1.25 1.25 0 100-2.5H1.978a1.25 1.25 0 000 2.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.786 3.77a12.542 12.542 0 00-12.965-.865.249.249 0 01-.292-.045L1.937.269A.507.507 0 001.392.16a.5.5 0 00-.308.462v6.7a.5.5 0 00.5.5h6.7a.5.5 0 00.354-.854L6.783 5.115a.253.253 0 01-.068-.228.249.249 0 01.152-.181 10 10 0 019.466 1.1 9.759 9.759 0 01.094 15.809 1.25 1.25 0 001.473 2.016 12.122 12.122 0 005.013-9.961 12.125 12.125 0 00-5.127-9.9z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
27
resources/scripts/components/base/base-editor/icons/index.js
Normal file
27
resources/scripts/components/base/base-editor/icons/index.js
Normal file
@ -0,0 +1,27 @@
|
||||
import UnderlineIcon from './UnderlineIcon.vue'
|
||||
import BoldIcon from './BoldIcon.vue'
|
||||
import CodingIcon from './CodingIcon.vue'
|
||||
import ItalicIcon from './ItalicIcon.vue'
|
||||
import ListIcon from './ListIcon.vue'
|
||||
import ListUlIcon from './ListUlIcon.vue'
|
||||
import ParagraphIcon from './ParagraphIcon.vue'
|
||||
import QuoteIcon from './QuoteIcon.vue'
|
||||
import StrikethroughIcon from './StrikethroughIcon.vue'
|
||||
import UndoIcon from './UndoIcon.vue'
|
||||
import RedoIcon from './RedoIcon.vue'
|
||||
import CodeBlockIcon from './CodeBlockIcon.vue'
|
||||
|
||||
export {
|
||||
UnderlineIcon,
|
||||
BoldIcon,
|
||||
CodingIcon,
|
||||
ItalicIcon,
|
||||
ListIcon,
|
||||
ListUlIcon,
|
||||
ParagraphIcon,
|
||||
QuoteIcon,
|
||||
StrikethroughIcon,
|
||||
UndoIcon,
|
||||
RedoIcon,
|
||||
CodeBlockIcon
|
||||
}
|
||||
351
resources/scripts/components/base/base-table/BaseTable.vue
Normal file
351
resources/scripts/components/base/base-table/BaseTable.vue
Normal file
@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8 pb-4 lg:pb-0">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="
|
||||
relative
|
||||
overflow-hidden
|
||||
bg-white
|
||||
border-b border-gray-200
|
||||
shadow
|
||||
sm:rounded-lg
|
||||
"
|
||||
>
|
||||
<slot name="header" />
|
||||
<table :class="tableClass">
|
||||
<thead :class="theadClass">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in tableColumns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
getThClass(column),
|
||||
{
|
||||
'text-bold text-black': sort.fieldName === column.key,
|
||||
},
|
||||
]"
|
||||
@click="changeSorting(column)"
|
||||
>
|
||||
{{ column.label }}
|
||||
<span
|
||||
v-if="sort.fieldName === column.key && sort.order === 'asc'"
|
||||
class="asc-direction"
|
||||
>
|
||||
↑
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
sort.fieldName === column.key && sort.order === 'desc'
|
||||
"
|
||||
class="desc-direction"
|
||||
>
|
||||
↓
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
v-if="loadingType === 'placeholder' && (loading || isLoading)"
|
||||
>
|
||||
<tr
|
||||
v-for="placeRow in placeholderCount"
|
||||
:key="placeRow"
|
||||
:class="placeRow % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class=""
|
||||
:class="getTdClass(column)"
|
||||
>
|
||||
<base-content-placeholders
|
||||
:class="getPlaceholderClass(column)"
|
||||
:rounded="true"
|
||||
>
|
||||
<base-content-placeholders-text
|
||||
class="w-full h-6"
|
||||
:lines="1"
|
||||
/>
|
||||
</base-content-placeholders>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<tr
|
||||
v-for="(row, index) in sortedRows"
|
||||
:key="index"
|
||||
:class="index % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class=""
|
||||
:class="getTdClass(column)"
|
||||
>
|
||||
<slot :name="'cell-' + column.key" :row="row">
|
||||
{{ lodashGet(row.data, column.key) }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-if="loadingType === 'spinner' && (loading || isLoading)"
|
||||
class="
|
||||
absolute
|
||||
top-0
|
||||
left-0
|
||||
z-10
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-full
|
||||
h-full
|
||||
bg-white bg-opacity-60
|
||||
"
|
||||
>
|
||||
<SpinnerIcon class="w-10 h-10 text-primary-500" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
!loading && !isLoading && sortedRows && sortedRows.length === 0
|
||||
"
|
||||
class="
|
||||
text-center text-gray-500
|
||||
pb-2
|
||||
flex
|
||||
h-[160px]
|
||||
justify-center
|
||||
items-center
|
||||
flex-col
|
||||
"
|
||||
>
|
||||
<BaseIcon
|
||||
name="ExclamationCircleIcon"
|
||||
class="w-6 h-6 text-gray-400"
|
||||
/>
|
||||
|
||||
<span class="block mt-1">{{ noResultsMessage }}</span>
|
||||
</div>
|
||||
|
||||
<BaseTablePagination
|
||||
v-if="pagination"
|
||||
:pagination="pagination"
|
||||
@pageChange="pageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, watch, ref, reactive } from 'vue'
|
||||
import { get } from 'lodash'
|
||||
import Row from './Row'
|
||||
import Column from './Column'
|
||||
import BaseTablePagination from './BaseTablePagination.vue'
|
||||
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: [Array, Function],
|
||||
required: true,
|
||||
},
|
||||
sortBy: { type: String, default: '' },
|
||||
sortOrder: { type: String, default: '' },
|
||||
tableClass: {
|
||||
type: String,
|
||||
default: 'min-w-full divide-y divide-gray-200',
|
||||
},
|
||||
theadClass: { type: String, default: 'bg-gray-50' },
|
||||
tbodyClass: { type: String, default: '' },
|
||||
noResultsMessage: {
|
||||
type: String,
|
||||
default: 'No Results Found',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingType: {
|
||||
type: String,
|
||||
default: 'placeholder',
|
||||
validator: function (value) {
|
||||
return ['placeholder', 'spinner'].indexOf(value) !== -1
|
||||
},
|
||||
},
|
||||
placeholderCount: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
})
|
||||
|
||||
let rows = reactive([])
|
||||
let isLoading = ref(false)
|
||||
|
||||
let tableColumns = reactive(props.columns.map((column) => new Column(column)))
|
||||
|
||||
let sort = reactive({
|
||||
fieldName: '',
|
||||
order: '',
|
||||
})
|
||||
|
||||
let pagination = ref('')
|
||||
|
||||
const usesLocalData = computed(() => {
|
||||
return Array.isArray(props.data)
|
||||
})
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
if (!usesLocalData.value) {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
if (sort.fieldName === '') {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
if (tableColumns.length === 0) {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
const sortColumn = getColumn(sort.fieldName)
|
||||
|
||||
if (!sortColumn) {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
let sorted = [...rows.value].sort(
|
||||
sortColumn.getSortPredicate(sort.order, tableColumns)
|
||||
)
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
function getColumn(columnName) {
|
||||
return tableColumns.find((column) => column.key === columnName)
|
||||
}
|
||||
|
||||
function getThClass(column) {
|
||||
let classes =
|
||||
'whitespace-nowrap px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider'
|
||||
|
||||
if (column.defaultThClass) {
|
||||
classes = column.defaultThClass
|
||||
}
|
||||
|
||||
if (column.sortable) {
|
||||
classes = `${classes} cursor-pointer`
|
||||
} else {
|
||||
classes = `${classes} pointer-events-none`
|
||||
}
|
||||
|
||||
if (column.thClass) {
|
||||
classes = `${classes} ${column.thClass}`
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function getTdClass(column) {
|
||||
let classes = 'px-6 py-4 text-sm text-gray-500 whitespace-nowrap'
|
||||
|
||||
if (column.defaultTdClass) {
|
||||
classes = column.defaultTdClass
|
||||
}
|
||||
|
||||
if (column.tdClass) {
|
||||
classes = `${classes} ${column.tdClass}`
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function getPlaceholderClass(column) {
|
||||
let classes = 'w-full'
|
||||
|
||||
if (column.placeholderClass) {
|
||||
classes = `${classes} ${column.placeholderClass}`
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function prepareLocalData() {
|
||||
pagination.value = null
|
||||
return props.data
|
||||
}
|
||||
|
||||
async function fetchServerData() {
|
||||
const page = (pagination.value && pagination.value.currentPage) || 1
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const response = await props.data({
|
||||
sort,
|
||||
page,
|
||||
})
|
||||
|
||||
isLoading.value = false
|
||||
|
||||
pagination.value = response.pagination
|
||||
return response.data
|
||||
}
|
||||
|
||||
function changeSorting(column) {
|
||||
if (sort.fieldName !== column.key) {
|
||||
sort.fieldName = column.key
|
||||
sort.order = 'asc'
|
||||
} else {
|
||||
sort.order = sort.order === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
if (!usesLocalData.value) {
|
||||
mapDataToRows()
|
||||
}
|
||||
}
|
||||
|
||||
async function mapDataToRows() {
|
||||
const data = usesLocalData.value
|
||||
? prepareLocalData()
|
||||
: await fetchServerData()
|
||||
|
||||
rows.value = data.map((rowData) => new Row(rowData, tableColumns))
|
||||
}
|
||||
|
||||
async function pageChange(page) {
|
||||
pagination.value.currentPage = page
|
||||
await mapDataToRows()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await mapDataToRows()
|
||||
}
|
||||
|
||||
function lodashGet(array, key) {
|
||||
return get(array, key)
|
||||
}
|
||||
|
||||
if (usesLocalData.value) {
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
mapDataToRows()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await mapDataToRows()
|
||||
})
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShowPagination"
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
px-4
|
||||
py-3
|
||||
bg-white
|
||||
border-t border-gray-200
|
||||
sm:px-6
|
||||
"
|
||||
>
|
||||
<div class="flex justify-between flex-1 sm:hidden">
|
||||
<a
|
||||
href="#"
|
||||
:class="{
|
||||
'disabled cursor-normal pointer-events-none !bg-gray-100 !text-gray-400':
|
||||
pagination.currentPage === 1,
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-700
|
||||
bg-white
|
||||
border border-gray-300
|
||||
rounded-md
|
||||
hover:bg-gray-50
|
||||
"
|
||||
@click="pageClicked(pagination.currentPage - 1)"
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
:class="{
|
||||
'disabled cursor-default pointer-events-none !bg-gray-100 !text-gray-400':
|
||||
pagination.currentPage === pagination.totalPages,
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
ml-3
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-700
|
||||
bg-white
|
||||
border border-gray-300
|
||||
rounded-md
|
||||
hover:bg-gray-50
|
||||
"
|
||||
@click="pageClicked(pagination.currentPage + 1)"
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing
|
||||
{{ ' ' }}
|
||||
<span
|
||||
v-if="pagination.limit && pagination.currentPage"
|
||||
class="font-medium"
|
||||
>
|
||||
{{
|
||||
pagination.currentPage * pagination.limit - (pagination.limit - 1)
|
||||
}}
|
||||
</span>
|
||||
{{ ' ' }}
|
||||
to
|
||||
{{ ' ' }}
|
||||
<span
|
||||
v-if="pagination.limit && pagination.currentPage"
|
||||
class="font-medium"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
pagination.currentPage * pagination.limit <=
|
||||
pagination.totalCount
|
||||
"
|
||||
>
|
||||
{{ pagination.currentPage * pagination.limit }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ pagination.totalCount }}
|
||||
</span>
|
||||
</span>
|
||||
{{ ' ' }}
|
||||
of
|
||||
{{ ' ' }}
|
||||
<span v-if="pagination.totalCount" class="font-medium">
|
||||
{{ pagination.totalCount }}
|
||||
</span>
|
||||
{{ ' ' }}
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
class="relative z-0 inline-flex -space-x-px rounded-md shadow-sm"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
:class="{
|
||||
'disabled cursor-normal pointer-events-none !bg-gray-100 !text-gray-400':
|
||||
pagination.currentPage === 1,
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-2
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-500
|
||||
bg-white
|
||||
border border-gray-300
|
||||
rounded-l-md
|
||||
hover:bg-gray-50
|
||||
"
|
||||
@click="pageClicked(pagination.currentPage - 1)"
|
||||
>
|
||||
<span class="sr-only">Previous</span>
|
||||
<BaseIcon name="ChevronLeftIcon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="hasFirst"
|
||||
href="#"
|
||||
aria-current="page"
|
||||
:class="{
|
||||
'z-10 bg-primary-50 border-primary-500 text-primary-600':
|
||||
isActive(1),
|
||||
'bg-white border-gray-300 text-gray-500 hover:bg-gray-50':
|
||||
!isActive(1),
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
border
|
||||
"
|
||||
@click="pageClicked(1)"
|
||||
>
|
||||
1
|
||||
</a>
|
||||
|
||||
<span
|
||||
v-if="hasFirstEllipsis"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-700
|
||||
bg-white
|
||||
border border-gray-300
|
||||
"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<a
|
||||
v-for="page in pages"
|
||||
:key="page"
|
||||
href="#"
|
||||
:class="{
|
||||
'z-10 bg-primary-50 border-primary-500 text-primary-600':
|
||||
isActive(page),
|
||||
'bg-white border-gray-300 text-gray-500 hover:bg-gray-50':
|
||||
!isActive(page),
|
||||
disabled: page === '...',
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
items-center
|
||||
hidden
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-500
|
||||
bg-white
|
||||
border border-gray-300
|
||||
hover:bg-gray-50
|
||||
md:inline-flex
|
||||
"
|
||||
@click="pageClicked(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</a>
|
||||
|
||||
<span
|
||||
v-if="hasLastEllipsis"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-700
|
||||
bg-white
|
||||
border border-gray-300
|
||||
"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<a
|
||||
v-if="hasLast"
|
||||
href="#"
|
||||
aria-current="page"
|
||||
:class="{
|
||||
'z-10 bg-primary-50 border-primary-500 text-primary-600':
|
||||
isActive(pagination.totalPages),
|
||||
'bg-white border-gray-300 text-gray-500 hover:bg-gray-50':
|
||||
!isActive(pagination.totalPages),
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
border
|
||||
"
|
||||
@click="pageClicked(pagination.totalPages)"
|
||||
>
|
||||
{{ pagination.totalPages }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-2
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-gray-500
|
||||
bg-white
|
||||
border border-gray-300
|
||||
rounded-r-md
|
||||
hover:bg-gray-50
|
||||
"
|
||||
:class="{
|
||||
'disabled cursor-default pointer-events-none !bg-gray-100 !text-gray-400':
|
||||
pagination.currentPage === pagination.totalPages,
|
||||
}"
|
||||
@click="pageClicked(pagination.currentPage + 1)"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<BaseIcon name="ChevronRightIcon" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Todo: Need to convert this to Composition API
|
||||
|
||||
export default {
|
||||
props: {
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
pages() {
|
||||
return this.pagination.totalPages === undefined ? [] : this.pageLinks()
|
||||
},
|
||||
hasFirst() {
|
||||
return this.pagination.currentPage >= 4 || this.pagination.totalPages < 10
|
||||
},
|
||||
hasLast() {
|
||||
return (
|
||||
this.pagination.currentPage <= this.pagination.totalPages - 3 ||
|
||||
this.pagination.totalPages < 10
|
||||
)
|
||||
},
|
||||
hasFirstEllipsis() {
|
||||
return (
|
||||
this.pagination.currentPage >= 4 && this.pagination.totalPages >= 10
|
||||
)
|
||||
},
|
||||
hasLastEllipsis() {
|
||||
return (
|
||||
this.pagination.currentPage <= this.pagination.totalPages - 3 &&
|
||||
this.pagination.totalPages >= 10
|
||||
)
|
||||
},
|
||||
shouldShowPagination() {
|
||||
if (this.pagination.totalPages === undefined) {
|
||||
return false
|
||||
}
|
||||
if (this.pagination.count === 0) {
|
||||
return false
|
||||
}
|
||||
return this.pagination.totalPages > 1
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isActive(page) {
|
||||
const currentPage = this.pagination.currentPage || 1
|
||||
return currentPage === page
|
||||
},
|
||||
pageClicked(page) {
|
||||
if (
|
||||
page === '...' ||
|
||||
page === this.pagination.currentPage ||
|
||||
page > this.pagination.totalPages ||
|
||||
page < 1
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('pageChange', page)
|
||||
},
|
||||
pageLinks() {
|
||||
const pages = []
|
||||
let left = 2
|
||||
let right = this.pagination.totalPages - 1
|
||||
if (this.pagination.totalPages >= 10) {
|
||||
left = Math.max(1, this.pagination.currentPage - 2)
|
||||
right = Math.min(
|
||||
this.pagination.currentPage + 2,
|
||||
this.pagination.totalPages
|
||||
)
|
||||
}
|
||||
for (let i = left; i <= right; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
66
resources/scripts/components/base/base-table/Column.js
Normal file
66
resources/scripts/components/base/base-table/Column.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { pick } from './helpers';
|
||||
|
||||
export default class Column {
|
||||
constructor(columnObject) {
|
||||
const properties = pick(columnObject, [
|
||||
'key', 'label', 'thClass', 'tdClass', 'sortBy', 'sortable', 'hidden', 'dataType'
|
||||
]);
|
||||
|
||||
for (const property in properties) {
|
||||
this[property] = columnObject[property];
|
||||
}
|
||||
|
||||
if (!properties['dataType']) {
|
||||
this['dataType'] = 'string'
|
||||
}
|
||||
|
||||
if (properties['sortable'] === undefined) {
|
||||
this['sortable'] = true
|
||||
}
|
||||
}
|
||||
|
||||
getFilterFieldName() {
|
||||
return this.filterOn || this.key;
|
||||
}
|
||||
|
||||
isSortable() {
|
||||
return this.sortable;
|
||||
}
|
||||
|
||||
getSortPredicate(sortOrder, allColumns) {
|
||||
const sortFieldName = this.getSortFieldName();
|
||||
|
||||
const sortColumn = allColumns.find(column => column.key === sortFieldName);
|
||||
|
||||
const dataType = sortColumn.dataType;
|
||||
|
||||
if (dataType.startsWith('date') || dataType === 'numeric') {
|
||||
|
||||
return (row1, row2) => {
|
||||
const value1 = row1.getSortableValue(sortFieldName);
|
||||
const value2 = row2.getSortableValue(sortFieldName);
|
||||
|
||||
if (sortOrder === 'desc') {
|
||||
return value2 < value1 ? -1 : 1;
|
||||
}
|
||||
|
||||
return value1 < value2 ? -1 : 1;
|
||||
};
|
||||
}
|
||||
|
||||
return (row1, row2) => {
|
||||
const value1 = row1.getSortableValue(sortFieldName);
|
||||
const value2 = row2.getSortableValue(sortFieldName);
|
||||
|
||||
if (sortOrder === 'desc') {
|
||||
return value2.localeCompare(value1);
|
||||
}
|
||||
|
||||
return value1.localeCompare(value2);
|
||||
};
|
||||
}
|
||||
|
||||
getSortFieldName() {
|
||||
return this.sortBy || this.key;
|
||||
}
|
||||
}
|
||||
43
resources/scripts/components/base/base-table/Row.js
Normal file
43
resources/scripts/components/base/base-table/Row.js
Normal file
@ -0,0 +1,43 @@
|
||||
import moment from 'moment';
|
||||
import { get } from './helpers';
|
||||
|
||||
export default class Row {
|
||||
constructor(data, columns) {
|
||||
this.data = data;
|
||||
this.columns = columns;
|
||||
}
|
||||
|
||||
getValue(columnName) {
|
||||
return get(this.data, columnName);
|
||||
}
|
||||
|
||||
getColumn(columnName) {
|
||||
return this.columns.find(column => column.key === columnName);
|
||||
}
|
||||
|
||||
getSortableValue(columnName) {
|
||||
const dataType = this.getColumn(columnName).dataType;
|
||||
|
||||
let value = this.getValue(columnName);
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (value instanceof String) {
|
||||
value = value.toLowerCase();
|
||||
}
|
||||
|
||||
if (dataType.startsWith('date')) {
|
||||
const format = dataType.replace('date:', '');
|
||||
|
||||
return moment(value, format).format('YYYYMMDDHHmmss');
|
||||
}
|
||||
|
||||
if (dataType === 'numeric') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
30
resources/scripts/components/base/base-table/helpers.js
Normal file
30
resources/scripts/components/base/base-table/helpers.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user