mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 03:04:05 +00:00
Rename resources/scripts-v2 to resources/scripts and drop @v2 alias
Now that the legacy v1 frontend (commit 064bdf53) is gone, the v2 directory is the only frontend and the v2 suffix is just noise. Renames resources/scripts-v2 to resources/scripts via git mv (so git records the move as renames, preserving blame and log --follow), then bulk-rewrites the 152 files that imported via @v2/... to use @/scripts/... instead. The existing @ alias (resources/) covers the new path with no extra config needed.
Drops the now-unused @v2 alias from vite.config.js and points the laravel-vite-plugin entry at resources/scripts/main.ts. Updates the only blade reference (resources/views/app.blade.php) to match. The package.json test script (eslint ./resources/scripts) automatically targets the right place after the rename without any edit.
Verified: npm run build exits clean and the Vite warning lines now reference resources/scripts/plugins/i18n.ts, confirming every import resolved through the new path. git log --follow on any moved file walks back through its scripts-v2 history.
This commit is contained in:
28
resources/scripts/components/base/BaseBadge.vue
Normal file
28
resources/scripts/components/base/BaseBadge.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span
|
||||
class="
|
||||
px-2
|
||||
py-1
|
||||
text-sm
|
||||
font-normal
|
||||
text-center text-status-green
|
||||
uppercase
|
||||
bg-success
|
||||
"
|
||||
:style="{ backgroundColor: bgColor, color }"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
bgColor?: string | null
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
bgColor: null,
|
||||
color: null,
|
||||
})
|
||||
</script>
|
||||
137
resources/scripts/components/base/BaseButton.vue
Normal file
137
resources/scripts/components/base/BaseButton.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
|
||||
|
||||
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
type ButtonVariant =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'primary-outline'
|
||||
| 'white'
|
||||
| 'danger'
|
||||
| 'gray'
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
defaultClass?: string
|
||||
tag?: string
|
||||
disabled?: boolean
|
||||
rounded?: boolean
|
||||
loading?: boolean
|
||||
size?: ButtonSize
|
||||
variant?: ButtonVariant
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
defaultClass:
|
||||
'inline-flex whitespace-nowrap items-center border font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2',
|
||||
tag: 'button',
|
||||
disabled: false,
|
||||
rounded: false,
|
||||
loading: false,
|
||||
size: 'md',
|
||||
variant: 'primary',
|
||||
})
|
||||
|
||||
const sizeClass = computed<Record<string, boolean>>(() => {
|
||||
return {
|
||||
'px-2.5 py-1.5 text-xs leading-4 rounded-lg': props.size === 'xs',
|
||||
'px-3 py-2 text-sm leading-4 rounded-lg': props.size == 'sm',
|
||||
'px-4 py-2 text-sm leading-5 rounded-lg': props.size === 'md',
|
||||
'px-4 py-2 text-base leading-6 rounded-lg': props.size === 'lg',
|
||||
'px-6 py-3 text-base leading-6 rounded-lg': props.size === 'xl',
|
||||
}
|
||||
})
|
||||
|
||||
const placeHolderSize = computed<string>(() => {
|
||||
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<Record<string, boolean>>(() => {
|
||||
return {
|
||||
'border-transparent shadow-xs text-white bg-btn-primary hover:bg-btn-primary-hover 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-solid border-primary-500 font-normal transition ease-in-out duration-150 text-primary-500 hover:bg-primary-200 shadow-inner focus:ring-primary-500':
|
||||
props.variant == 'primary-outline',
|
||||
'border-line-default text-body bg-surface hover:bg-hover focus:ring-primary-500 focus:ring-offset-0':
|
||||
props.variant == 'white',
|
||||
'border-transparent shadow-xs text-white bg-red-600 hover:bg-red-700 focus:ring-red-500':
|
||||
props.variant === 'danger',
|
||||
'border-transparent bg-surface-muted border hover:bg-surface-muted/60 focus:ring-gray-500 focus:ring-offset-0':
|
||||
props.variant === 'gray',
|
||||
}
|
||||
})
|
||||
|
||||
const roundedClass = computed<string>(() => {
|
||||
return props.rounded ? '!rounded-full' : ''
|
||||
})
|
||||
|
||||
const iconLeftClass = computed<Record<string, boolean>>(() => {
|
||||
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<Record<string, boolean>>(() => {
|
||||
return {
|
||||
'text-white': props.variant === 'primary',
|
||||
'text-primary-700': props.variant === 'secondary',
|
||||
'text-body': props.variant === 'white',
|
||||
'text-subtle': props.variant === 'gray',
|
||||
}
|
||||
})
|
||||
|
||||
const iconRightClass = computed<Record<string, boolean>>(() => {
|
||||
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>
|
||||
40
resources/scripts/components/base/BaseCard.vue
Normal file
40
resources/scripts/components/base/BaseCard.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="bg-surface rounded-xl shadow-sm border border-line-default">
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="px-5 py-4 text-heading border-b border-line-light border-solid"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div :class="containerClass">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="hasFooterSlot"
|
||||
class="px-5 py-4 border-t border-line-light border-solid sm:px-6"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
interface Props {
|
||||
containerClass?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
containerClass: 'px-4 py-5 sm:px-8 sm:py-8',
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasHeaderSlot = computed<boolean>(() => {
|
||||
return !!slots.header
|
||||
})
|
||||
const hasFooterSlot = computed<boolean>(() => {
|
||||
return !!slots.footer
|
||||
})
|
||||
</script>
|
||||
77
resources/scripts/components/base/BaseCheckbox.vue
Normal file
77
resources/scripts/components/base/BaseCheckbox.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<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-subtle cursor-not-allowed' : 'text-body'
|
||||
} cursor-pointer `"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<p v-if="description" class="text-muted">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
description?: string
|
||||
modelValue?: boolean | unknown[]
|
||||
id?: number | string
|
||||
disabled?: boolean
|
||||
checkboxClass?: string
|
||||
setInitialValue?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: '',
|
||||
description: '',
|
||||
modelValue: false,
|
||||
id: () => `check_${Math.random().toString(36).substr(2, 9)}`,
|
||||
disabled: false,
|
||||
checkboxClass: 'w-4 h-4 border-line-strong rounded cursor-pointer',
|
||||
setInitialValue: false,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean | unknown[]): void
|
||||
(e: 'change', value: boolean | unknown[]): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
if (props.setInitialValue) {
|
||||
emit('update:modelValue', props.modelValue)
|
||||
}
|
||||
|
||||
const checked = computed<boolean | unknown[]>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean | unknown[]) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
},
|
||||
})
|
||||
|
||||
const disabledClass = computed<string>(() => {
|
||||
if (props.disabled) {
|
||||
return 'text-subtle cursor-not-allowed'
|
||||
}
|
||||
|
||||
return 'text-primary-600 focus:ring-primary-500'
|
||||
})
|
||||
</script>
|
||||
278
resources/scripts/components/base/BaseCustomInput.vue
Normal file
278
resources/scripts/components/base/BaseCustomInput.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface CustomFieldOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface CustomFieldData {
|
||||
label: string
|
||||
value: string
|
||||
slug: string
|
||||
model_type: string
|
||||
}
|
||||
|
||||
interface FieldGroup {
|
||||
label: string
|
||||
fields: CustomFieldOption[]
|
||||
}
|
||||
|
||||
type FieldType =
|
||||
| 'shipping'
|
||||
| 'billing'
|
||||
| 'customer'
|
||||
| 'invoice'
|
||||
| 'estimate'
|
||||
| 'payment'
|
||||
| 'company'
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
modelValue?: string
|
||||
fields?: FieldType[] | null
|
||||
customFields?: CustomFieldData[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
modelValue: '',
|
||||
fields: null,
|
||||
customFields: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fieldList = ref<FieldGroup[]>([])
|
||||
const invoiceFields = ref<CustomFieldData[]>([])
|
||||
const estimateFields = ref<CustomFieldData[]>([])
|
||||
const paymentFields = ref<CustomFieldData[]>([])
|
||||
const customerFields = ref<CustomFieldData[]>([])
|
||||
|
||||
watch(
|
||||
() => props.fields,
|
||||
() => {
|
||||
if (props.fields && props.fields.length > 0) {
|
||||
getFields()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.customFields,
|
||||
(newValue: CustomFieldData[] | undefined) => {
|
||||
const data = newValue ?? []
|
||||
invoiceFields.value = data.filter((field) => field.model_type === 'Invoice')
|
||||
customerFields.value = data.filter(
|
||||
(field) => field.model_type === 'Customer'
|
||||
)
|
||||
paymentFields.value = data.filter(
|
||||
(field) => field.model_type === 'Payment'
|
||||
)
|
||||
estimateFields.value = data.filter(
|
||||
(field) => field.model_type === 'Estimate'
|
||||
)
|
||||
getFields()
|
||||
}
|
||||
)
|
||||
|
||||
const value = computed<string>({
|
||||
get: () => props.modelValue,
|
||||
set: (val: string) => {
|
||||
emit('update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
function getFields(): void {
|
||||
fieldList.value = []
|
||||
if (!props.fields || props.fields.length === 0) return
|
||||
|
||||
if (props.fields.includes('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.includes('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.includes('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' },
|
||||
{ label: 'Tax ID', value: 'CONTACT_TAX_ID' },
|
||||
...customerFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.includes('invoice')) {
|
||||
fieldList.value.push({
|
||||
label: 'Invoice',
|
||||
fields: [
|
||||
{ label: 'Date', value: 'INVOICE_DATE' },
|
||||
{ label: 'Due Date', value: 'INVOICE_DUE_DATE' },
|
||||
{ label: 'Number', value: 'INVOICE_NUMBER' },
|
||||
{ label: 'Ref Number', value: 'INVOICE_REF_NUMBER' },
|
||||
...invoiceFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.includes('estimate')) {
|
||||
fieldList.value.push({
|
||||
label: 'Estimate',
|
||||
fields: [
|
||||
{ label: 'Date', value: 'ESTIMATE_DATE' },
|
||||
{ label: 'Expiry Date', value: 'ESTIMATE_EXPIRY_DATE' },
|
||||
{ label: 'Number', value: 'ESTIMATE_NUMBER' },
|
||||
{ label: 'Ref Number', value: 'ESTIMATE_REF_NUMBER' },
|
||||
...estimateFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.includes('payment')) {
|
||||
fieldList.value.push({
|
||||
label: 'Payment',
|
||||
fields: [
|
||||
{ label: 'Date', value: 'PAYMENT_DATE' },
|
||||
{ label: 'Number', value: 'PAYMENT_NUMBER' },
|
||||
{ label: 'Mode', value: 'PAYMENT_MODE' },
|
||||
{ label: 'Amount', value: 'PAYMENT_AMOUNT' },
|
||||
...paymentFields.value.map((i) => ({
|
||||
label: i.label,
|
||||
value: i.slug,
|
||||
})),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (props.fields.includes('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' },
|
||||
{ label: 'Vat Id', value: 'COMPANY_VAT' },
|
||||
{ label: 'Tax Id', value: 'COMPANY_TAX' },
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
getFields()
|
||||
</script>
|
||||
|
||||
<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-auto min-w-[40rem]"
|
||||
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-muted 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-hover-strong
|
||||
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-subtle"
|
||||
/>
|
||||
|
||||
{{ field.label }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</BaseDropdown>
|
||||
</div>
|
||||
<BaseEditor v-model="value" />
|
||||
</div>
|
||||
</template>
|
||||
16
resources/scripts/components/base/BaseCustomTag.vue
Normal file
16
resources/scripts/components/base/BaseCustomTag.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { h, defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseCustomTag',
|
||||
props: {
|
||||
tag: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
return () => h(props.tag, attrs, slots)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { Address, Country } from '@/scripts/types/domain'
|
||||
|
||||
interface DisplayAddress {
|
||||
address_street_1?: string | null
|
||||
address_street_2?: string | null
|
||||
city?: string | null
|
||||
state?: string | null
|
||||
zip?: string | null
|
||||
country?: Pick<Country, 'name'> | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
address: DisplayAddress
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="address"
|
||||
class="text-sm font-bold leading-5 text-heading 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>
|
||||
104
resources/scripts/components/base/BaseCustomerSelectInput.vue
Normal file
104
resources/scripts/components/base/BaseCustomerSelectInput.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCustomerStore } from '../../features/company/customers/store'
|
||||
import { useModalStore } from '../../stores/modal.store'
|
||||
import { useUserStore } from '../../stores/user.store'
|
||||
import { ABILITIES } from '../../config/abilities'
|
||||
import CustomerModal from '../../features/company/customers/components/CustomerModal.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | number | Record<string, unknown> | null
|
||||
fetchAll?: boolean
|
||||
showAction?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
fetchAll: false,
|
||||
showAction: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number | Record<string, unknown> | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modalStore = useModalStore()
|
||||
const customerStore = useCustomerStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const selectedCustomer = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value as string | number | Record<string, unknown> | null)
|
||||
},
|
||||
})
|
||||
|
||||
async function searchCustomers(search: string) {
|
||||
const data: Record<string, unknown> = { search }
|
||||
|
||||
if (props.fetchAll) {
|
||||
data.limit = 'all'
|
||||
}
|
||||
|
||||
const response = await customerStore.fetchCustomers(data)
|
||||
const results = response.data ?? []
|
||||
|
||||
if (results.length > 0 && customerStore.editCustomer) {
|
||||
const customerFound = results.find(
|
||||
(c: Record<string, unknown>) => c.id === customerStore.editCustomer?.id
|
||||
)
|
||||
if (!customerFound) {
|
||||
const editCopy = { ...customerStore.editCustomer }
|
||||
results.unshift(editCopy as typeof results[0])
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function addCustomer(): void {
|
||||
customerStore.resetCurrentCustomer()
|
||||
|
||||
modalStore.openModal({
|
||||
title: t('customers.add_new_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseMultiselect
|
||||
v-model="selectedCustomer"
|
||||
v-bind="$attrs"
|
||||
track-by="name"
|
||||
value-prop="id"
|
||||
label="name"
|
||||
:filter-results="false"
|
||||
resolve-on-load
|
||||
:delay="500"
|
||||
:searchable="true"
|
||||
:options="searchCustomers"
|
||||
label-value="name"
|
||||
:placeholder="$t('customers.type_or_click')"
|
||||
:can-deselect="false"
|
||||
class="w-full"
|
||||
>
|
||||
<template v-if="showAction" #action>
|
||||
<BaseSelectAction
|
||||
v-if="userStore.hasAbilities(ABILITIES.CREATE_CUSTOMER)"
|
||||
@click="addCustomer"
|
||||
>
|
||||
<BaseIcon
|
||||
name="UserPlusIcon"
|
||||
class="h-4 mr-2 -ml-2 text-center text-primary-400"
|
||||
/>
|
||||
|
||||
{{ $t('customers.add_new_customer') }}
|
||||
</BaseSelectAction>
|
||||
</template>
|
||||
</BaseMultiselect>
|
||||
|
||||
<CustomerModal />
|
||||
</template>
|
||||
539
resources/scripts/components/base/BaseCustomerSelectPopup.vue
Normal file
539
resources/scripts/components/base/BaseCustomerSelectPopup.vue
Normal file
@@ -0,0 +1,539 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { ABILITIES } from '@/scripts/config/abilities'
|
||||
import { useCustomerStore } from '@/scripts/features/company/customers/store'
|
||||
import { useInvoiceStore } from '@/scripts/features/company/invoices/store'
|
||||
import { useEstimateStore } from '@/scripts/features/company/estimates/store'
|
||||
import { useRecurringInvoiceStore } from '@/scripts/features/company/recurring-invoices/store'
|
||||
import CustomerModal from '@/scripts/features/company/customers/components/CustomerModal.vue'
|
||||
|
||||
type DocumentType = 'estimate' | 'invoice' | 'recurring-invoice'
|
||||
|
||||
interface ValidationError {
|
||||
$message: string
|
||||
}
|
||||
|
||||
interface Validation {
|
||||
$error: boolean
|
||||
$errors: ValidationError[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
valid?: Validation
|
||||
customerId?: number | null
|
||||
type?: DocumentType | null
|
||||
contentLoading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
valid: () => ({ $error: false, $errors: [] }),
|
||||
customerId: null,
|
||||
type: null,
|
||||
contentLoading: false,
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const customerStore = useCustomerStore()
|
||||
const invoiceStore = useInvoiceStore()
|
||||
const estimateStore = useEstimateStore()
|
||||
const recurringInvoiceStore = useRecurringInvoiceStore()
|
||||
|
||||
const search = ref<string | null>(null)
|
||||
const isSearchingCustomer = ref<boolean>(false)
|
||||
|
||||
const selectedCustomer = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'invoice':
|
||||
return invoiceStore.newInvoice.customer
|
||||
case 'estimate':
|
||||
return estimateStore.newEstimate.customer
|
||||
case 'recurring-invoice':
|
||||
return recurringInvoiceStore.newRecurringInvoice.customer
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch initial customers on setup
|
||||
async function fetchInitialCustomers(): Promise<void> {
|
||||
await customerStore.fetchCustomers({
|
||||
orderByField: '',
|
||||
orderBy: '',
|
||||
})
|
||||
}
|
||||
|
||||
// Select customer on setup if customerId is provided
|
||||
if (props.customerId) {
|
||||
if (props.type === 'invoice') {
|
||||
invoiceStore.selectCustomer(props.customerId)
|
||||
} else if (props.type === 'estimate') {
|
||||
estimateStore.selectCustomer(props.customerId)
|
||||
} else if (props.type === 'recurring-invoice') {
|
||||
recurringInvoiceStore.selectCustomer(props.customerId)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInitialCustomers()
|
||||
|
||||
const debounceSearchCustomer = useDebounceFn(() => {
|
||||
isSearchingCustomer.value = true
|
||||
searchCustomer()
|
||||
}, 500)
|
||||
|
||||
async function searchCustomer(): Promise<void> {
|
||||
await customerStore.fetchCustomers({
|
||||
display_name: search.value ?? '',
|
||||
page: 1,
|
||||
})
|
||||
isSearchingCustomer.value = false
|
||||
}
|
||||
|
||||
function selectNewCustomer(id: number, close: () => void): void {
|
||||
const params: Record<string, unknown> = { userId: id }
|
||||
if (route.params.id) params.model_id = route.params.id
|
||||
|
||||
if (props.type === 'invoice') {
|
||||
invoiceStore.getNextNumber(params, true)
|
||||
invoiceStore.selectCustomer(id)
|
||||
} else if (props.type === 'estimate') {
|
||||
estimateStore.getNextNumber(params, true)
|
||||
estimateStore.selectCustomer(id)
|
||||
} else if (props.type === 'recurring-invoice') {
|
||||
recurringInvoiceStore.selectCustomer(id)
|
||||
}
|
||||
|
||||
close()
|
||||
search.value = null
|
||||
}
|
||||
|
||||
function resetSelectedCustomer(): void {
|
||||
if (props.type === 'invoice') {
|
||||
invoiceStore.resetSelectedCustomer()
|
||||
} else if (props.type === 'estimate') {
|
||||
estimateStore.resetSelectedCustomer()
|
||||
} else if (props.type === 'recurring-invoice') {
|
||||
recurringInvoiceStore.resetSelectedCustomer()
|
||||
}
|
||||
}
|
||||
|
||||
async function editCustomer(): Promise<void> {
|
||||
if (!selectedCustomer.value) return
|
||||
await customerStore.fetchCustomer(selectedCustomer.value.id)
|
||||
modalStore.openModal({
|
||||
title: t('customers.edit_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
})
|
||||
}
|
||||
|
||||
function openCustomerModal(): void {
|
||||
customerStore.resetCurrentCustomer()
|
||||
modalStore.openModal({
|
||||
title: t('customers.add_customer'),
|
||||
componentName: 'CustomerModal',
|
||||
variant: 'md',
|
||||
})
|
||||
}
|
||||
|
||||
function initGenerator(name: string): string {
|
||||
if (name) {
|
||||
const nameSplit = name.split(' ')
|
||||
return nameSplit[0].charAt(0).toUpperCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<CustomerModal />
|
||||
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
style="min-height: 170px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
|
||||
<div v-else class="max-h-[173px]">
|
||||
<div
|
||||
v-if="selectedCustomer"
|
||||
class="
|
||||
flex flex-col
|
||||
p-4
|
||||
bg-surface
|
||||
border border-line-light border-solid
|
||||
min-h-[170px]
|
||||
rounded-xl
|
||||
shadow
|
||||
"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex relative justify-between mb-2">
|
||||
<BaseText
|
||||
:text="selectedCustomer.name"
|
||||
class="flex-1 text-base font-medium text-left text-heading"
|
||||
/>
|
||||
<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-muted 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-muted 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-subtle
|
||||
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-subtle
|
||||
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-xl">
|
||||
<PopoverButton
|
||||
:class="{
|
||||
'': 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-hidden rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
relative
|
||||
flex
|
||||
justify-center
|
||||
px-0
|
||||
p-0
|
||||
py-16
|
||||
bg-surface
|
||||
border border-line-light border-solid
|
||||
rounded-xl
|
||||
shadow
|
||||
min-h-[170px]
|
||||
"
|
||||
>
|
||||
<BaseIcon
|
||||
name="UserIcon"
|
||||
class="
|
||||
flex
|
||||
justify-center
|
||||
!w-10
|
||||
!h-10
|
||||
p-2
|
||||
mr-5
|
||||
text-sm text-white
|
||||
bg-surface-muted
|
||||
rounded-full
|
||||
font-base
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="mt-1">
|
||||
<label class="text-lg font-medium text-heading">
|
||||
{{ $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-xl
|
||||
shadow
|
||||
ring-1 ring-black/5
|
||||
bg-surface
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<BaseInput
|
||||
v-model="search"
|
||||
container-class="m-4"
|
||||
:placeholder="$t('general.search')"
|
||||
type="text"
|
||||
icon="search"
|
||||
@update:modelValue="() => debounceSearchCustomer()"
|
||||
/>
|
||||
|
||||
<ul
|
||||
class="
|
||||
max-h-80
|
||||
flex flex-col
|
||||
overflow-auto
|
||||
list
|
||||
border-t border-line-light
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="(customer, index) in customerStore.customers"
|
||||
:key="index"
|
||||
href="#"
|
||||
class="
|
||||
flex
|
||||
px-6
|
||||
py-2
|
||||
border-b border-line-light border-solid
|
||||
cursor-pointer
|
||||
hover:cursor-pointer hover:bg-hover-strong
|
||||
focus:outline-hidden focus:bg-surface-tertiary
|
||||
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-surface-muted
|
||||
rounded-full
|
||||
avatar
|
||||
"
|
||||
>
|
||||
{{ initGenerator(customer.name) }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col justify-center text-left">
|
||||
<BaseText
|
||||
v-if="customer.name"
|
||||
:text="customer.name"
|
||||
class="
|
||||
m-0
|
||||
text-base
|
||||
font-normal
|
||||
leading-tight
|
||||
cursor-pointer
|
||||
"
|
||||
/>
|
||||
<BaseText
|
||||
v-if="customer.contact_name"
|
||||
:text="customer.contact_name"
|
||||
class="
|
||||
m-0
|
||||
text-sm
|
||||
font-medium
|
||||
text-subtle
|
||||
cursor-pointer
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="customerStore.customers.length === 0"
|
||||
class="flex justify-center p-5 text-subtle"
|
||||
>
|
||||
<label class="text-base text-muted 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-surface-muted
|
||||
border-none
|
||||
outline-hidden
|
||||
focus:bg-surface-muted
|
||||
"
|
||||
@click="openCustomerModal"
|
||||
>
|
||||
<BaseIcon name="UserPlusIcon" 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>
|
||||
</div>
|
||||
</template>
|
||||
260
resources/scripts/components/base/BaseDatePicker.vue
Normal file
260
resources/scripts/components/base/BaseDatePicker.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<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-subtle
|
||||
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 setup lang="ts">
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { Arabic } from 'flatpickr/dist/l10n/ar.js'
|
||||
import { Czech } from 'flatpickr/dist/l10n/cs.js'
|
||||
import { German } from 'flatpickr/dist/l10n/de.js'
|
||||
import { Greek } from 'flatpickr/dist/l10n/gr.js'
|
||||
import { english } from 'flatpickr/dist/l10n/default.js'
|
||||
import { Spanish } from 'flatpickr/dist/l10n/es.js'
|
||||
import { Persian } from 'flatpickr/dist/l10n/fa.js'
|
||||
import { Finnish } from 'flatpickr/dist/l10n/fi.js'
|
||||
import { French } from 'flatpickr/dist/l10n/fr.js'
|
||||
import { Hindi } from 'flatpickr/dist/l10n/hi.js'
|
||||
import { Croatian } from 'flatpickr/dist/l10n/hr.js'
|
||||
import { Indonesian } from 'flatpickr/dist/l10n/id.js'
|
||||
import { Italian } from 'flatpickr/dist/l10n/it.js'
|
||||
import { Japanese } from 'flatpickr/dist/l10n/ja.js'
|
||||
import { Korean } from 'flatpickr/dist/l10n/ko.js'
|
||||
import { Lithuanian } from 'flatpickr/dist/l10n/lt.js'
|
||||
import { Latvian } from 'flatpickr/dist/l10n/lv.js'
|
||||
import { Dutch } from 'flatpickr/dist/l10n/nl.js'
|
||||
import { Polish } from 'flatpickr/dist/l10n/pl.js'
|
||||
import { Portuguese } from 'flatpickr/dist/l10n/pt.js'
|
||||
import { Romanian } from 'flatpickr/dist/l10n/ro.js'
|
||||
import { Russian } from 'flatpickr/dist/l10n/ru.js'
|
||||
import { Slovak } from 'flatpickr/dist/l10n/sk.js'
|
||||
import { Slovenian } from 'flatpickr/dist/l10n/sl.js'
|
||||
import { Serbian } from 'flatpickr/dist/l10n/sr.js'
|
||||
import { Swedish } from 'flatpickr/dist/l10n/sv.js'
|
||||
import { Thai } from 'flatpickr/dist/l10n/th.js'
|
||||
import { Turkish } from 'flatpickr/dist/l10n/tr.js'
|
||||
import { Vietnamese } from 'flatpickr/dist/l10n/vn.js'
|
||||
import { Mandarin } from 'flatpickr/dist/l10n/zh.js'
|
||||
import type { CustomLocale, Locale } from 'flatpickr/dist/types/locale'
|
||||
import { computed, reactive, watch, ref, useSlots } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
|
||||
interface FlatPickrInstance {
|
||||
fp: { open: () => void }
|
||||
}
|
||||
|
||||
const dp = ref<FlatPickrInstance | null>(null)
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | Date
|
||||
contentLoading?: boolean
|
||||
placeholder?: string | null
|
||||
invalid?: boolean
|
||||
enableTime?: boolean
|
||||
disabled?: boolean
|
||||
showCalendarIcon?: boolean
|
||||
containerClass?: string
|
||||
defaultInputClass?: string
|
||||
time24hr?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => new Date(),
|
||||
contentLoading: false,
|
||||
placeholder: null,
|
||||
invalid: false,
|
||||
enableTime: false,
|
||||
disabled: false,
|
||||
showCalendarIcon: true,
|
||||
containerClass: '',
|
||||
defaultInputClass:
|
||||
'font-base pl-8 py-2 outline-hidden focus:ring-primary-400 focus:outline-hidden focus:border-primary-400 block w-full sm:text-sm border-line-default rounded-md text-heading',
|
||||
time24hr: false,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | Date): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Localize Flatpicker
|
||||
const lang: string = userStore.currentUserSettings.language
|
||||
|
||||
const localeMap: Record<string, CustomLocale | Locale> = {
|
||||
ar: Arabic,
|
||||
cs: Czech,
|
||||
de: German,
|
||||
el: Greek,
|
||||
en: english,
|
||||
es: Spanish,
|
||||
fa: Persian,
|
||||
fi: Finnish,
|
||||
fr: French,
|
||||
hi: Hindi,
|
||||
hr: Croatian,
|
||||
id: Indonesian,
|
||||
it: Italian,
|
||||
ja: Japanese,
|
||||
ko: Korean,
|
||||
lt: Lithuanian,
|
||||
lv: Latvian,
|
||||
nl: Dutch,
|
||||
pl: Polish,
|
||||
pt: Portuguese,
|
||||
pt_BR: Portuguese,
|
||||
ro: Romanian,
|
||||
ru: Russian,
|
||||
sk: Slovak,
|
||||
sl: Slovenian,
|
||||
sr: Serbian,
|
||||
sv: Swedish,
|
||||
th: Thai,
|
||||
tr: Turkish,
|
||||
vi: Vietnamese,
|
||||
zh: Mandarin,
|
||||
}
|
||||
|
||||
const fpLocale = localeMap[lang] ?? english
|
||||
|
||||
interface FlatPickrConfig {
|
||||
altInput: boolean
|
||||
enableTime: boolean
|
||||
time_24hr: boolean
|
||||
locale: CustomLocale | Locale
|
||||
altFormat?: string
|
||||
}
|
||||
|
||||
const config = reactive<FlatPickrConfig>({
|
||||
altInput: true,
|
||||
enableTime: props.enableTime,
|
||||
time_24hr: props.time24hr,
|
||||
locale: fpLocale,
|
||||
})
|
||||
|
||||
const date = computed<string | Date>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string | Date) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const carbonFormat = computed<string | undefined>(() => {
|
||||
return companyStore.selectedCompanySettings?.carbon_date_format
|
||||
})
|
||||
|
||||
const carbonFormatWithTime = computed<string>(() => {
|
||||
let format: string =
|
||||
companyStore.selectedCompanySettings?.carbon_date_format ?? ''
|
||||
if (companyStore.selectedCompanySettings?.invoice_use_time === 'YES') {
|
||||
format +=
|
||||
' ' + (companyStore.selectedCompanySettings?.carbon_time_format ?? '')
|
||||
}
|
||||
return format.replace('g', 'h').replace('a', 'K')
|
||||
})
|
||||
|
||||
const hasIconSlot = computed<boolean>(() => {
|
||||
return !!slots.icon
|
||||
})
|
||||
|
||||
const computedContainerClass = computed<string>(() => {
|
||||
const containerClass = `${props.containerClass} `
|
||||
|
||||
return containerClass
|
||||
})
|
||||
|
||||
const inputInvalidClass = computed<string>(() => {
|
||||
if (props.invalid) {
|
||||
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputDisabledClass = computed<string>(() => {
|
||||
if (props.disabled) {
|
||||
return 'border border-solid rounded-md outline-hidden input-field box-border-2 base-date-picker-input placeholder-gray-400 bg-surface-muted text-body border-line-default'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
function onClickDp(): void {
|
||||
dp.value?.fp.open()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.enableTime,
|
||||
() => {
|
||||
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
|
||||
? `${carbonFormatWithTime.value}`
|
||||
: '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,33 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
value?: string | number
|
||||
contentLoading?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
value: '',
|
||||
contentLoading: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<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-heading non-italic">
|
||||
{{ value }}
|
||||
|
||||
<slot />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
178
resources/scripts/components/base/BaseDialog.vue
Normal file
178
resources/scripts/components/base/BaseDialog.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="dialogStore.active">
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
class="fixed inset-0 z-20 overflow-y-auto"
|
||||
:open="dialogStore.active"
|
||||
@close="dialogStore.closeDialog"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-end
|
||||
justify-center
|
||||
min-h-screen
|
||||
px-4
|
||||
pt-4
|
||||
pb-20
|
||||
text-center
|
||||
sm:block sm:p-0
|
||||
"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 transition-opacity bg-black/50"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>​</span
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
inline-block
|
||||
px-4
|
||||
pt-5
|
||||
pb-4
|
||||
overflow-hidden
|
||||
text-left
|
||||
align-bottom
|
||||
transition-all
|
||||
bg-surface/95 backdrop-blur-xl backdrop-saturate-150
|
||||
rounded-xl border border-line-default
|
||||
shadow-2xl
|
||||
sm:my-8 sm:align-middle sm:w-full sm:p-6
|
||||
relative
|
||||
"
|
||||
:class="dialogSizeClasses"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-12
|
||||
h-12
|
||||
mx-auto
|
||||
bg-alert-success-bg
|
||||
rounded-full
|
||||
"
|
||||
:class="{
|
||||
'bg-alert-success-bg': dialogStore.variant === 'primary',
|
||||
'bg-alert-error-bg': dialogStore.variant === 'danger',
|
||||
}"
|
||||
>
|
||||
<BaseIcon
|
||||
v-if="dialogStore.variant === 'primary'"
|
||||
name="CheckCircleIcon"
|
||||
class="w-6 h-6 text-alert-success-text"
|
||||
/>
|
||||
<BaseIcon
|
||||
v-else
|
||||
name="ExclamationTriangleIcon"
|
||||
class="w-6 h-6 text-alert-error-text"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
class="text-lg font-medium leading-6 text-heading"
|
||||
>
|
||||
{{ dialogStore.title }}
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-muted">
|
||||
{{ dialogStore.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 sm:mt-6 grid gap-3"
|
||||
:class="{
|
||||
'sm:grid-cols-2 sm:grid-flow-row-dense':
|
||||
!dialogStore.hideNoButton,
|
||||
}"
|
||||
>
|
||||
<base-button
|
||||
class="justify-center"
|
||||
:variant="dialogStore.variant"
|
||||
:class="{ 'w-full': dialogStore.hideNoButton }"
|
||||
@click="resolveDialog(true)"
|
||||
>
|
||||
{{ dialogStore.yesLabel }}
|
||||
</base-button>
|
||||
|
||||
<base-button
|
||||
v-if="!dialogStore.hideNoButton"
|
||||
class="justify-center"
|
||||
variant="white"
|
||||
@click="resolveDialog(false)"
|
||||
>
|
||||
{{ dialogStore.noLabel }}
|
||||
</base-button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog.store'
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function resolveDialog(resValue: boolean): void {
|
||||
dialogStore.resolve(resValue)
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
|
||||
const dialogSizeClasses = computed<string>(() => {
|
||||
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>
|
||||
6
resources/scripts/components/base/BaseDivider.vue
Normal file
6
resources/scripts/components/base/BaseDivider.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<hr class="w-full border-line-light" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
87
resources/scripts/components/base/BaseDropdown.vue
Normal file
87
resources/scripts/components/base/BaseDropdown.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<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>
|
||||
<span ref="trigger" class="inline-flex">
|
||||
<MenuButton class="focus:outline-hidden" @click="onClick">
|
||||
<slot name="activator" />
|
||||
</MenuButton>
|
||||
</span>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="container"
|
||||
class="fixed top-0 left-0 z-10"
|
||||
:class="[widthClass, !contentLoading ? 'pointer-events-none' : '']"
|
||||
>
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="scale-95 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-95 opacity-0"
|
||||
>
|
||||
<MenuItems :class="containerClasses">
|
||||
<div class="py-1">
|
||||
<slot />
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/vue'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { usePopper } from '@/scripts/composables/use-popper'
|
||||
import type { Placement } from '@popperjs/core'
|
||||
|
||||
interface Props {
|
||||
containerClass?: string
|
||||
widthClass?: string
|
||||
positionClass?: string
|
||||
position?: Placement
|
||||
wrapperClass?: string
|
||||
contentLoading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
containerClass: '',
|
||||
widthClass: 'w-56',
|
||||
positionClass: 'absolute z-10 right-0',
|
||||
position: 'bottom-end',
|
||||
wrapperClass: 'inline-block h-full text-left',
|
||||
contentLoading: false,
|
||||
})
|
||||
|
||||
const containerClasses = computed<string>(() => {
|
||||
const baseClass = `origin-top-right rounded-xl shadow-xl bg-surface/80 backdrop-blur-xl border border-white/15 divide-y divide-line-light focus:outline-hidden`
|
||||
return `${baseClass} pointer-events-auto ${props.containerClass}`
|
||||
})
|
||||
|
||||
const [trigger, container, popper] = usePopper({
|
||||
placement: props.position,
|
||||
strategy: 'fixed',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
|
||||
})
|
||||
|
||||
async function onClick(): Promise<void> {
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
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-hover-strong text-heading' : 'text-body',
|
||||
'group flex items-center px-4 py-2 text-sm font-normal whitespace-normal',
|
||||
]"
|
||||
>
|
||||
<slot :active="active" />
|
||||
</a>
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MenuItem } from '@headlessui/vue'
|
||||
</script>
|
||||
35
resources/scripts/components/base/BaseErrorAlert.vue
Normal file
35
resources/scripts/components/base/BaseErrorAlert.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from '@heroicons/vue/24/solid'
|
||||
|
||||
interface Props {
|
||||
errorTitle?: string
|
||||
errors?: string[] | null
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
errorTitle: 'There were some errors with your submission',
|
||||
errors: null,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md bg-alert-error-bg p-4">
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-alert-error-text" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-alert-error-text">
|
||||
{{ errorTitle }}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-alert-error-text">
|
||||
<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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { EstimateStatus } from '@/scripts/types/domain'
|
||||
|
||||
interface Props {
|
||||
status?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const labelStatus = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case EstimateStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return t('estimates.draft')
|
||||
case EstimateStatus.SENT:
|
||||
case 'SENT':
|
||||
return t('estimates.sent')
|
||||
case EstimateStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return t('estimates.viewed')
|
||||
case EstimateStatus.EXPIRED:
|
||||
case 'EXPIRED':
|
||||
return t('estimates.expired')
|
||||
case EstimateStatus.ACCEPTED:
|
||||
case 'ACCEPTED':
|
||||
return t('estimates.accepted')
|
||||
case EstimateStatus.REJECTED:
|
||||
case 'REJECTED':
|
||||
return t('estimates.rejected')
|
||||
default:
|
||||
return props.status
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{ labelStatus }}
|
||||
</template>
|
||||
582
resources/scripts/components/base/BaseFileUploader.vue
Normal file
582
resources/scripts/components/base/BaseFileUploader.vue
Normal file
@@ -0,0 +1,582 @@
|
||||
<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-line-strong
|
||||
transition-all
|
||||
duration-300
|
||||
ease-in-out
|
||||
isolate
|
||||
hover:border-line-strong
|
||||
group
|
||||
min-h-[100px]
|
||||
bg-surface-secondary
|
||||
"
|
||||
:class="avatar ? 'w-32 h-32' : 'w-full'"
|
||||
>
|
||||
<input
|
||||
id="file-upload"
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
tabindex="-1"
|
||||
:multiple="multiple"
|
||||
:name="inputFieldName"
|
||||
:accept="accept"
|
||||
class="absolute z-10 w-full h-full opacity-0 cursor-pointer"
|
||||
@click="($event.target as HTMLInputElement).value = ''"
|
||||
@change="
|
||||
onChange(
|
||||
($event.target as HTMLInputElement).name,
|
||||
($event.target as HTMLInputElement).files!,
|
||||
($event.target as HTMLInputElement).files!.length
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Avatar Not Selected -->
|
||||
<div v-if="!localFiles.length && avatar" class="">
|
||||
<img :src="getDefaultAvatar()" class="rounded" alt="Default Avatar" />
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="absolute z-30 bg-surface 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="CloudArrowUpIcon"
|
||||
class="h-6 mb-2 text-xl leading-6 text-subtle"
|
||||
/>
|
||||
<p class="text-xs leading-4 text-center text-subtle">
|
||||
{{ $t('general.file_upload.drag_a_file') }}
|
||||
<a
|
||||
class="
|
||||
cursor-pointer
|
||||
text-primary-500
|
||||
hover:text-primary-600 hover:font-medium
|
||||
relative
|
||||
z-20
|
||||
"
|
||||
href="#"
|
||||
@click.prevent.stop="onBrowse"
|
||||
>
|
||||
{{ $t('general.file_upload.browse') }}
|
||||
</a>
|
||||
{{ $t('general.file_upload.to_choose') }}
|
||||
</p>
|
||||
<p class="text-xs leading-4 text-center text-subtle mt-2">
|
||||
{{ recommendedText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="localFiles.length && avatar && !multiple"
|
||||
class="flex w-full h-full border border-line-default rounded justify-center items-center"
|
||||
>
|
||||
<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-subtle
|
||||
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-body
|
||||
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-surface
|
||||
border border-line-default
|
||||
rounded-full
|
||||
shadow-md
|
||||
-bottom-3
|
||||
-right-3
|
||||
group
|
||||
hover:border-line-strong
|
||||
"
|
||||
@click.prevent.stop="onAvatarRemove(localFiles[0])"
|
||||
>
|
||||
<BaseIcon name="XMarkIcon" class="h-4 text-xl leading-6 text-heading" />
|
||||
</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="index"
|
||||
href="#"
|
||||
class="
|
||||
block
|
||||
p-2
|
||||
m-2
|
||||
bg-surface
|
||||
border border-line-default
|
||||
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-subtle
|
||||
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-body
|
||||
font-medium
|
||||
text-sm
|
||||
truncate
|
||||
overflow-hidden
|
||||
w-full
|
||||
"
|
||||
>
|
||||
{{ localFile.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="
|
||||
cursor-pointer
|
||||
box-border
|
||||
absolute
|
||||
z-30
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-8
|
||||
h-8
|
||||
bg-surface
|
||||
border border-line-default
|
||||
rounded-full
|
||||
shadow-md
|
||||
-bottom-3
|
||||
-right-3
|
||||
group
|
||||
hover:border-line-strong
|
||||
"
|
||||
@click.prevent.stop="onFileRemove(index)"
|
||||
>
|
||||
<BaseIcon name="XMarkIcon" class="h-4 text-xl leading-6 text-heading" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex w-full items-center justify-center">
|
||||
<a
|
||||
v-for="(localFile, index) in localFiles"
|
||||
:key="index"
|
||||
href="#"
|
||||
class="
|
||||
block
|
||||
p-2
|
||||
m-2
|
||||
bg-surface
|
||||
border border-line-default
|
||||
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-subtle
|
||||
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-body
|
||||
font-medium
|
||||
text-sm
|
||||
truncate
|
||||
overflow-hidden
|
||||
w-full
|
||||
"
|
||||
>
|
||||
{{ localFile.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="
|
||||
cursor-pointer
|
||||
box-border
|
||||
absolute
|
||||
z-30
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-8
|
||||
h-8
|
||||
bg-surface
|
||||
border border-line-default
|
||||
rounded-full
|
||||
shadow-md
|
||||
-bottom-3
|
||||
-right-3
|
||||
group
|
||||
hover:border-line-strong
|
||||
"
|
||||
@click.prevent.stop="onFileRemove(index)"
|
||||
>
|
||||
<BaseIcon name="XMarkIcon" class="h-4 text-xl leading-6 text-heading" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { client as http } from '@/scripts/api/client'
|
||||
import * as utils from '@/scripts/utils/format-money'
|
||||
|
||||
interface LocalFile {
|
||||
fileObject?: File
|
||||
type: string
|
||||
name: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
id: string | number
|
||||
url: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
multiple?: boolean
|
||||
avatar?: boolean
|
||||
autoProcess?: boolean
|
||||
uploadUrl?: string
|
||||
preserveLocalFiles?: boolean
|
||||
accept?: string
|
||||
inputFieldName?: string
|
||||
base64?: boolean
|
||||
modelValue?: LocalFile[]
|
||||
recommendedText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
avatar: false,
|
||||
autoProcess: false,
|
||||
uploadUrl: '',
|
||||
preserveLocalFiles: false,
|
||||
accept: 'image/*',
|
||||
inputFieldName: 'photos',
|
||||
base64: false,
|
||||
modelValue: () => [],
|
||||
recommendedText: '',
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'change', fieldName: string, file: FileList | File | string, fileCount: number, rawFile?: File): void
|
||||
(e: 'remove', value: LocalFile | number): void
|
||||
(e: 'update:modelValue', value: LocalFile[]): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// status
|
||||
const STATUS_INITIAL = 0
|
||||
const STATUS_SAVING = 1
|
||||
const STATUS_SUCCESS = 2
|
||||
const STATUS_FAILED = 3
|
||||
|
||||
const uploadedFiles = ref<UploadedFile[]>([])
|
||||
const localFiles = ref<LocalFile[]>([])
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const uploadError = ref<unknown>(null)
|
||||
const currentStatus = ref<number | null>(null)
|
||||
|
||||
function reset(): void {
|
||||
// reset form to initial state
|
||||
currentStatus.value = STATUS_INITIAL
|
||||
|
||||
uploadedFiles.value = []
|
||||
|
||||
if (props.modelValue && props.modelValue.length) {
|
||||
localFiles.value = [...props.modelValue]
|
||||
} else {
|
||||
localFiles.value = []
|
||||
}
|
||||
|
||||
uploadError.value = null
|
||||
}
|
||||
|
||||
function upload(formData: FormData): Promise<UploadedFile[]> {
|
||||
return (
|
||||
http
|
||||
.post(props.uploadUrl, formData)
|
||||
// get data
|
||||
.then((x: { data: Record<string, unknown>[] }) => x.data)
|
||||
// add url field
|
||||
.then((x) =>
|
||||
x.map((img) => ({ ...img, url: `/images/${img.id}` }) as UploadedFile)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// upload data to the server
|
||||
function save(formData: FormData): void {
|
||||
currentStatus.value = STATUS_SAVING
|
||||
|
||||
upload(formData)
|
||||
.then((x) => {
|
||||
uploadedFiles.value = ([] as UploadedFile[]).concat(x)
|
||||
currentStatus.value = STATUS_SUCCESS
|
||||
})
|
||||
.catch((err: { response: unknown }) => {
|
||||
uploadError.value = err.response
|
||||
currentStatus.value = STATUS_FAILED
|
||||
})
|
||||
}
|
||||
|
||||
function getBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = (error) => reject(error)
|
||||
})
|
||||
}
|
||||
|
||||
function onChange(fieldName: string, fileList: FileList, fileCount: number): void {
|
||||
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(): void {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function onAvatarRemove(image: LocalFile): void {
|
||||
localFiles.value = []
|
||||
emit('remove', image)
|
||||
}
|
||||
|
||||
function onFileRemove(index: number): void {
|
||||
localFiles.value.splice(index, 1)
|
||||
emit('remove', index)
|
||||
}
|
||||
|
||||
function getDefaultAvatar(): string {
|
||||
const imgUrl = new URL('$images/default-avatar.jpg', import.meta.url)
|
||||
return imgUrl.href
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
localFiles.value = [...v]
|
||||
}
|
||||
)
|
||||
</script>
|
||||
27
resources/scripts/components/base/BaseFormatMoney.vue
Normal file
27
resources/scripts/components/base/BaseFormatMoney.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { formatMoney } from '../../utils/format-money'
|
||||
import type { CurrencyConfig } from '../../utils/format-money'
|
||||
|
||||
interface Props {
|
||||
amount: number | string
|
||||
currency?: CurrencyConfig | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currency: null,
|
||||
})
|
||||
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
const formattedAmount = computed<string>(() => {
|
||||
const amountNum = typeof props.amount === 'string' ? Number(props.amount) : props.amount
|
||||
const currencyConfig = props.currency ?? companyStore.selectedCompanyCurrency
|
||||
return formatMoney(amountNum, currencyConfig ?? undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span style="font-family: sans-serif">{{ formattedAmount }}</span>
|
||||
</template>
|
||||
54
resources/scripts/components/base/BaseGlobalLoader.vue
Normal file
54
resources/scripts/components/base/BaseGlobalLoader.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center h-screen">
|
||||
<MainLogo
|
||||
class="w-28 h-auto text-primary-400 mb-6"
|
||||
alt="InvoiceShelf Logo"
|
||||
/>
|
||||
<div class="flex space-x-1.5">
|
||||
<span class="loader-dot" />
|
||||
<span class="loader-dot delay-150" />
|
||||
<span class="loader-dot delay-300" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
|
||||
|
||||
interface Props {
|
||||
showBgOverlay?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showBgOverlay: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.loader-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
background-color: oklch(0.585 0.233 277.117);
|
||||
animation: loader-bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loader-dot.delay-150 {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.loader-dot.delay-300 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes loader-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.4);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
resources/scripts/components/base/BaseHeading.vue
Normal file
22
resources/scripts/components/base/BaseHeading.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
type?: 'section-title' | 'heading-title'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'section-title',
|
||||
})
|
||||
|
||||
const typeClass = computed<Record<string, boolean>>(() => ({
|
||||
'text-heading text-lg font-medium': props.type === 'heading-title',
|
||||
'text-muted uppercase text-base': props.type === 'section-title',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h6 :class="typeClass">
|
||||
<slot />
|
||||
</h6>
|
||||
</template>
|
||||
21
resources/scripts/components/base/BaseIcon.vue
Normal file
21
resources/scripts/components/base/BaseIcon.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<component :is="heroIcons[name]" v-if="isLoaded" class="h-5 w-5" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import * as heroIcons from '@heroicons/vue/24/outline'
|
||||
|
||||
const isLoaded = ref<boolean>(false)
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
onMounted(() => {
|
||||
isLoaded.value = true
|
||||
})
|
||||
</script>
|
||||
82
resources/scripts/components/base/BaseInfoAlert.vue
Normal file
82
resources/scripts/components/base/BaseInfoAlert.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
lists?: string[] | null
|
||||
actions?: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'hide'): void
|
||||
(e: string): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: 'There were some errors with your submission',
|
||||
lists: null,
|
||||
actions: () => ['Dismiss'],
|
||||
})
|
||||
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-md bg-alert-warning-bg p-4 relative">
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="h-5 w-5 text-alert-warning-text absolute right-4 cursor-pointer"
|
||||
@click="$emit('hide')"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<BaseIcon
|
||||
name="ExclamationIcon"
|
||||
class="h-5 w-5 text-alert-warning-text"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-alert-warning-text">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-alert-warning-text">
|
||||
<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-alert-warning-bg
|
||||
px-2
|
||||
py-1.5
|
||||
rounded-md
|
||||
text-sm
|
||||
font-medium
|
||||
text-alert-warning-text
|
||||
hover:bg-alert-warning-bg
|
||||
focus:outline-hidden
|
||||
focus:ring-2
|
||||
focus:ring-offset-2
|
||||
focus:ring-offset-yellow-50
|
||||
focus:ring-yellow-600
|
||||
mr-3
|
||||
"
|
||||
@click="$emit(`${action}`)"
|
||||
>
|
||||
{{ action }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
268
resources/scripts/components/base/BaseInput.vue
Normal file
268
resources/scripts/components/base/BaseInput.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<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-xs 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-muted
|
||||
border border-r-0 border-line-default
|
||||
rounded-l-md
|
||||
bg-surface-secondary
|
||||
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-muted 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 lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
interface ModelModifiers {
|
||||
uppercase?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
type?: number | string
|
||||
modelValue?: string | number
|
||||
loading?: boolean
|
||||
loadingPosition?: 'left' | 'right'
|
||||
addon?: string | null
|
||||
inlineAddon?: string
|
||||
invalid?: boolean
|
||||
disabled?: boolean
|
||||
containerClass?: string
|
||||
contentLoadClass?: string
|
||||
defaultInputClass?: string
|
||||
iconLeftClass?: string
|
||||
iconRightClass?: string
|
||||
modelModifiers?: ModelModifiers
|
||||
}
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
type: 'text',
|
||||
modelValue: '',
|
||||
loading: false,
|
||||
loadingPosition: 'left',
|
||||
addon: null,
|
||||
inlineAddon: '',
|
||||
invalid: false,
|
||||
disabled: false,
|
||||
containerClass: '',
|
||||
contentLoadClass: '',
|
||||
defaultInputClass:
|
||||
'font-base block w-full sm:text-sm border-line-default rounded-md text-heading',
|
||||
iconLeftClass: 'h-5 w-5 text-subtle',
|
||||
iconRightClass: 'h-5 w-5 text-subtle',
|
||||
modelModifiers: () => ({}),
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const hasLeftIconSlot = computed<boolean>(() => {
|
||||
return !!slots.left || (props.loading && props.loadingPosition === 'left')
|
||||
})
|
||||
|
||||
const hasRightIconSlot = computed<boolean>(() => {
|
||||
return !!slots.right || (props.loading && props.loadingPosition === 'right')
|
||||
})
|
||||
|
||||
const inputPaddingClass = computed<string>(() => {
|
||||
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<string>(() => {
|
||||
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<string>(() => {
|
||||
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<string>(() => {
|
||||
if (props.disabled) {
|
||||
return `border-line-light bg-surface-tertiary !text-subtle ring-surface-muted focus:ring-surface-muted focus:border-line-light`
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const computedContainerClass = computed<string>(() => {
|
||||
let cls = `${props.containerClass} `
|
||||
|
||||
if (props.addon) {
|
||||
return `${props.containerClass} flex`
|
||||
}
|
||||
|
||||
return cls
|
||||
})
|
||||
|
||||
function emitValue(e: Event): void {
|
||||
const target = e.target as HTMLInputElement
|
||||
let val: string = target.value
|
||||
if (props.modelModifiers.uppercase) {
|
||||
val = val.toUpperCase()
|
||||
}
|
||||
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
</script>
|
||||
51
resources/scripts/components/base/BaseInvoiceStatusLabel.vue
Normal file
51
resources/scripts/components/base/BaseInvoiceStatusLabel.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { InvoiceStatus, InvoicePaidStatus } from '@/scripts/types/domain'
|
||||
|
||||
interface Props {
|
||||
status?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const labelStatus = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case InvoiceStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return t('general.draft')
|
||||
case InvoiceStatus.SENT:
|
||||
case 'SENT':
|
||||
return t('general.sent')
|
||||
case InvoiceStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return t('invoices.viewed')
|
||||
case InvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return t('invoices.completed')
|
||||
case 'DUE':
|
||||
return t('general.due')
|
||||
case 'OVERDUE':
|
||||
return t('invoices.overdue')
|
||||
case InvoicePaidStatus.UNPAID:
|
||||
case 'UNPAID':
|
||||
return t('invoices.unpaid')
|
||||
case InvoicePaidStatus.PARTIALLY_PAID:
|
||||
case 'PARTIALLY_PAID':
|
||||
return t('invoices.partially_paid')
|
||||
case InvoicePaidStatus.PAID:
|
||||
case 'PAID':
|
||||
return t('invoices.paid')
|
||||
default:
|
||||
return props.status
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{ labelStatus }}
|
||||
</template>
|
||||
183
resources/scripts/components/base/BaseItemSelect.vue
Normal file
183
resources/scripts/components/base/BaseItemSelect.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useItemStore } from '@/scripts/features/company/items/store'
|
||||
import { ABILITIES } from '@/scripts/config/abilities'
|
||||
import ItemModal from '@/scripts/features/company/items/components/ItemModal.vue'
|
||||
import type { Item } from '@/scripts/types/domain'
|
||||
import type { Tax } from '@/scripts/types/domain'
|
||||
|
||||
interface LineItem {
|
||||
item_id: number | null
|
||||
name: string
|
||||
description: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
type?: string | null
|
||||
item: LineItem
|
||||
index?: number
|
||||
invalid?: boolean
|
||||
invalidDescription?: boolean
|
||||
taxPerItem?: string
|
||||
taxes?: Tax[] | null
|
||||
store?: { deselectItem: (index: number) => void } | null
|
||||
storeProp?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'search', val: string): void
|
||||
(e: 'select', val: Item): void
|
||||
(e: 'deselect', index: number): void
|
||||
(e: 'update:description', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
type: null,
|
||||
index: 0,
|
||||
invalid: false,
|
||||
invalidDescription: false,
|
||||
taxPerItem: '',
|
||||
taxes: null,
|
||||
store: null,
|
||||
storeProp: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const modalStore = useModalStore()
|
||||
const itemStore = useItemStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const itemSelect = ref<Item | null>(null)
|
||||
const multiselectRef = ref<{ close?: () => void } | null>(null)
|
||||
const loading = ref<boolean>(false)
|
||||
const itemData = reactive<LineItem>({ ...props.item })
|
||||
|
||||
async function searchItems(search: string): Promise<Item[]> {
|
||||
const res = await itemStore.fetchItems({ search })
|
||||
return res.data as unknown as Item[]
|
||||
}
|
||||
|
||||
const description = computed<string | null>({
|
||||
get: () => props.item.description,
|
||||
set: (value: string | null) => {
|
||||
emit('update:description', value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
function openItemModal(): void {
|
||||
// Close the multiselect dropdown before opening the modal
|
||||
;(document.activeElement as HTMLElement)?.blur()
|
||||
|
||||
nextTick(() => {
|
||||
modalStore.openModal({
|
||||
title: t('items.add_item'),
|
||||
componentName: 'ItemModal',
|
||||
refreshData: (val: Item) => emit('select', val),
|
||||
data: {
|
||||
taxPerItem: props.taxPerItem,
|
||||
taxes: props.taxes,
|
||||
itemIndex: props.index,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function deselectItem(index: number): void {
|
||||
if (props.store) {
|
||||
props.store.deselectItem(index)
|
||||
}
|
||||
emit('deselect', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 text-sm">
|
||||
<ItemModal />
|
||||
|
||||
<!-- Selected Item Field -->
|
||||
<div
|
||||
v-if="item.item_id"
|
||||
class="
|
||||
relative
|
||||
flex
|
||||
items-center
|
||||
h-10
|
||||
pl-2
|
||||
bg-surface-muted
|
||||
border border-line-default border-solid
|
||||
rounded
|
||||
"
|
||||
>
|
||||
{{ item.name }}
|
||||
|
||||
<span
|
||||
class="absolute text-subtle cursor-pointer top-[8px] right-[10px]"
|
||||
@click="deselectItem(index)"
|
||||
>
|
||||
<BaseIcon name="XCircleIcon" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Select Item Field -->
|
||||
<BaseMultiselect
|
||||
v-else
|
||||
ref="multiselectRef"
|
||||
v-model="itemSelect"
|
||||
:content-loading="contentLoading"
|
||||
value-prop="id"
|
||||
track-by="name"
|
||||
:invalid="invalid"
|
||||
preserve-search
|
||||
:initial-search="itemData.name"
|
||||
label="name"
|
||||
:filter-results="false"
|
||||
resolve-on-load
|
||||
:delay="500"
|
||||
searchable
|
||||
:options="searchItems"
|
||||
object
|
||||
@update:modelValue="(val: Item) => $emit('select', val)"
|
||||
@searchChange="(val: string) => $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">
|
||||
{{ $t('validation.description_maxlength') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
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>
|
||||
8
resources/scripts/components/base/BaseList.vue
Normal file
8
resources/scripts/components/base/BaseList.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="list-none">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
39
resources/scripts/components/base/BaseListItem.vue
Normal file
39
resources/scripts/components/base/BaseListItem.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<router-link v-bind="$attrs" :class="containerClass">
|
||||
<span v-if="hasIconSlot" class="mr-3">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<span>{{ title }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
active: boolean
|
||||
index?: number | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
index: null,
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const defaultClass =
|
||||
'cursor-pointer px-3 py-2 mb-0.5 text-sm font-medium leading-5 flex items-center rounded-lg transition-colors'
|
||||
|
||||
const hasIconSlot = computed<boolean>(() => {
|
||||
return !!slots.icon
|
||||
})
|
||||
|
||||
const containerClass = computed<string>(() => {
|
||||
if (props.active) {
|
||||
return `${defaultClass} text-primary-600 bg-primary-50 font-semibold`
|
||||
}
|
||||
return `${defaultClass} text-body hover:bg-hover hover:text-heading`
|
||||
})
|
||||
</script>
|
||||
147
resources/scripts/components/base/BaseModal.vue
Normal file
147
resources/scripts/components/base/BaseModal.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionRoot appear as="template" :show="show">
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
class="fixed inset-0 z-20 overflow-y-auto"
|
||||
:open="show"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-end
|
||||
justify-center
|
||||
min-h-screen
|
||||
px-4
|
||||
text-center
|
||||
sm:block sm:px-2
|
||||
"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 transition-opacity bg-black/50"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>​</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-surface/95 backdrop-blur-xl backdrop-saturate-150
|
||||
rounded-xl border border-line-default
|
||||
text-left
|
||||
overflow-visible
|
||||
relative
|
||||
shadow-2xl
|
||||
transition-all
|
||||
my-4
|
||||
${modalSize}
|
||||
sm:w-full
|
||||
border-t-8 border-solid rounded border-primary-500`"
|
||||
>
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
px-6
|
||||
py-4
|
||||
text-lg
|
||||
font-medium
|
||||
text-heading
|
||||
border-b border-line-default border-solid
|
||||
"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { computed, watch, useSlots } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
interface Props {
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
show: false,
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'open', value: boolean): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
emit('open', newVal)
|
||||
}
|
||||
})
|
||||
|
||||
const modalSize = computed<string>(() => {
|
||||
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<boolean>(() => {
|
||||
return !!slots.header
|
||||
})
|
||||
</script>
|
||||
101
resources/scripts/components/base/BaseMoney.vue
Normal file
101
resources/scripts/components/base/BaseMoney.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<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 lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Money3Component } from 'v-money3'
|
||||
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
|
||||
const money3 = Money3Component
|
||||
|
||||
interface Currency {
|
||||
decimal_separator: string
|
||||
thousand_separator: string
|
||||
symbol: string
|
||||
precision: number
|
||||
}
|
||||
|
||||
interface CurrencyBindings {
|
||||
decimal: string
|
||||
thousands: string
|
||||
prefix: string
|
||||
precision: number
|
||||
masked: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
modelValue: string | number
|
||||
invalid?: boolean
|
||||
inputClass?: string
|
||||
disabled?: boolean
|
||||
percent?: boolean
|
||||
currency?: Currency | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
invalid: false,
|
||||
inputClass:
|
||||
'font-base block w-full sm:text-sm border-line-default rounded-md text-heading',
|
||||
disabled: false,
|
||||
percent: false,
|
||||
currency: null,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const companyStore = useCompanyStore()
|
||||
let hasInitialValueSet = false
|
||||
|
||||
const money = computed<string | number>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string | number) => {
|
||||
if (!hasInitialValueSet) {
|
||||
hasInitialValueSet = true
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const currencyBindings = computed<CurrencyBindings>(() => {
|
||||
const currency: 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<string>(() => {
|
||||
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>
|
||||
1421
resources/scripts/components/base/BaseMultiselect.vue
Normal file
1421
resources/scripts/components/base/BaseMultiselect.vue
Normal file
File diff suppressed because it is too large
Load Diff
79
resources/scripts/components/base/BasePdfPreview.vue
Normal file
79
resources/scripts/components/base/BasePdfPreview.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
src: string | false
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const status = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
|
||||
async function checkPdf(url: string): Promise<void> {
|
||||
status.value = 'loading'
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', credentials: 'same-origin' })
|
||||
if (response.ok) {
|
||||
status.value = 'ready'
|
||||
} else {
|
||||
status.value = 'error'
|
||||
}
|
||||
} catch {
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function retry(): void {
|
||||
if (props.src) {
|
||||
checkPdf(props.src)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
(url) => {
|
||||
if (url) {
|
||||
checkPdf(url)
|
||||
} else {
|
||||
status.value = 'idle'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col min-h-0 mt-8 overflow-hidden"
|
||||
style="height: 75vh"
|
||||
>
|
||||
<!-- Loading -->
|
||||
<div
|
||||
v-if="status === 'loading' || status === 'idle'"
|
||||
class="flex-1 flex items-center justify-center border border-line-default rounded-md bg-surface"
|
||||
>
|
||||
<BaseSpinner class="w-8 h-8 text-primary-400" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-4 border border-line-default rounded-md bg-surface"
|
||||
>
|
||||
<BaseIcon name="ExclamationCircleIcon" class="w-12 h-12 text-muted" />
|
||||
<p class="text-sm text-muted">
|
||||
{{ $t('general.unable_to_load_pdf') }}
|
||||
</p>
|
||||
<BaseButton variant="primary-outline" size="sm" @click="retry">
|
||||
{{ $t('general.retry') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- PDF iframe -->
|
||||
<iframe
|
||||
v-else
|
||||
:src="src || undefined"
|
||||
class="flex-1 border border-line-default border-solid rounded-md bg-surface"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
86
resources/scripts/components/base/BaseRadio.vue
Normal file
86
resources/scripts/components/base/BaseRadio.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<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-hidden">
|
||||
<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 lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from '@headlessui/vue'
|
||||
|
||||
interface Props {
|
||||
id?: string | number
|
||||
label?: string
|
||||
modelValue?: string | number
|
||||
value?: string | number
|
||||
name?: string | number
|
||||
checkedStateClass?: string
|
||||
unCheckedStateClass?: string
|
||||
optionGroupActiveStateClass?: string
|
||||
checkedStateLabelClass?: string
|
||||
unCheckedStateLabelClass?: string
|
||||
optionGroupClass?: string
|
||||
optionGroupLabelClass?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
id: () => `radio_${Math.random().toString(36).substr(2, 9)}`,
|
||||
label: '',
|
||||
modelValue: '',
|
||||
value: '',
|
||||
name: '',
|
||||
checkedStateClass: 'bg-primary-500',
|
||||
unCheckedStateClass: 'bg-surface ',
|
||||
optionGroupActiveStateClass: 'ring-2 ring-offset-2 ring-primary-500',
|
||||
checkedStateLabelClass: 'text-primary-500 ',
|
||||
unCheckedStateLabelClass: 'text-heading',
|
||||
optionGroupClass:
|
||||
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center',
|
||||
optionGroupLabelClass: 'block text-sm font-light',
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | number): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const selected = computed<string | number>({
|
||||
get: () => props.modelValue,
|
||||
set: (modelValue: string | number) => emit('update:modelValue', modelValue),
|
||||
})
|
||||
</script>
|
||||
204
resources/scripts/components/base/BaseRating.vue
Normal file
204
resources/scripts/components/base/BaseRating.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeMount } from 'vue'
|
||||
|
||||
interface RatingConfig {
|
||||
style?: Partial<RatingStyle>
|
||||
isIndicatorActive?: boolean
|
||||
}
|
||||
|
||||
interface RatingStyle {
|
||||
fullStarColor: string
|
||||
emptyStarColor: string
|
||||
starWidth: number
|
||||
starHeight: number
|
||||
}
|
||||
|
||||
interface StarData {
|
||||
raw: number
|
||||
percent: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config?: RatingConfig | null
|
||||
rating?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
config: null,
|
||||
rating: 0,
|
||||
})
|
||||
|
||||
const EMPTY_STAR = 0
|
||||
const FULL_STAR = 1
|
||||
const TOTAL_STARS = 5
|
||||
|
||||
const stars = ref<StarData[]>([])
|
||||
const isIndicatorActive = ref<boolean>(false)
|
||||
const style = ref<RatingStyle>({
|
||||
fullStarColor: '#F1C644',
|
||||
emptyStarColor: '#D4D4D4',
|
||||
starWidth: 20,
|
||||
starHeight: 20,
|
||||
})
|
||||
|
||||
const getStarPoints = computed<string>(() => {
|
||||
const centerX = style.value.starWidth / 2
|
||||
const centerY = style.value.starHeight / 2
|
||||
const innerCircleArms = 5
|
||||
const innerRadius = style.value.starWidth / innerCircleArms
|
||||
const innerOuterRadiusRatio = 2.5
|
||||
const outerRadius = innerRadius * innerOuterRadiusRatio
|
||||
|
||||
return calcStarPoints(centerX, centerY, innerCircleArms, innerRadius, outerRadius)
|
||||
})
|
||||
|
||||
function calcStarPoints(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
innerCircleArms: number,
|
||||
innerRadius: number,
|
||||
outerRadius: number
|
||||
): string {
|
||||
const angle = Math.PI / innerCircleArms
|
||||
const angleOffsetToCenterStar = 60
|
||||
const totalArms = innerCircleArms * 2
|
||||
let points = ''
|
||||
|
||||
for (let i = 0; i < totalArms; i++) {
|
||||
const isEvenIndex = i % 2 === 0
|
||||
const r = isEvenIndex ? outerRadius : innerRadius
|
||||
const currX = centerX + Math.cos(i * angle + angleOffsetToCenterStar) * r
|
||||
const currY = centerY + Math.sin(i * angle + angleOffsetToCenterStar) * r
|
||||
points += currX + ',' + currY + ' '
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
function calcStarFullness(starData: StarData): string {
|
||||
return starData.raw * 100 + '%'
|
||||
}
|
||||
|
||||
function getFullFillColor(starData: StarData): string {
|
||||
return starData.raw !== EMPTY_STAR
|
||||
? style.value.fullStarColor
|
||||
: style.value.emptyStarColor
|
||||
}
|
||||
|
||||
function initStars(): void {
|
||||
for (let i = 0; i < TOTAL_STARS; i++) {
|
||||
stars.value.push({
|
||||
raw: EMPTY_STAR,
|
||||
percent: EMPTY_STAR + '%',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setStars(): void {
|
||||
let fullStarsCounter = Math.floor(props.rating)
|
||||
|
||||
for (let i = 0; i < stars.value.length; i++) {
|
||||
if (fullStarsCounter !== 0) {
|
||||
stars.value[i].raw = FULL_STAR
|
||||
stars.value[i].percent = calcStarFullness(stars.value[i])
|
||||
fullStarsCounter--
|
||||
} else {
|
||||
const surplus = Math.round((props.rating % 1) * 10) / 10
|
||||
const roundedOneDecimalPoint = Math.round(surplus * 10) / 10
|
||||
stars.value[i].raw = roundedOneDecimalPoint
|
||||
stars.value[i].percent = calcStarFullness(stars.value[i])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setConfigData(): void {
|
||||
if (props.config) {
|
||||
if (props.config.style?.fullStarColor) {
|
||||
style.value.fullStarColor = props.config.style.fullStarColor
|
||||
}
|
||||
if (props.config.style?.emptyStarColor) {
|
||||
style.value.emptyStarColor = props.config.style.emptyStarColor
|
||||
}
|
||||
if (props.config.style?.starWidth) {
|
||||
style.value.starWidth = props.config.style.starWidth
|
||||
}
|
||||
if (props.config.style?.starHeight) {
|
||||
style.value.starHeight = props.config.style.starHeight
|
||||
}
|
||||
if (props.config.isIndicatorActive) {
|
||||
isIndicatorActive.value = props.config.isIndicatorActive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
initStars()
|
||||
setStars()
|
||||
setConfigData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="star-rating">
|
||||
<div
|
||||
v-for="(star, index) in stars"
|
||||
:key="index"
|
||||
:title="String(rating)"
|
||||
class="star-container"
|
||||
>
|
||||
<svg
|
||||
:style="[
|
||||
{ fill: `url(#gradient${star.raw})` },
|
||||
{ width: style.starWidth },
|
||||
{ height: style.starHeight },
|
||||
]"
|
||||
class="star-svg"
|
||||
>
|
||||
<polygon :points="getStarPoints" style="fill-rule: nonzero" />
|
||||
<defs>
|
||||
<linearGradient :id="`gradient${star.raw}`">
|
||||
<stop
|
||||
id="stop1"
|
||||
:offset="star.percent"
|
||||
:stop-color="getFullFillColor(star)"
|
||||
stop-opacity="1"
|
||||
/>
|
||||
<stop
|
||||
id="stop2"
|
||||
:offset="star.percent"
|
||||
:stop-color="getFullFillColor(star)"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
<stop
|
||||
id="stop3"
|
||||
:offset="star.percent"
|
||||
:stop-color="style.emptyStarColor"
|
||||
stop-opacity="1"
|
||||
/>
|
||||
<stop
|
||||
id="stop4"
|
||||
:stop-color="style.emptyStarColor"
|
||||
offset="100%"
|
||||
stop-opacity="1"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-if="isIndicatorActive" class="indicator">{{ rating }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.star-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.star-container {
|
||||
display: flex;
|
||||
}
|
||||
.star-container:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RecurringInvoiceStatus } from '@/scripts/types/domain'
|
||||
|
||||
interface Props {
|
||||
status?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const labelStatus = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case RecurringInvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return t('recurring_invoices.complete')
|
||||
case RecurringInvoiceStatus.ON_HOLD:
|
||||
case 'ON_HOLD':
|
||||
return t('recurring_invoices.on_hold')
|
||||
case RecurringInvoiceStatus.ACTIVE:
|
||||
case 'ACTIVE':
|
||||
return t('recurring_invoices.active')
|
||||
default:
|
||||
return props.status
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{ labelStatus }}
|
||||
</template>
|
||||
11
resources/scripts/components/base/BaseScrollPane.vue
Normal file
11
resources/scripts/components/base/BaseScrollPane.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="-my-2 overflow-x-auto lg:overflow-visible sm:-mx-6 lg:-mx-8">
|
||||
<div class="py-2 align-middle inline-block min-w-full sm:px-4 lg:px-6">
|
||||
<div class="overflow-hidden lg:overflow-visible sm:px-2 lg:p-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
26
resources/scripts/components/base/BaseSelectAction.vue
Normal file
26
resources/scripts/components/base/BaseSelectAction.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A simple action slot component used inside BaseMultiselect dropdowns.
|
||||
* Renders a clickable row at the bottom of the dropdown for actions like
|
||||
* "Add new item" or "Add new customer".
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-full
|
||||
px-6
|
||||
py-2
|
||||
text-sm
|
||||
bg-surface-muted
|
||||
cursor-pointer
|
||||
text-primary-400
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
215
resources/scripts/components/base/BaseSelectInput.vue
Normal file
215
resources/scripts/components/base/BaseSelectInput.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<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-heading 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-surface
|
||||
border border-line-default
|
||||
rounded-md
|
||||
shadow-xs
|
||||
cursor-default
|
||||
focus:outline-hidden
|
||||
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-subtle truncate">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
<span v-else class="block text-subtle 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-subtle"
|
||||
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-surface
|
||||
rounded-md
|
||||
shadow-lg
|
||||
max-h-60
|
||||
ring-1 ring-black/5
|
||||
focus:outline-hidden
|
||||
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-heading',
|
||||
'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 lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
interface SelectOption {
|
||||
id: string | number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type ModelValue = string | number | boolean | SelectOption | SelectOption[]
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
modelValue?: ModelValue
|
||||
options: SelectOption[]
|
||||
label?: string
|
||||
placeholder?: string
|
||||
labelKey?: string
|
||||
valueProp?: string | null
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
modelValue: '',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
labelKey: 'label',
|
||||
valueProp: null,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: ModelValue): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const selectedValue = ref<ModelValue>(props.modelValue)
|
||||
|
||||
function isObject(val: unknown): val is Record<string, unknown> {
|
||||
return typeof val === 'object' && val !== null
|
||||
}
|
||||
|
||||
function getValue(val: ModelValue): string | number | boolean | unknown {
|
||||
if (isObject(val) && !Array.isArray(val)) {
|
||||
return val[props.labelKey]
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.valueProp && props.options.length) {
|
||||
const found = props.options.find((val) => {
|
||||
if (val[props.valueProp!]) {
|
||||
return val[props.valueProp!] === props.modelValue
|
||||
}
|
||||
return false
|
||||
})
|
||||
selectedValue.value = found ?? props.modelValue
|
||||
} else {
|
||||
selectedValue.value = props.modelValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedValue, (val) => {
|
||||
if (props.valueProp && isObject(val) && !Array.isArray(val)) {
|
||||
emit('update:modelValue', val[props.valueProp] as ModelValue)
|
||||
} else {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
39
resources/scripts/components/base/BaseSettingCard.vue
Normal file
39
resources/scripts/components/base/BaseSettingCard.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<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-muted
|
||||
max-w-[680px]
|
||||
"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 lg:mt-0 lg:ml-2">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</BaseCard>
|
||||
</template>
|
||||
22
resources/scripts/components/base/BaseSpinner.vue
Normal file
22
resources/scripts/components/base/BaseSpinner.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<svg
|
||||
class="animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
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-surface-muted'"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
h-6
|
||||
transition-colors
|
||||
rounded-full
|
||||
w-11
|
||||
focus:outline-hidden focus:ring-primary-500
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<span
|
||||
:class="enabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
class="
|
||||
inline-block
|
||||
w-4
|
||||
h-4
|
||||
transition-transform
|
||||
bg-white
|
||||
rounded-full
|
||||
"
|
||||
/>
|
||||
</Switch>
|
||||
|
||||
<SwitchLabel v-if="labelRight" class="ml-4 cursor-pointer">{{
|
||||
labelRight
|
||||
}}</SwitchLabel>
|
||||
</div>
|
||||
</SwitchGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Switch, SwitchGroup, SwitchLabel } from '@headlessui/vue'
|
||||
|
||||
interface Props {
|
||||
labelLeft?: string
|
||||
labelRight?: string
|
||||
modelValue?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
labelLeft: '',
|
||||
labelRight: '',
|
||||
modelValue: false,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const enabled = computed<boolean>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
</script>
|
||||
24
resources/scripts/components/base/BaseTab.vue
Normal file
24
resources/scripts/components/base/BaseTab.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<TabPanel :class="[tabPanelContainer, 'focus:outline-hidden']">
|
||||
<slot />
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TabPanel } from '@headlessui/vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string | number
|
||||
count?: string | number
|
||||
countVariant?: string | number
|
||||
tabPanelContainer?: string
|
||||
}>(),
|
||||
{
|
||||
title: 'Tab',
|
||||
count: '',
|
||||
countVariant: '',
|
||||
tabPanelContainer: 'py-4 mt-px',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
87
resources/scripts/components/base/BaseTabGroup.vue
Normal file
87
resources/scripts/components/base/BaseTabGroup.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
import type { VNode } from 'vue'
|
||||
import { TabGroup, TabList, Tab, TabPanels } from '@headlessui/vue'
|
||||
|
||||
interface TabData {
|
||||
title: string
|
||||
count?: number | string
|
||||
'count-variant'?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
defaultIndex?: number
|
||||
filter?: string | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'change', tab: TabData): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultIndex: 0,
|
||||
filter: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const tabs = computed<TabData[]>(() => {
|
||||
const defaultSlot = slots.default?.()
|
||||
if (!defaultSlot) return []
|
||||
return defaultSlot.map((tab: VNode) => (tab.props ?? {}) as TabData)
|
||||
})
|
||||
|
||||
function onChange(d: number): void {
|
||||
emit('change', tabs.value[d])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<TabGroup :default-index="defaultIndex" @change="onChange">
|
||||
<TabList
|
||||
:class="[
|
||||
'flex border-b border-line-default',
|
||||
'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-5 py-2.5 text-sm leading-5 font-medium flex items-center relative -mb-px border-b-2 focus:outline-hidden whitespace-nowrap transition-colors',
|
||||
selected
|
||||
? 'border-primary-400 text-heading'
|
||||
: 'border-transparent text-muted hover:text-body hover:border-line-strong',
|
||||
]"
|
||||
>
|
||||
{{ 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>
|
||||
32
resources/scripts/components/base/BaseText.vue
Normal file
32
resources/scripts/components/base/BaseText.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
tag?: string
|
||||
text?: string
|
||||
length?: number | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tag: 'div',
|
||||
text: '',
|
||||
length: null,
|
||||
})
|
||||
|
||||
const displayText = computed<string>(() => {
|
||||
if (props.length !== null) {
|
||||
return props.text.length <= props.length
|
||||
? props.text
|
||||
: `${props.text.substring(0, props.length)}...`
|
||||
}
|
||||
return props.text
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="whitespace-normal">
|
||||
<BaseCustomTag :tag="tag" :title="props.text" class="line-clamp-1">
|
||||
{{ displayText }}
|
||||
</BaseCustomTag>
|
||||
</div>
|
||||
</template>
|
||||
93
resources/scripts/components/base/BaseTextarea.vue
Normal file
93
resources/scripts/components/base/BaseTextarea.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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 lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
row?: number | null
|
||||
invalid?: boolean
|
||||
disabled?: boolean
|
||||
modelValue?: string | number
|
||||
defaultInputClass?: string
|
||||
autosize?: boolean
|
||||
borderless?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
row: null,
|
||||
invalid: false,
|
||||
disabled: false,
|
||||
modelValue: '',
|
||||
defaultInputClass:
|
||||
'box-border w-full px-3 py-2 text-sm not-italic font-normal leading-snug text-left text-heading placeholder-subtle bg-surface border border-line-default border-solid rounded outline-hidden',
|
||||
autosize: false,
|
||||
borderless: false,
|
||||
})
|
||||
|
||||
const textarea = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const inputBorderClass = computed<string>(() => {
|
||||
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-hidden focus:ring-primary-400 focus:border focus:border-primary-400'
|
||||
})
|
||||
|
||||
const loadingPlaceholderSize = computed<string>(() => {
|
||||
switch (props.row) {
|
||||
case 2:
|
||||
return '56'
|
||||
case 4:
|
||||
return '94'
|
||||
default:
|
||||
return '56'
|
||||
}
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onInput(e: Event): void {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
emit('update:modelValue', target.value)
|
||||
|
||||
if (props.autosize) {
|
||||
target.style.height = 'auto'
|
||||
target.style.height = `${target.scrollHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (textarea.value && props.autosize) {
|
||||
textarea.value.style.height = textarea.value.scrollHeight + 'px'
|
||||
textarea.value.style.overflowY = 'hidden'
|
||||
textarea.value.style.resize = 'none'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
141
resources/scripts/components/base/BaseTimePicker.vue
Normal file
141
resources/scripts/components/base/BaseTimePicker.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<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-subtle
|
||||
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 lang="ts">
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, reactive, useSlots, ref } from 'vue'
|
||||
|
||||
interface FlatPickrInstance {
|
||||
fp: { open: () => void }
|
||||
}
|
||||
|
||||
const dpt = ref<FlatPickrInstance | null>(null)
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | Date
|
||||
contentLoading?: boolean
|
||||
placeholder?: string | null
|
||||
invalid?: boolean
|
||||
disabled?: boolean
|
||||
containerClass?: string
|
||||
clockIcon?: boolean
|
||||
defaultInputClass?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => new Date(),
|
||||
contentLoading: false,
|
||||
placeholder: null,
|
||||
invalid: false,
|
||||
disabled: false,
|
||||
containerClass: '',
|
||||
clockIcon: true,
|
||||
defaultInputClass:
|
||||
'font-base pl-8 py-2 outline-hidden focus:ring-primary-400 focus:outline-hidden focus:border-primary-400 block w-full sm:text-sm border-line-strong rounded-md text-heading',
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string | Date): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
interface TimePickerConfig {
|
||||
enableTime: boolean
|
||||
noCalendar: boolean
|
||||
dateFormat: string
|
||||
time_24hr: boolean
|
||||
}
|
||||
|
||||
const config = reactive<TimePickerConfig>({
|
||||
enableTime: true,
|
||||
noCalendar: true,
|
||||
dateFormat: 'H:i',
|
||||
time_24hr: true,
|
||||
})
|
||||
|
||||
const time = computed<string | Date>({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string | Date) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const hasIconSlot = computed<boolean>(() => {
|
||||
return !!slots.icon
|
||||
})
|
||||
|
||||
function onClickPicker(): void {
|
||||
dpt.value?.fp.open()
|
||||
}
|
||||
|
||||
const computedContainerClass = computed<string>(() => {
|
||||
const containerClass = `${props.containerClass} `
|
||||
|
||||
return containerClass
|
||||
})
|
||||
|
||||
const inputInvalidClass = computed<string>(() => {
|
||||
if (props.invalid) {
|
||||
return 'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const inputDisabledClass = computed<string>(() => {
|
||||
if (props.disabled) {
|
||||
return 'border border-solid rounded-md outline-hidden input-field box-border-2 base-date-picker-input placeholder-subtle bg-surface-muted text-body border-line-strong'
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
84
resources/scripts/components/base/BaseWizardNavigation.vue
Normal file
84
resources/scripts/components/base/BaseWizardNavigation.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
interface Props {
|
||||
currentStep?: number | null
|
||||
steps?: number | null
|
||||
containerClass?: string
|
||||
progress?: string
|
||||
currentStepClass?: string
|
||||
nextStepClass?: string
|
||||
previousStepClass?: string
|
||||
iconClass?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', index: number): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentStep: null,
|
||||
steps: null,
|
||||
containerClass: 'flex justify-between w-full my-10 max-w-xl mx-auto',
|
||||
progress: 'rounded-full float-left w-6 h-6 border-4 cursor-pointer',
|
||||
currentStepClass: 'bg-white border-primary-500',
|
||||
nextStepClass: 'border-line-default bg-surface',
|
||||
previousStepClass:
|
||||
'bg-primary-500 border-primary-500 flex justify-center items-center',
|
||||
iconClass:
|
||||
'flex items-center justify-center w-full h-full text-sm font-black text-center text-white',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function stepStyle(number: number): string[] {
|
||||
if (props.currentStep === number) {
|
||||
return [props.currentStepClass, props.progress]
|
||||
}
|
||||
if (props.currentStep !== null && props.currentStep > number) {
|
||||
return [props.previousStepClass, props.progress]
|
||||
}
|
||||
if (props.currentStep !== null && props.currentStep < number) {
|
||||
return [props.nextStepClass, props.progress]
|
||||
}
|
||||
return [props.progress]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="containerClass"
|
||||
class="
|
||||
relative
|
||||
after:bg-surface-muted
|
||||
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(index)"
|
||||
class="z-10"
|
||||
href="#"
|
||||
@click.prevent="$emit('click', index)"
|
||||
>
|
||||
<svg
|
||||
v-if="currentStep !== null && currentStep > index"
|
||||
: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>
|
||||
34
resources/scripts/components/base/BaseWizardStep.vue
Normal file
34
resources/scripts/components/base/BaseWizardStep.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
interface Props {
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
stepContainerClass?: string
|
||||
stepTitleClass?: string
|
||||
stepDescriptionClass?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: null,
|
||||
description: null,
|
||||
stepContainerClass:
|
||||
'w-full p-8 mb-8 bg-surface border border-line-default border-solid rounded',
|
||||
stepTitleClass: 'text-2xl not-italic font-semibold leading-7 text-heading',
|
||||
stepDescriptionClass:
|
||||
'w-full mt-2.5 mb-8 text-sm not-italic leading-snug text-muted lg:w-7/12 md:w-7/12 sm:w-7/12',
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
45
resources/scripts/components/base/EstimateStatusBadge.vue
Normal file
45
resources/scripts/components/base/EstimateStatusBadge.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EstimateStatus } from '@/scripts/types/domain'
|
||||
|
||||
interface Props {
|
||||
status?: EstimateStatus | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case EstimateStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return `${baseClasses} bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300/50`
|
||||
case EstimateStatus.SENT:
|
||||
case 'SENT':
|
||||
return `${baseClasses} bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-300/50`
|
||||
case EstimateStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return `${baseClasses} bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-300/50`
|
||||
case EstimateStatus.EXPIRED:
|
||||
case 'EXPIRED':
|
||||
return `${baseClasses} bg-red-50 text-red-700 ring-1 ring-inset ring-red-300/50`
|
||||
case EstimateStatus.ACCEPTED:
|
||||
case 'ACCEPTED':
|
||||
return `${baseClasses} bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-300/50`
|
||||
case EstimateStatus.REJECTED:
|
||||
case 'REJECTED':
|
||||
return `${baseClasses} bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-300/50`
|
||||
default:
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
89
resources/scripts/components/base/InvoiceInformationCard.vue
Normal file
89
resources/scripts/components/base/InvoiceInformationCard.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { Invoice } from '@/scripts/types/domain'
|
||||
import type { Currency } from '@/scripts/types/domain'
|
||||
import type { Company } from '@/scripts/types/domain'
|
||||
import type { Customer } from '@/scripts/types/domain'
|
||||
|
||||
interface InvoiceInfo {
|
||||
paid_status: string
|
||||
total: number
|
||||
formatted_notes?: string | null
|
||||
currency?: Currency
|
||||
company?: Pick<Company, 'name'>
|
||||
customer?: Pick<Customer, 'name'>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
invoice: InvoiceInfo | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-surface shadow overflow-hidden rounded-lg mt-6">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-heading">
|
||||
{{ $t('invoices.invoice_information') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div v-if="invoice" class="border-t border-line-default px-4 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-line-default">
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-muted">
|
||||
{{ $t('general.from') }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-heading sm:mt-0 sm:col-span-2">
|
||||
{{ invoice.company?.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-muted">
|
||||
{{ $t('general.to') }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-heading sm:mt-0 sm:col-span-2">
|
||||
{{ invoice.customer?.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-muted capitalize">
|
||||
{{ $t('invoices.paid_status').toLowerCase() }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-heading sm:mt-0 sm:col-span-2">
|
||||
<BaseInvoiceStatusBadge
|
||||
:status="invoice.paid_status"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
<BaseInvoiceStatusLabel :status="invoice.paid_status" />
|
||||
</BaseInvoiceStatusBadge>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-muted">
|
||||
{{ $t('invoices.total') }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-heading sm:mt-0 sm:col-span-2">
|
||||
<BaseFormatMoney
|
||||
:currency="invoice.currency"
|
||||
:amount="invoice.total"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="invoice.formatted_notes"
|
||||
class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
|
||||
>
|
||||
<dt class="text-sm font-medium text-muted">
|
||||
{{ $t('invoices.notes') }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-heading sm:mt-0 sm:col-span-2">
|
||||
<span v-html="invoice.formatted_notes"></span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div v-else class="w-full flex items-center justify-center p-5">
|
||||
<BaseSpinner class="text-primary-500 h-10 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
144
resources/scripts/components/base/InvoicePublicPage.vue
Normal file
144
resources/scripts/components/base/InvoicePublicPage.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { client } from '@/scripts/api/client'
|
||||
import InvoiceInformationCard from './InvoiceInformationCard.vue'
|
||||
import type { Currency } from '@/scripts/types/domain'
|
||||
import type { Company } from '@/scripts/types/domain'
|
||||
import type { Customer } from '@/scripts/types/domain'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customer_logo?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface InvoicePublicData {
|
||||
invoice_number: string
|
||||
paid_status: string
|
||||
total: number
|
||||
formatted_notes?: string | null
|
||||
payment_module_enabled?: boolean
|
||||
currency?: Currency
|
||||
company?: Pick<Company, 'name' | 'slug'>
|
||||
customer?: Pick<Customer, 'name'>
|
||||
}
|
||||
|
||||
const invoiceData = ref<InvoicePublicData | null>(null) as Ref<InvoicePublicData | null>
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
loadInvoice()
|
||||
|
||||
async function loadInvoice(): Promise<void> {
|
||||
const hash = route.params.hash as string
|
||||
const { data } = await client.get(`/customer/invoices/${hash}`)
|
||||
invoiceData.value = data.data
|
||||
}
|
||||
|
||||
const shareableLink = computed<string>(() => {
|
||||
return route.path + '?pdf'
|
||||
})
|
||||
|
||||
function getLogo(): URL {
|
||||
const imgUrl = new URL('$images/logo-gray.png', import.meta.url)
|
||||
return imgUrl
|
||||
}
|
||||
|
||||
const customerLogo = computed<string | false>(() => {
|
||||
if (window.customer_logo) {
|
||||
return window.customer_logo
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const pageTitle: ComputedRef<string> = computed(() => invoiceData.value?.invoice_number ?? '')
|
||||
|
||||
function payInvoice(): void {
|
||||
const resolved = router.resolve({ name: 'invoice.pay' })
|
||||
if (resolved.matched.length) {
|
||||
router.push({
|
||||
name: 'invoice.pay',
|
||||
params: {
|
||||
hash: route.params.hash as string,
|
||||
company: invoiceData.value?.company?.slug ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen overflow-y-auto min-h-0">
|
||||
<div class="bg-linear-to-r from-primary-500 to-primary-400 h-5"></div>
|
||||
|
||||
<div
|
||||
class="
|
||||
relative
|
||||
p-6
|
||||
pb-28
|
||||
px-4
|
||||
md:px-6
|
||||
w-full
|
||||
md:w-auto md:max-w-xl
|
||||
mx-auto
|
||||
"
|
||||
>
|
||||
<BasePageHeader :title="pageTitle || ''">
|
||||
<template #actions>
|
||||
<div
|
||||
class="
|
||||
flex flex-col
|
||||
md:flex-row
|
||||
absolute
|
||||
md:relative
|
||||
bottom-2
|
||||
left-0
|
||||
px-4
|
||||
md:px-0
|
||||
w-full
|
||||
md:space-x-4 md:space-y-0
|
||||
space-y-2
|
||||
"
|
||||
>
|
||||
<a :href="shareableLink" target="_blank" class="block w-full">
|
||||
<BaseButton
|
||||
variant="primary-outline"
|
||||
class="justify-center w-full"
|
||||
>
|
||||
{{ $t('general.download_pdf') }}
|
||||
</BaseButton>
|
||||
</a>
|
||||
|
||||
<BaseButton
|
||||
v-if="
|
||||
invoiceData &&
|
||||
invoiceData.paid_status !== 'PAID' &&
|
||||
invoiceData.payment_module_enabled
|
||||
"
|
||||
variant="primary"
|
||||
class="justify-center"
|
||||
@click="payInvoice"
|
||||
>
|
||||
{{ $t('general.pay_invoice') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
|
||||
<InvoiceInformationCard :invoice="invoiceData" />
|
||||
|
||||
<div
|
||||
v-if="!customerLogo"
|
||||
class="flex items-center justify-center mt-4 text-muted font-normal"
|
||||
>
|
||||
Powered by
|
||||
<a href="https://invoiceshelf.com" target="_blank">
|
||||
<img :src="getLogo().href" class="h-4 ml-1 mb-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
58
resources/scripts/components/base/InvoiceStatusBadge.vue
Normal file
58
resources/scripts/components/base/InvoiceStatusBadge.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { InvoiceStatus, InvoicePaidStatus } from '@/scripts/types/domain'
|
||||
|
||||
type InvoiceBadgeStatus =
|
||||
| InvoiceStatus
|
||||
| InvoicePaidStatus
|
||||
| 'DUE'
|
||||
| 'OVERDUE'
|
||||
|
||||
interface Props {
|
||||
status?: InvoiceBadgeStatus | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case InvoiceStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return `${baseClasses} bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300/50`
|
||||
case InvoiceStatus.SENT:
|
||||
case 'SENT':
|
||||
return `${baseClasses} bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-300/50`
|
||||
case InvoiceStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return `${baseClasses} bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-300/50`
|
||||
case InvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return `${baseClasses} bg-green-50 text-green-700 ring-1 ring-inset ring-green-300/50`
|
||||
case 'DUE':
|
||||
return `${baseClasses} bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-300/50`
|
||||
case 'OVERDUE':
|
||||
return `${baseClasses} bg-red-50 text-red-700 ring-1 ring-inset ring-red-300/50`
|
||||
case InvoicePaidStatus.UNPAID:
|
||||
case 'UNPAID':
|
||||
return `${baseClasses} bg-orange-50 text-orange-700 ring-1 ring-inset ring-orange-300/50`
|
||||
case InvoicePaidStatus.PARTIALLY_PAID:
|
||||
case 'PARTIALLY_PAID':
|
||||
return `${baseClasses} bg-cyan-50 text-cyan-700 ring-1 ring-inset ring-cyan-300/50`
|
||||
case InvoicePaidStatus.PAID:
|
||||
case 'PAID':
|
||||
return `${baseClasses} bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-300/50`
|
||||
default:
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
22
resources/scripts/components/base/NewBadge.vue
Normal file
22
resources/scripts/components/base/NewBadge.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
interface Props {
|
||||
/** Note: original prop name was "sucess" (sic) — preserved for backwards compatibility */
|
||||
sucess?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
sucess: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
sucess ? 'bg-green-500/25 text-status-green' : 'bg-red-500/25 text-status-red',
|
||||
'px-2 py-1 text-sm font-normal text-center uppercase',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
40
resources/scripts/components/base/PaidStatusBadge.vue
Normal file
40
resources/scripts/components/base/PaidStatusBadge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { InvoicePaidStatus } from '@/scripts/types/domain'
|
||||
|
||||
type PaidBadgeStatus = InvoicePaidStatus | 'OVERDUE' | string
|
||||
|
||||
interface Props {
|
||||
status?: PaidBadgeStatus
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case InvoicePaidStatus.PAID:
|
||||
case 'PAID':
|
||||
return `${baseClasses} bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-300/50`
|
||||
case InvoicePaidStatus.UNPAID:
|
||||
case 'UNPAID':
|
||||
return `${baseClasses} bg-orange-50 text-orange-700 ring-1 ring-inset ring-orange-300/50`
|
||||
case InvoicePaidStatus.PARTIALLY_PAID:
|
||||
case 'PARTIALLY_PAID':
|
||||
return `${baseClasses} bg-cyan-50 text-cyan-700 ring-1 ring-inset ring-cyan-300/50`
|
||||
case 'OVERDUE':
|
||||
return `${baseClasses} bg-red-50 text-red-700 ring-1 ring-inset ring-red-300/50`
|
||||
default:
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RecurringInvoiceStatus } from '@/scripts/types/domain'
|
||||
|
||||
interface Props {
|
||||
status?: RecurringInvoiceStatus | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium'
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case RecurringInvoiceStatus.ACTIVE:
|
||||
case 'ACTIVE':
|
||||
return `${baseClasses} bg-blue-50 text-blue-700 ring-1 ring-inset ring-blue-300/50`
|
||||
case RecurringInvoiceStatus.ON_HOLD:
|
||||
case 'ON_HOLD':
|
||||
return `${baseClasses} bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-300/50`
|
||||
case RecurringInvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return `${baseClasses} bg-green-50 text-green-700 ring-1 ring-inset ring-green-300/50`
|
||||
default:
|
||||
return `${baseClasses} bg-surface-secondary text-muted ring-1 ring-inset ring-line-default`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
60
resources/scripts/components/base/index.ts
Normal file
60
resources/scripts/components/base/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export { default as BaseBadge } from './BaseBadge.vue'
|
||||
export { default as BaseButton } from './BaseButton.vue'
|
||||
export { default as BaseCard } from './BaseCard.vue'
|
||||
export { default as BaseCheckbox } from './BaseCheckbox.vue'
|
||||
export { default as BaseCustomerAddressDisplay } from './BaseCustomerAddressDisplay.vue'
|
||||
export { default as BaseCustomerSelectInput } from './BaseCustomerSelectInput.vue'
|
||||
export { default as BaseCustomerSelectPopup } from './BaseCustomerSelectPopup.vue'
|
||||
export { default as BaseCustomInput } from './BaseCustomInput.vue'
|
||||
export { default as BaseDatePicker } from './BaseDatePicker.vue'
|
||||
export { default as BaseDescriptionList } from './BaseDescriptionList.vue'
|
||||
export { default as BaseDescriptionListItem } from './BaseDescriptionListItem.vue'
|
||||
export { default as BaseDialog } from './BaseDialog.vue'
|
||||
export { default as BaseDivider } from './BaseDivider.vue'
|
||||
export { default as BaseDropdown } from './BaseDropdown.vue'
|
||||
export { default as BaseDropdownItem } from './BaseDropdownItem.vue'
|
||||
export { default as BaseErrorAlert } from './BaseErrorAlert.vue'
|
||||
export { default as BaseEstimateStatusLabel } from './BaseEstimateStatusLabel.vue'
|
||||
export { default as BaseFileUploader } from './BaseFileUploader.vue'
|
||||
export { default as BaseFormatMoney } from './BaseFormatMoney.vue'
|
||||
export { default as BaseGlobalLoader } from './BaseGlobalLoader.vue'
|
||||
export { default as BaseHeading } from './BaseHeading.vue'
|
||||
export { default as BaseIcon } from './BaseIcon.vue'
|
||||
export { default as BaseInfoAlert } from './BaseInfoAlert.vue'
|
||||
export { default as BaseInput } from './BaseInput.vue'
|
||||
export { default as BaseInvoiceStatusLabel } from './BaseInvoiceStatusLabel.vue'
|
||||
export { default as BaseItemSelect } from './BaseItemSelect.vue'
|
||||
export { default as BaseLabel } from './BaseLabel.vue'
|
||||
export { default as BaseList } from './BaseList.vue'
|
||||
export { default as BaseListItem } from './BaseListItem.vue'
|
||||
export { default as BaseMoney } from './BaseMoney.vue'
|
||||
export { default as BaseModal } from './BaseModal.vue'
|
||||
export { default as BaseMultiselect } from './BaseMultiselect.vue'
|
||||
export { default as BasePdfPreview } from './BasePdfPreview.vue'
|
||||
export { default as BaseRadio } from './BaseRadio.vue'
|
||||
export { default as BaseRating } from './BaseRating.vue'
|
||||
export { default as BaseRecurringInvoiceStatusLabel } from './BaseRecurringInvoiceStatusLabel.vue'
|
||||
export { default as BaseScrollPane } from './BaseScrollPane.vue'
|
||||
export { default as BaseSelectAction } from './BaseSelectAction.vue'
|
||||
export { default as BaseSelectInput } from './BaseSelectInput.vue'
|
||||
export { default as BaseSettingCard } from './BaseSettingCard.vue'
|
||||
export { default as BaseSpinner } from './BaseSpinner.vue'
|
||||
export { default as BaseSwitch } from './BaseSwitch.vue'
|
||||
export { default as BaseTab } from './BaseTab.vue'
|
||||
export { default as BaseTabGroup } from './BaseTabGroup.vue'
|
||||
export { default as BaseText } from './BaseText.vue'
|
||||
export { default as BaseTextarea } from './BaseTextarea.vue'
|
||||
export { default as BaseTimePicker } from './BaseTimePicker.vue'
|
||||
export { default as BaseWizardNavigation } from './BaseWizardNavigation.vue'
|
||||
export { default as BaseWizardStep } from './BaseWizardStep.vue'
|
||||
|
||||
// Status badge components
|
||||
export { default as InvoiceStatusBadge } from './InvoiceStatusBadge.vue'
|
||||
export { default as EstimateStatusBadge } from './EstimateStatusBadge.vue'
|
||||
export { default as PaidStatusBadge } from './PaidStatusBadge.vue'
|
||||
export { default as RecurringInvoiceStatusBadge } from './RecurringInvoiceStatusBadge.vue'
|
||||
export { default as NewBadge } from './NewBadge.vue'
|
||||
|
||||
// Shared page components
|
||||
export { default as InvoiceInformationCard } from './InvoiceInformationCard.vue'
|
||||
export { default as InvoicePublicPage } from './InvoicePublicPage.vue'
|
||||
214
resources/scripts/components/charts/LineChart.vue
Normal file
214
resources/scripts/components/charts/LineChart.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="graph-container h-[300px]">
|
||||
<canvas id="graph" ref="graph" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import type { ChartConfiguration, ChartDataset } from 'chart.js/auto'
|
||||
import { ref, computed, onMounted, watchEffect, inject } from 'vue'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
|
||||
interface FormatUtils {
|
||||
formatMoney: (amount: number, currency: CurrencyInfo) => string
|
||||
}
|
||||
|
||||
interface CurrencyInfo {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
labels?: string[]
|
||||
values?: number[]
|
||||
invoices?: number[]
|
||||
expenses?: number[]
|
||||
receipts?: number[]
|
||||
income?: number[]
|
||||
}
|
||||
|
||||
const utils = inject<FormatUtils>('utils')
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
labels: () => [],
|
||||
values: () => [],
|
||||
invoices: () => [],
|
||||
expenses: () => [],
|
||||
receipts: () => [],
|
||||
income: () => [],
|
||||
})
|
||||
|
||||
let myLineChart: Chart | null = null
|
||||
const graph = ref<HTMLCanvasElement | null>(null)
|
||||
const companyStore = useCompanyStore()
|
||||
const defaultCurrency = computed<CurrencyInfo>(() => {
|
||||
return companyStore.selectedCompanyCurrency
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.labels) {
|
||||
if (myLineChart) {
|
||||
myLineChart.reset()
|
||||
update()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!graph.value) return
|
||||
const context = graph.value.getContext('2d')
|
||||
if (!context) return
|
||||
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const gridColor =
|
||||
style.getPropertyValue('--color-line-light').trim() || 'rgba(0,0,0,0.1)'
|
||||
const tickColor =
|
||||
style.getPropertyValue('--color-muted').trim() || '#6b7280'
|
||||
const surfaceColor =
|
||||
style.getPropertyValue('--color-surface').trim() || '#fff'
|
||||
|
||||
const datasets: ChartDataset<'line', number[]>[] = [
|
||||
{
|
||||
label: 'Sales',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
backgroundColor: 'rgba(230, 254, 249)',
|
||||
borderColor: tickColor,
|
||||
borderCapStyle: 'butt',
|
||||
borderDash: [],
|
||||
borderDashOffset: 0.0,
|
||||
borderJoinStyle: 'miter',
|
||||
pointBorderColor: tickColor,
|
||||
pointBackgroundColor: surfaceColor,
|
||||
pointBorderWidth: 1,
|
||||
pointHoverRadius: 5,
|
||||
pointHoverBackgroundColor: tickColor,
|
||||
pointHoverBorderColor: 'rgba(220,220,220,1)',
|
||||
pointHoverBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHitRadius: 10,
|
||||
data: props.invoices.map((invoice) => invoice / 100),
|
||||
},
|
||||
{
|
||||
label: 'Receipts',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
backgroundColor: 'rgba(230, 254, 249)',
|
||||
borderColor: 'rgb(2, 201, 156)',
|
||||
borderCapStyle: 'butt',
|
||||
borderDash: [],
|
||||
borderDashOffset: 0.0,
|
||||
borderJoinStyle: 'miter',
|
||||
pointBorderColor: 'rgb(2, 201, 156)',
|
||||
pointBackgroundColor: surfaceColor,
|
||||
pointBorderWidth: 1,
|
||||
pointHoverRadius: 5,
|
||||
pointHoverBackgroundColor: 'rgb(2, 201, 156)',
|
||||
pointHoverBorderColor: 'rgba(220,220,220,1)',
|
||||
pointHoverBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHitRadius: 10,
|
||||
data: props.receipts.map((receipt) => receipt / 100),
|
||||
},
|
||||
{
|
||||
label: 'Expenses',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
backgroundColor: 'rgba(245, 235, 242)',
|
||||
borderColor: 'rgb(255,0,0)',
|
||||
borderCapStyle: 'butt',
|
||||
borderDash: [],
|
||||
borderDashOffset: 0.0,
|
||||
borderJoinStyle: 'miter',
|
||||
pointBorderColor: 'rgb(255,0,0)',
|
||||
pointBackgroundColor: surfaceColor,
|
||||
pointBorderWidth: 1,
|
||||
pointHoverRadius: 5,
|
||||
pointHoverBackgroundColor: 'rgb(255,0,0)',
|
||||
pointHoverBorderColor: 'rgba(220,220,220,1)',
|
||||
pointHoverBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHitRadius: 10,
|
||||
data: props.expenses.map((expense) => expense / 100),
|
||||
},
|
||||
{
|
||||
label: 'Net Income',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
backgroundColor: 'rgba(236, 235, 249)',
|
||||
borderColor: 'rgba(88, 81, 216, 1)',
|
||||
borderCapStyle: 'butt',
|
||||
borderDash: [],
|
||||
borderDashOffset: 0.0,
|
||||
borderJoinStyle: 'miter',
|
||||
pointBorderColor: 'rgba(88, 81, 216, 1)',
|
||||
pointBackgroundColor: surfaceColor,
|
||||
pointBorderWidth: 1,
|
||||
pointHoverRadius: 5,
|
||||
pointHoverBackgroundColor: 'rgba(88, 81, 216, 1)',
|
||||
pointHoverBorderColor: 'rgba(220,220,220,1)',
|
||||
pointHoverBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHitRadius: 10,
|
||||
data: props.income.map((i) => i / 100),
|
||||
},
|
||||
]
|
||||
|
||||
const config: ChartConfiguration<'line', number[], string> = {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: tickColor },
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: tickColor },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
callbacks: {
|
||||
label: function (tooltipContext) {
|
||||
if (!utils) return ''
|
||||
return utils.formatMoney(
|
||||
Math.round(tooltipContext.parsed.y * 100),
|
||||
defaultCurrency.value
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
myLineChart = new Chart(context, config)
|
||||
})
|
||||
|
||||
function update(): void {
|
||||
if (!myLineChart) return
|
||||
myLineChart.data.labels = props.labels
|
||||
myLineChart.data.datasets[0].data = props.invoices.map(
|
||||
(invoice) => invoice / 100
|
||||
)
|
||||
myLineChart.data.datasets[1].data = props.receipts.map(
|
||||
(receipt) => receipt / 100
|
||||
)
|
||||
myLineChart.data.datasets[2].data = props.expenses.map(
|
||||
(expense) => expense / 100
|
||||
)
|
||||
myLineChart.data.datasets[3].data = props.income.map((i) => i / 100)
|
||||
myLineChart.update('none')
|
||||
}
|
||||
</script>
|
||||
1
resources/scripts/components/charts/index.ts
Normal file
1
resources/scripts/components/charts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LineChart } from './LineChart.vue'
|
||||
257
resources/scripts/components/editor/RichEditor.vue
Normal file
257
resources/scripts/components/editor/RichEditor.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<ContentPlaceholder v-if="contentLoading">
|
||||
<ContentPlaceholderBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
style="height: 200px"
|
||||
/>
|
||||
</ContentPlaceholder>
|
||||
<div
|
||||
v-else
|
||||
class="box-border w-full text-sm leading-8 text-left bg-surface border border-line-light rounded-xl shadow min-h-[200px] overflow-hidden"
|
||||
>
|
||||
<div v-if="editor" class="editor-content">
|
||||
<div class="flex justify-end p-2 border-b border-line-light 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-heading bg-surface rounded-xs md:h-9 md:w-9"
|
||||
>
|
||||
<EllipsisVerticalIcon class="w-6 h-6 text-body" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-wrap space-x-1">
|
||||
<button
|
||||
v-for="button in editorButtons"
|
||||
type="button"
|
||||
:key="button.name"
|
||||
class="p-1 rounded hover:bg-surface-tertiary"
|
||||
@click="button.action"
|
||||
>
|
||||
<component
|
||||
:is="button.icon"
|
||||
v-if="button.icon"
|
||||
class="w-4 h-4 text-body fill-current text-body"
|
||||
/>
|
||||
<span v-else-if="button.text" class="px-1 text-sm font-medium text-body">
|
||||
{{ button.text }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</BaseDropdown>
|
||||
</div>
|
||||
<div class="hidden p-2 border-b border-line-light md:flex">
|
||||
<div class="flex flex-wrap space-x-1">
|
||||
<button
|
||||
v-for="button in editorButtons"
|
||||
type="button"
|
||||
:key="button.name"
|
||||
class="p-1 rounded hover:bg-surface-tertiary"
|
||||
@click="button.action"
|
||||
>
|
||||
<component
|
||||
:is="button.icon"
|
||||
v-if="button.icon"
|
||||
class="w-4 h-4 text-body fill-current text-body"
|
||||
/>
|
||||
<span v-else-if="button.text" class="px-1 text-sm font-medium text-body">
|
||||
{{ button.text }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<editor-content
|
||||
:editor="editor"
|
||||
class="box-border relative w-full text-sm leading-8 text-left editor__content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, watch, markRaw } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline'
|
||||
import {
|
||||
BoldIcon,
|
||||
CodingIcon,
|
||||
ItalicIcon,
|
||||
ListIcon,
|
||||
ListUlIcon,
|
||||
ParagraphIcon,
|
||||
QuoteIcon,
|
||||
StrikethroughIcon,
|
||||
UndoIcon,
|
||||
RedoIcon,
|
||||
CodeBlockIcon,
|
||||
MenuCenterIcon,
|
||||
} from '@/scripts/components/editor/icons/index'
|
||||
import {
|
||||
Bars3BottomLeftIcon,
|
||||
Bars3BottomRightIcon,
|
||||
Bars3Icon,
|
||||
LinkIcon,
|
||||
} from '@heroicons/vue/24/solid'
|
||||
import { ContentPlaceholder, ContentPlaceholderBox } from '../layout'
|
||||
|
||||
interface EditorButton {
|
||||
name: string
|
||||
icon?: Component
|
||||
text?: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
contentLoading?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
contentLoading: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: { openOnClick: false },
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
alignments: ['left', 'right', 'center', 'justify'],
|
||||
}),
|
||||
],
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
emit('update:modelValue', ed.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
const editorButtons = ref<EditorButton[]>([
|
||||
{ name: 'bold', icon: markRaw(BoldIcon) as Component, action: () => editor.value?.chain().focus().toggleBold().run() },
|
||||
{ name: 'italic', icon: markRaw(ItalicIcon) as Component, action: () => editor.value?.chain().focus().toggleItalic().run() },
|
||||
{ name: 'strike', icon: markRaw(StrikethroughIcon) as Component, action: () => editor.value?.chain().focus().toggleStrike().run() },
|
||||
{ name: 'code', icon: markRaw(CodingIcon) as Component, action: () => editor.value?.chain().focus().toggleCode().run() },
|
||||
{ name: 'paragraph', icon: markRaw(ParagraphIcon) as Component, action: () => editor.value?.chain().focus().setParagraph().run() },
|
||||
{ name: 'h1', text: 'H1', action: () => editor.value?.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ name: 'h2', text: 'H2', action: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ name: 'h3', text: 'H3', action: () => editor.value?.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ name: 'bulletList', icon: markRaw(ListUlIcon) as Component, action: () => editor.value?.chain().focus().toggleBulletList().run() },
|
||||
{ name: 'orderedList', icon: markRaw(ListIcon) as Component, action: () => editor.value?.chain().focus().toggleOrderedList().run() },
|
||||
{ name: 'blockquote', icon: markRaw(QuoteIcon) as Component, action: () => editor.value?.chain().focus().toggleBlockquote().run() },
|
||||
{ name: 'codeBlock', icon: markRaw(CodeBlockIcon) as Component, action: () => editor.value?.chain().focus().toggleCodeBlock().run() },
|
||||
{ name: 'undo', icon: markRaw(UndoIcon) as Component, action: () => editor.value?.chain().focus().undo().run() },
|
||||
{ name: 'redo', icon: markRaw(RedoIcon) as Component, action: () => editor.value?.chain().focus().redo().run() },
|
||||
{ name: 'alignLeft', icon: markRaw(Bars3BottomLeftIcon) as Component, action: () => editor.value?.chain().focus().setTextAlign('left').run() },
|
||||
{ name: 'alignRight', icon: markRaw(Bars3BottomRightIcon) as Component, action: () => editor.value?.chain().focus().setTextAlign('right').run() },
|
||||
{ name: 'alignJustify', icon: markRaw(Bars3Icon) as Component, action: () => editor.value?.chain().focus().setTextAlign('justify').run() },
|
||||
{ name: 'alignCenter', icon: markRaw(MenuCenterIcon) as Component, action: () => editor.value?.chain().focus().setTextAlign('center').run() },
|
||||
{
|
||||
name: 'addLink',
|
||||
icon: markRaw(LinkIcon) as Component,
|
||||
action: () => {
|
||||
const url = window.prompt('URL')
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setLink({ href: url }).run()
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue: string) => {
|
||||
if (editor.value && newValue !== editor.value.getHTML()) {
|
||||
editor.value.commands.setContent(newValue, false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (editor.value) {
|
||||
editor.value.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@reference "../../../css/invoiceshelf.css";
|
||||
|
||||
.ProseMirror {
|
||||
min-height: 200px;
|
||||
padding: 8px 12px;
|
||||
outline: none;
|
||||
@apply rounded-xl 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 var(--color-line-default);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary-500);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror:focus {
|
||||
@apply border border-primary-400 ring-primary-400;
|
||||
}
|
||||
</style>
|
||||
7
resources/scripts/components/editor/icons/BoldIcon.vue
Normal file
7
resources/scripts/components/editor/icons/BoldIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.194 10.962A6.271 6.271 0 0012.844.248H4.3a1.25 1.25 0 000 2.5h1.013a.25.25 0 01.25.25V21a.25.25 0 01-.25.25H4.3a1.25 1.25 0 100 2.5h9.963a6.742 6.742 0 002.93-12.786zm-4.35-8.214a3.762 3.762 0 010 7.523H8.313a.25.25 0 01-.25-.25V3a.25.25 0 01.25-.25zm1.42 18.5H8.313a.25.25 0 01-.25-.25v-7.977a.25.25 0 01.25-.25h5.951a4.239 4.239 0 010 8.477z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -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>
|
||||
7
resources/scripts/components/editor/icons/CodingIcon.vue
Normal file
7
resources/scripts/components/editor/icons/CodingIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M9.147 21.552a1.244 1.244 0 01-.895-.378L.84 13.561a2.257 2.257 0 010-3.125l7.412-7.613a1.25 1.25 0 011.791 1.744l-6.9 7.083a.5.5 0 000 .7l6.9 7.082a1.25 1.25 0 01-.9 2.122zm5.707 0a1.25 1.25 0 01-.9-2.122l6.9-7.083a.5.5 0 000-.7l-6.9-7.082a1.25 1.25 0 011.791-1.744l7.411 7.612a2.257 2.257 0 010 3.125l-7.412 7.614a1.244 1.244 0 01-.89.38zm6.514-9.373z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
7
resources/scripts/components/editor/icons/ItalicIcon.vue
Normal file
7
resources/scripts/components/editor/icons/ItalicIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.5.248h-7.637a1.25 1.25 0 000 2.5h1.086a.25.25 0 01.211.384L4.78 21.017a.5.5 0 01-.422.231H1.5a1.25 1.25 0 000 2.5h7.637a1.25 1.25 0 000-2.5H8.051a.25.25 0 01-.211-.384L19.22 2.98a.5.5 0 01.422-.232H22.5a1.25 1.25 0 000-2.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
7
resources/scripts/components/editor/icons/ListIcon.vue
Normal file
7
resources/scripts/components/editor/icons/ListIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M7.75 4.5h15a1 1 0 000-2h-15a1 1 0 000 2zm15 6.5h-15a1 1 0 100 2h15a1 1 0 000-2zm0 8.5h-15a1 1 0 000 2h15a1 1 0 000-2zM2.212 17.248a2 2 0 00-1.933 1.484.75.75 0 101.45.386.5.5 0 11.483.63.75.75 0 100 1.5.5.5 0 11-.482.635.75.75 0 10-1.445.4 2 2 0 103.589-1.648.251.251 0 010-.278 2 2 0 00-1.662-3.111zm2.038-6.5a2 2 0 00-4 0 .75.75 0 001.5 0 .5.5 0 011 0 1.031 1.031 0 01-.227.645L.414 14.029A.75.75 0 001 15.248h2.5a.75.75 0 000-1.5h-.419a.249.249 0 01-.195-.406L3.7 12.33a2.544 2.544 0 00.55-1.582zM4 5.248h-.25A.25.25 0 013.5 5V1.623A1.377 1.377 0 002.125.248H1.5a.75.75 0 000 1.5h.25A.25.25 0 012 2v3a.25.25 0 01-.25.25H1.5a.75.75 0 000 1.5H4a.75.75 0 000-1.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
10
resources/scripts/components/editor/icons/ListUlIcon.vue
Normal file
10
resources/scripts/components/editor/icons/ListUlIcon.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="2.5" cy="3.998" r="2.5"></circle>
|
||||
<path d="M8.5 5H23a1 1 0 000-2H8.5a1 1 0 000 2z"></path>
|
||||
<circle cx="2.5" cy="11.998" r="2.5"></circle>
|
||||
<path d="M23 11H8.5a1 1 0 000 2H23a1 1 0 000-2z"></path>
|
||||
<circle cx="2.5" cy="19.998" r="2.5"></circle>
|
||||
<path d="M23 19H8.5a1 1 0 000 2H23a1 1 0 000-2z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M3.75 5.25h16.5a.75.75 0 1 1 0 1.5H3.75a.75.75 0 0 1 0-1.5zm4 4h8.5a.75.75 0 1 1 0 1.5h-8.5a.75.75 0 1 1 0-1.5zm-4 4h16.5a.75.75 0 1 1 0 1.5H3.75a.75.75 0 1 1 0-1.5zm4 4h8.5a.75.75 0 1 1 0 1.5h-8.5a.75.75 0 1 1 0-1.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -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>
|
||||
7
resources/scripts/components/editor/icons/QuoteIcon.vue
Normal file
7
resources/scripts/components/editor/icons/QuoteIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18.559 3.932a4.942 4.942 0 100 9.883 4.609 4.609 0 001.115-.141.25.25 0 01.276.368 6.83 6.83 0 01-5.878 3.523 1.25 1.25 0 000 2.5 9.71 9.71 0 009.428-9.95V8.873a4.947 4.947 0 00-4.941-4.941zm-12.323 0a4.942 4.942 0 000 9.883 4.6 4.6 0 001.115-.141.25.25 0 01.277.368 6.83 6.83 0 01-5.878 3.523 1.25 1.25 0 000 2.5 9.711 9.711 0 009.428-9.95V8.873a4.947 4.947 0 00-4.942-4.941z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
7
resources/scripts/components/editor/icons/RedoIcon.vue
Normal file
7
resources/scripts/components/editor/icons/RedoIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.608.161a.5.5 0 00-.545.108L19.472 2.86a.25.25 0 01-.292.045 12.537 12.537 0 00-12.966.865A12.259 12.259 0 006.1 23.632a1.25 1.25 0 001.476-2.018 9.759 9.759 0 01.091-15.809 10 10 0 019.466-1.1.25.25 0 01.084.409l-1.85 1.85a.5.5 0 00.354.853h6.7a.5.5 0 00.5-.5V.623a.5.5 0 00-.313-.462z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -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>
|
||||
7
resources/scripts/components/editor/icons/UndoIcon.vue
Normal file
7
resources/scripts/components/editor/icons/UndoIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.786 3.77a12.542 12.542 0 00-12.965-.865.249.249 0 01-.292-.045L1.937.269A.507.507 0 001.392.16a.5.5 0 00-.308.462v6.7a.5.5 0 00.5.5h6.7a.5.5 0 00.354-.854L6.783 5.115a.253.253 0 01-.068-.228.249.249 0 01.152-.181 10 10 0 019.466 1.1 9.759 9.759 0 01.094 15.809 1.25 1.25 0 001.473 2.016 12.122 12.122 0 005.013-9.961 12.125 12.125 0 00-5.127-9.9z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
29
resources/scripts/components/editor/icons/index.ts
Normal file
29
resources/scripts/components/editor/icons/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import UnderlineIcon from './UnderlineIcon.vue'
|
||||
import BoldIcon from './BoldIcon.vue'
|
||||
import CodingIcon from './CodingIcon.vue'
|
||||
import ItalicIcon from './ItalicIcon.vue'
|
||||
import ListIcon from './ListIcon.vue'
|
||||
import ListUlIcon from './ListUlIcon.vue'
|
||||
import ParagraphIcon from './ParagraphIcon.vue'
|
||||
import QuoteIcon from './QuoteIcon.vue'
|
||||
import StrikethroughIcon from './StrikethroughIcon.vue'
|
||||
import UndoIcon from './UndoIcon.vue'
|
||||
import RedoIcon from './RedoIcon.vue'
|
||||
import CodeBlockIcon from './CodeBlockIcon.vue'
|
||||
import MenuCenterIcon from './MenuCenterIcon.vue'
|
||||
|
||||
export {
|
||||
UnderlineIcon,
|
||||
BoldIcon,
|
||||
CodingIcon,
|
||||
ItalicIcon,
|
||||
ListIcon,
|
||||
ListUlIcon,
|
||||
ParagraphIcon,
|
||||
QuoteIcon,
|
||||
StrikethroughIcon,
|
||||
UndoIcon,
|
||||
RedoIcon,
|
||||
CodeBlockIcon,
|
||||
MenuCenterIcon,
|
||||
}
|
||||
1
resources/scripts/components/editor/index.ts
Normal file
1
resources/scripts/components/editor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as RichEditor } from './RichEditor.vue'
|
||||
24
resources/scripts/components/form/FormGrid.vue
Normal file
24
resources/scripts/components/form/FormGrid.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
layout?: 'one-column' | 'two-column'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'two-column',
|
||||
})
|
||||
|
||||
const formLayout = computed<string>(() => {
|
||||
if (props.layout === 'two-column') {
|
||||
return 'grid gap-y-6 gap-x-4 grid-cols-1 md:grid-cols-2'
|
||||
}
|
||||
return 'grid gap-y-6 gap-x-4 grid-cols-1'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="formLayout">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
96
resources/scripts/components/form/FormGroup.vue
Normal file
96
resources/scripts/components/form/FormGroup.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div :class="containerClasses" class="relative w-full text-left">
|
||||
<ContentPlaceholder v-if="contentLoading">
|
||||
<ContentPlaceholderText :lines="1" :class="contentLoadClass" />
|
||||
</ContentPlaceholder>
|
||||
<label
|
||||
v-else-if="label"
|
||||
:class="labelClasses"
|
||||
class="
|
||||
flex
|
||||
text-sm
|
||||
not-italic
|
||||
items-center
|
||||
font-medium
|
||||
text-heading
|
||||
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-subtle cursor-pointer hover:text-body"
|
||||
/>
|
||||
</label>
|
||||
<div :class="inputContainerClasses">
|
||||
<slot></slot>
|
||||
<span v-if="helpText" class="text-muted 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 lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { ContentPlaceholder, ContentPlaceholderText } from '../layout'
|
||||
|
||||
interface Props {
|
||||
contentLoading?: boolean
|
||||
contentLoadClass?: string
|
||||
label?: string
|
||||
variant?: 'vertical' | 'horizontal'
|
||||
error?: string | boolean | null
|
||||
required?: boolean
|
||||
tooltip?: string | null
|
||||
helpText?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentLoading: false,
|
||||
contentLoadClass: 'w-16 h-5',
|
||||
label: '',
|
||||
variant: 'vertical',
|
||||
error: null,
|
||||
required: false,
|
||||
tooltip: null,
|
||||
helpText: null,
|
||||
})
|
||||
|
||||
const containerClasses = computed<string>(() => {
|
||||
if (props.variant === 'horizontal') {
|
||||
return 'grid md:grid-cols-12 items-center'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const labelClasses = computed<string>(() => {
|
||||
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<string>(() => {
|
||||
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<boolean>(() => {
|
||||
return !!slots.labelRight
|
||||
})
|
||||
</script>
|
||||
65
resources/scripts/components/form/SwitchSection.vue
Normal file
65
resources/scripts/components/form/SwitchSection.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<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-heading font-medium"
|
||||
passive
|
||||
>
|
||||
{{ title }}
|
||||
</SwitchLabel>
|
||||
<SwitchDescription class="text-sm text-muted">
|
||||
{{ description }}
|
||||
</SwitchDescription>
|
||||
</div>
|
||||
<Switch
|
||||
:disabled="disabled"
|
||||
:model-value="modelValue"
|
||||
:class="[
|
||||
modelValue ? 'bg-primary-500' : 'bg-surface-muted',
|
||||
'ml-4 relative inline-flex shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
|
||||
]"
|
||||
@update:modelValue="onUpdate"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
modelValue ? 'translate-x-5' : 'translate-x-0',
|
||||
'inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition ease-in-out duration-200',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
} from '@headlessui/vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
modelValue?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
modelValue: false,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function onUpdate(value: boolean): void {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
3
resources/scripts/components/form/index.ts
Normal file
3
resources/scripts/components/form/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FormGroup } from './FormGroup.vue'
|
||||
export { default as FormGrid } from './FormGrid.vue'
|
||||
export { default as SwitchSection } from './SwitchSection.vue'
|
||||
125
resources/scripts/components/icons/AstronautIcon.vue
Normal file
125
resources/scripts/components/icons/AstronautIcon.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<svg
|
||||
width="125"
|
||||
height="110"
|
||||
viewBox="0 0 125 110"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M46.8031 84.4643C46.8031 88.8034 43.3104 92.3215 39.0026 92.3215C34.6948 92.3215 31.2021 88.8034 31.2021 84.4643C31.2021 80.1252 34.6948 76.6072 39.0026 76.6072C43.3104 76.6072 46.8031 80.1252 46.8031 84.4643Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M60.4536 110H64.3539V72.6785H60.4536V110Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M85.8055 76.6072H70.2045C69.1319 76.6072 68.2544 77.4911 68.2544 78.5715V82.5C68.2544 83.5804 69.1319 84.4643 70.2045 84.4643H85.8055C86.878 84.4643 87.7556 83.5804 87.7556 82.5V78.5715C87.7556 77.4911 86.878 76.6072 85.8055 76.6072ZM70.2045 82.5H85.8055V78.5715H70.2045V82.5Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M91.6556 1.96429C94.8811 1.96429 97.506 4.60821 97.506 7.85714V19.6429H83.8181L85.308 21.6071H99.4561V7.85714C99.4561 3.53571 95.9459 0 91.6556 0H33.152C28.8618 0 25.3516 3.53571 25.3516 7.85714V21.6071H39.3203L40.8745 19.6429H27.3017V7.85714C27.3017 4.60821 29.9265 1.96429 33.152 1.96429H91.6556Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M122.858 92.3213H117.007C115.935 92.3213 115.057 93.2052 115.057 94.2856V102.143C115.057 103.223 115.935 104.107 117.007 104.107H122.858C123.93 104.107 124.808 103.223 124.808 102.143V94.2856C124.808 93.2052 123.93 92.3213 122.858 92.3213ZM117.007 102.143H122.858V94.2856H117.007V102.143Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M103.356 43.2142V70.7142H21.4511V43.2142H26.1821V41.2498H19.501V72.6783H105.306V41.2498H98.3541L98.2839 43.2142H103.356Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M101.406 21.6071C104.632 21.6071 107.257 24.251 107.257 27.5V41.25H98.2257L98.0853 43.2142H109.207V27.5C109.207 23.1609 105.714 19.6428 101.406 19.6428H83.8182L85.0878 21.6071H101.406ZM40.8746 19.6428H23.4016C19.0937 19.6428 15.6011 23.1609 15.6011 27.5V43.2142H26.1961L26.3365 41.25H17.5512V27.5C17.5512 24.251 20.1761 21.6071 23.4016 21.6071H39.3204L40.8746 19.6428Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M62.4041 9.82153C45.1709 9.82153 31.2021 23.8917 31.2021 41.2501C31.2021 58.6085 45.1709 72.6787 62.4041 72.6787C79.6373 72.6787 93.606 58.6085 93.606 41.2501C93.606 23.8917 79.6373 9.82153 62.4041 9.82153ZM62.4041 11.7858C78.5335 11.7858 91.6559 25.0035 91.6559 41.2501C91.6559 57.4967 78.5335 70.7144 62.4041 70.7144C46.2746 70.7144 33.1523 57.4967 33.1523 41.2501C33.1523 25.0035 46.2746 11.7858 62.4041 11.7858Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M62.4041 19.6428C45.1709 19.6428 31.2021 23.8916 31.2021 41.25C31.2021 58.6084 45.1709 66.7857 62.4041 66.7857C79.6373 66.7857 93.606 58.6084 93.606 41.25C93.606 23.8916 79.6373 19.6428 62.4041 19.6428ZM62.4041 21.6071C82.6346 21.6071 91.6559 27.665 91.6559 41.25C91.6559 56.0096 80.7216 64.8214 62.4041 64.8214C44.0866 64.8214 33.1523 56.0096 33.1523 41.25C33.1523 27.665 42.1735 21.6071 62.4041 21.6071Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M101.406 70.7144H23.4014C10.478 70.7144 0 81.2685 0 94.2858V110H124.808V94.2858C124.808 81.2685 114.33 70.7144 101.406 70.7144ZM101.406 72.6786C113.234 72.6786 122.858 82.3724 122.858 94.2858V108.036H1.95012V94.2858C1.95012 82.3724 11.574 72.6786 23.4014 72.6786H101.406Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M33.152 33.3928H29.2518C27.0969 33.3928 25.3516 35.1509 25.3516 37.3214V45.1785C25.3516 47.3491 27.0969 49.1071 29.2518 49.1071H33.152V33.3928ZM31.2019 35.3571V47.1428H29.2518C28.1773 47.1428 27.3017 46.2609 27.3017 45.1785V37.3214C27.3017 36.2391 28.1773 35.3571 29.2518 35.3571H31.2019Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M95.556 33.3928H91.6558V49.1071H95.556C97.7109 49.1071 99.4562 47.3491 99.4562 45.1785V37.3214C99.4562 35.1509 97.7109 33.3928 95.556 33.3928ZM95.556 35.3571C96.6305 35.3571 97.5061 36.2391 97.5061 37.3214V45.1785C97.5061 46.2609 96.6305 47.1428 95.556 47.1428H93.6059V35.3571H95.556Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M94.581 15.7144C94.0447 15.7144 93.606 16.1563 93.606 16.6965V34.3751C93.606 34.9152 94.0447 35.3572 94.581 35.3572C95.1173 35.3572 95.5561 34.9152 95.5561 34.3751V16.6965C95.5561 16.1563 95.1173 15.7144 94.581 15.7144Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M38.0273 41.2499C37.4891 41.2499 37.0522 40.8099 37.0522 40.2678C37.0522 33.3142 44.1409 25.5356 53.6283 25.5356C54.1665 25.5356 54.6033 25.9756 54.6033 26.5178C54.6033 27.0599 54.1665 27.4999 53.6283 27.4999C45.2564 27.4999 39.0024 34.2414 39.0024 40.2678C39.0024 40.8099 38.5655 41.2499 38.0273 41.2499Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M97.5059 110H99.456V72.6785H97.5059V110Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M25.3516 110H27.3017V72.6785H25.3516V110Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="124.808" height="110" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
primaryFillColor?: string
|
||||
secondaryFillColor?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
primaryFillColor: 'fill-primary-500',
|
||||
secondaryFillColor: 'fill-gray-600',
|
||||
})
|
||||
</script>
|
||||
19
resources/scripts/components/icons/DragIcon.vue
Normal file
19
resources/scripts/components/icons/DragIcon.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="fas"
|
||||
data-icon="grip-vertical"
|
||||
class="svg-inline--fa fa-grip-vertical fa-w-10"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 320 512"
|
||||
width="15"
|
||||
height="15"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M96 32H32C14.33 32 0 46.33 0 64v64c0 17.67 14.33 32 32 32h64c17.67 0 32-14.33 32-32V64c0-17.67-14.33-32-32-32zm0 160H32c-17.67 0-32 14.33-32 32v64c0 17.67 14.33 32 32 32h64c17.67 0 32-14.33 32-32v-64c0-17.67-14.33-32-32-32zm0 160H32c-17.67 0-32 14.33-32 32v64c0 17.67 14.33 32 32 32h64c17.67 0 32-14.33 32-32v-64c0-17.67-14.33-32-32-32zM288 32h-64c-17.67 0-32 14.33-32 32v64c0 17.67 14.33 32 32 32h64c17.67 0 32-14.33 32-32V64c0-17.67-14.33-32-32-32zm0 160h-64c-17.67 0-32 14.33-32 32v64c0 17.67 14.33 32 32 32h64c17.67 0 32-14.33 32-32v-64c0-17.67-14.33-32-32-32zm0 160h-64c-17.67 0-32 14.33-32 32v64c0 17.67 14.33 32 32 32h64c17.67 0 32-14.33 32-32v-64c0-17.67-14.33-32-32-32z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
17
resources/scripts/components/icons/LoadingIcon.vue
Normal file
17
resources/scripts/components/icons/LoadingIcon.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
45
resources/scripts/components/icons/MainLogo.vue
Normal file
45
resources/scripts/components/icons/MainLogo.vue
Normal file
File diff suppressed because one or more lines are too long
113
resources/scripts/components/icons/SatelliteIcon.vue
Normal file
113
resources/scripts/components/icons/SatelliteIcon.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<svg
|
||||
width="110"
|
||||
height="110"
|
||||
viewBox="0 0 110 110"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.76398 22.9512L4.54883 21.7361L21.7363 4.54858L22.9515 5.76374L5.76398 22.9512Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M88.264 105.451L87.0488 104.236L104.236 87.0486L105.451 88.2637L88.264 105.451Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M29.8265 81.3887L28.6113 80.1736L38.9238 69.8611L40.139 71.0762L29.8265 81.3887Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M30.9375 81.6406C30.9375 83.0637 29.7825 84.2188 28.3594 84.2188C26.9362 84.2188 25.7812 83.0637 25.7812 81.6406C25.7812 80.2175 26.9362 79.0625 28.3594 79.0625C29.7825 79.0625 30.9375 80.2175 30.9375 81.6406Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M77.3435 61.5801C76.4635 61.5801 75.5835 61.9152 74.9132 62.5873L62.5863 74.9124C61.244 76.2548 61.244 78.4324 62.5863 79.7748L92.8123 110.001L110 92.8132L79.7738 62.5873C79.1035 61.9152 78.2235 61.5801 77.3435 61.5801ZM77.3435 63.2988C77.8024 63.2988 78.2338 63.4776 78.5587 63.8024L107.569 92.8132L92.8123 107.569L63.8015 78.5596C63.4767 78.2348 63.2979 77.8034 63.2979 77.3445C63.2979 76.8838 63.4767 76.4524 63.8015 76.1276L76.1284 63.8024C76.4532 63.4776 76.8846 63.2988 77.3435 63.2988Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.1875 0L0 17.1875L30.2259 47.4134C30.8963 48.0838 31.7763 48.4206 32.6562 48.4206C33.5363 48.4206 34.4162 48.0838 35.0866 47.4134L47.4134 35.0866C48.7558 33.7442 48.7558 31.5683 47.4134 30.2259L17.1875 0ZM17.1875 2.43031L46.1983 31.4411C46.5231 31.7659 46.7019 32.1973 46.7019 32.6562C46.7019 33.1152 46.5231 33.5466 46.1983 33.8714L33.8714 46.1983C33.5466 46.5231 33.1152 46.7019 32.6562 46.7019C32.1973 46.7019 31.7659 46.5231 31.4411 46.1983L2.43031 17.1875L17.1875 2.43031Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M60.156 28.9238C59.276 28.9238 58.396 29.259 57.7257 29.931L29.9301 57.7249C28.5878 59.0673 28.5878 61.2449 29.9301 62.5873L47.4132 80.0687C48.0835 80.7407 48.9635 81.0759 49.8435 81.0759C50.7235 81.0759 51.6035 80.7407 52.2738 80.0687L80.0695 52.2748C81.4118 50.9324 81.4118 48.7548 80.0695 47.4124L62.5863 29.931C61.916 29.259 61.036 28.9238 60.156 28.9238ZM60.156 30.6426C60.6149 30.6426 61.0463 30.8213 61.3712 31.1462L78.8543 48.6276C79.1792 48.9524 79.3579 49.3838 79.3579 49.8445C79.3579 50.3034 79.1792 50.7348 78.8543 51.0596L51.0587 78.8535C50.7338 79.1784 50.3024 79.3571 49.8435 79.3571C49.3846 79.3571 48.9532 79.1784 48.6284 78.8535L31.1453 61.3721C30.8204 61.0473 30.6417 60.6159 30.6417 60.157C30.6417 59.6963 30.8204 59.2649 31.1453 58.9401L58.9409 31.1462C59.2657 30.8213 59.6971 30.6426 60.156 30.6426Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M71.0765 40.1387L69.8613 38.9236L72.4395 36.3455L73.6546 37.5606L71.0765 40.1387Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M72.9858 24.8608C69.6291 28.2176 69.6291 33.6574 72.9858 37.0141C74.6633 38.6916 76.8633 39.5321 79.0633 39.5321C81.2616 39.5321 83.4616 38.6916 85.1391 37.0141L72.9858 24.8608ZM73.1388 27.4441L82.5558 36.8612C81.5091 37.4816 80.3111 37.8133 79.0633 37.8133C77.226 37.8133 75.5003 37.0966 74.201 35.799C72.9033 34.4996 72.1883 32.774 72.1883 30.9383C72.1883 29.6888 72.5183 28.4908 73.1388 27.4441Z"
|
||||
:class="secondaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M86.1459 32.0051C85.9259 32.0051 85.7059 31.9209 85.5374 31.7542C85.2023 31.4173 85.2023 30.8742 85.5374 30.5373C86.3504 29.7261 86.7973 28.6467 86.7973 27.5003C86.7973 26.3522 86.3504 25.2728 85.5374 24.4615C83.9149 22.839 81.0859 22.839 79.4616 24.4615C79.1265 24.7984 78.5834 24.7984 78.2465 24.4615C77.9113 24.1264 77.9113 23.5833 78.2465 23.2464C80.5187 20.9742 84.4821 20.9742 86.7543 23.2464C87.8904 24.3825 88.516 25.8933 88.516 27.5003C88.516 29.1073 87.8904 30.6181 86.7543 31.7542C86.5859 31.9209 86.3659 32.0051 86.1459 32.0051Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M89.792 35.6514C89.572 35.6514 89.352 35.5672 89.1836 35.4004C88.8484 35.0636 88.8484 34.5204 89.1836 34.1836C90.9711 32.3978 91.9525 30.0259 91.9525 27.4994C91.9525 24.9745 90.9711 22.6009 89.1836 20.8151C87.3978 19.0294 85.0259 18.0462 82.4994 18.0462C79.9745 18.0462 77.6009 19.0294 75.8152 20.8151C75.48 21.1503 74.9352 21.1503 74.6 20.8151C74.2648 20.48 74.2648 19.9351 74.6 19.6C78.9553 15.2447 86.0434 15.2447 90.4005 19.6C94.7558 23.9553 94.7558 31.0434 90.4005 35.4004C90.232 35.5672 90.012 35.6514 89.792 35.6514Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M93.4379 39.297C93.2179 39.297 92.9979 39.2128 92.8295 39.0461C92.4944 38.7092 92.4944 38.1661 92.8295 37.8292C95.5898 35.0706 97.1092 31.4028 97.1092 27.4995C97.1092 23.5979 95.5898 19.9284 92.8295 17.1698C90.0709 14.4112 86.4031 12.8901 82.4998 12.8901C78.5983 12.8901 74.9287 14.4112 72.1701 17.1698C71.835 17.505 71.2901 17.505 70.955 17.1698C70.6198 16.8347 70.6198 16.2898 70.955 15.9547C74.0384 12.8712 78.1394 11.1714 82.4998 11.1714C86.862 11.1714 90.9612 12.8712 94.0464 15.9547C97.1298 19.0381 98.8279 23.139 98.8279 27.4995C98.8279 31.8617 97.1298 35.9609 94.0464 39.0461C93.8779 39.2128 93.6579 39.297 93.4379 39.297Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M39.7832 40.9981L8.8457 10.0606L10.0609 8.84546L40.9984 39.783L39.7832 40.9981Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M99.9395 101.154L69.002 70.2169L70.2171 69.0017L101.155 99.9392L99.9395 101.154Z"
|
||||
:class="primaryFillColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="110" height="110" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
primaryFillColor?: string
|
||||
secondaryFillColor?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
primaryFillColor: 'fill-primary-500',
|
||||
secondaryFillColor: 'fill-gray-600',
|
||||
})
|
||||
</script>
|
||||
22
resources/scripts/components/icons/SpinnerIcon.vue
Normal file
22
resources/scripts/components/icons/SpinnerIcon.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<svg
|
||||
class="animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="0 0 50 50"
|
||||
:class="colorClass"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="25" cy="25" r="25" fill="#EAF1FB" />
|
||||
<path
|
||||
d="M28.2656 23.0547C27.3021 24.0182 26.1302 24.5 24.75 24.5C23.3698 24.5 22.1849 24.0182 21.1953 23.0547C20.2318 22.0651 19.75 20.8802 19.75 19.5C19.75 18.1198 20.2318 16.9479 21.1953 15.9844C22.1849 14.9948 23.3698 14.5 24.75 14.5C26.1302 14.5 27.3021 14.9948 28.2656 15.9844C29.2552 16.9479 29.75 18.1198 29.75 19.5C29.75 20.8802 29.2552 22.0651 28.2656 23.0547ZM28.2656 25.75C29.6979 25.75 30.9219 26.2708 31.9375 27.3125C32.9792 28.3281 33.5 29.5521 33.5 30.9844V32.625C33.5 33.1458 33.3177 33.5885 32.9531 33.9531C32.5885 34.3177 32.1458 34.5 31.625 34.5H17.875C17.3542 34.5 16.9115 34.3177 16.5469 33.9531C16.1823 33.5885 16 33.1458 16 32.625V30.9844C16 29.5521 16.5078 28.3281 17.5234 27.3125C18.5651 26.2708 19.8021 25.75 21.2344 25.75H21.8984C22.8099 26.1667 23.7604 26.375 24.75 26.375C25.7396 26.375 26.6901 26.1667 27.6016 25.75H28.2656Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
colorClass?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
colorClass: 'text-primary-500',
|
||||
})
|
||||
</script>
|
||||
15
resources/scripts/components/icons/dashboard/DollarIcon.vue
Normal file
15
resources/scripts/components/icons/dashboard/DollarIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="25" cy="25" r="25" fill="#FDE4E5" />
|
||||
<path
|
||||
d="M27.2031 23.6016C28.349 23.9401 29.2083 24.6562 29.7812 25.75C30.3802 26.8438 30.4714 27.9766 30.0547 29.1484C29.7422 30.0078 29.2083 30.6979 28.4531 31.2188C27.6979 31.7135 26.8516 31.974 25.9141 32V33.875C25.9141 34.0573 25.849 34.2005 25.7188 34.3047C25.6146 34.4349 25.4714 34.5 25.2891 34.5H24.0391C23.8568 34.5 23.7005 34.4349 23.5703 34.3047C23.4661 34.2005 23.4141 34.0573 23.4141 33.875V32C22.1641 32 21.0443 31.6094 20.0547 30.8281C19.8984 30.6979 19.8073 30.5417 19.7812 30.3594C19.7552 30.1771 19.8203 30.0208 19.9766 29.8906L21.3047 28.5625C21.5651 28.3281 21.8255 28.3021 22.0859 28.4844C22.4766 28.7448 22.9193 28.875 23.4141 28.875H25.9922C26.3307 28.875 26.6042 28.7708 26.8125 28.5625C27.0469 28.3281 27.1641 28.0417 27.1641 27.7031C27.1641 27.1302 26.8906 26.7656 26.3438 26.6094L22.3203 25.4375C21.4349 25.1771 20.6927 24.7083 20.0938 24.0312C19.4948 23.3542 19.1432 22.5729 19.0391 21.6875C18.9349 20.4115 19.2995 19.3177 20.1328 18.4062C20.9922 17.4688 22.0599 17 23.3359 17H23.4141V15.125C23.4141 14.9427 23.4661 14.7995 23.5703 14.6953C23.7005 14.5651 23.8568 14.5 24.0391 14.5H25.2891C25.4714 14.5 25.6146 14.5651 25.7188 14.6953C25.849 14.7995 25.9141 14.9427 25.9141 15.125V17C27.1641 17 28.2839 17.3906 29.2734 18.1719C29.4297 18.3021 29.5208 18.4583 29.5469 18.6406C29.5729 18.8229 29.5078 18.9792 29.3516 19.1094L28.0234 20.4375C27.763 20.6719 27.5026 20.6979 27.2422 20.5156C26.8516 20.2552 26.4089 20.125 25.9141 20.125H23.3359C22.9974 20.125 22.7109 20.2422 22.4766 20.4766C22.2682 20.6849 22.1641 20.9583 22.1641 21.2969C22.1641 21.5312 22.2422 21.7526 22.3984 21.9609C22.5547 22.1693 22.75 22.3125 22.9844 22.3906L27.2031 23.6016Z"
|
||||
fill="#FB7178"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:class="colorClass"
|
||||
>
|
||||
<circle cx="25" cy="25" r="25" fill="#EAF1FB" />
|
||||
<path
|
||||
d="M26.75 19.8125C26.75 20.0729 26.8411 20.2943 27.0234 20.4766C27.2057 20.6589 27.4271 20.75 27.6875 20.75H33V33.5625C33 33.8229 32.9089 34.0443 32.7266 34.2266C32.5443 34.4089 32.3229 34.5 32.0625 34.5H18.9375C18.6771 34.5 18.4557 34.4089 18.2734 34.2266C18.0911 34.0443 18 33.8229 18 33.5625V15.4375C18 15.1771 18.0911 14.9557 18.2734 14.7734C18.4557 14.5911 18.6771 14.5 18.9375 14.5H26.75V19.8125ZM33 19.2656V19.5H28V14.5H28.2344C28.4948 14.5 28.7161 14.5911 28.8984 14.7734L32.7266 18.6016C32.9089 18.7839 33 19.0052 33 19.2656Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
colorClass?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
colorClass: 'text-primary-500',
|
||||
})
|
||||
</script>
|
||||
26
resources/scripts/components/icons/dashboard/InvoiceIcon.vue
Normal file
26
resources/scripts/components/icons/dashboard/InvoiceIcon.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="0 0 50 50"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:class="colorClass"
|
||||
>
|
||||
<circle cx="25" cy="25" r="25" fill="#EAF1FB" />
|
||||
<path
|
||||
d="M28.25 24.5V27H20.75V24.5H28.25ZM31.7266 18.6016C31.9089 18.7839 32 19.0052 32 19.2656V19.5H27V14.5H27.2344C27.4948 14.5 27.7161 14.5911 27.8984 14.7734L31.7266 18.6016ZM25.75 19.8125C25.75 20.0729 25.8411 20.2943 26.0234 20.4766C26.2057 20.6589 26.4271 20.75 26.6875 20.75H32V33.5625C32 33.8229 31.9089 34.0443 31.7266 34.2266C31.5443 34.4089 31.3229 34.5 31.0625 34.5H17.9375C17.6771 34.5 17.4557 34.4089 17.2734 34.2266C17.0911 34.0443 17 33.8229 17 33.5625V15.4375C17 15.1771 17.0911 14.9557 17.2734 14.7734C17.4557 14.5911 17.6771 14.5 17.9375 14.5H25.75V19.8125ZM19.5 17.3125V17.9375C19.5 18.1458 19.6042 18.25 19.8125 18.25H22.9375C23.1458 18.25 23.25 18.1458 23.25 17.9375V17.3125C23.25 17.1042 23.1458 17 22.9375 17H19.8125C19.6042 17 19.5 17.1042 19.5 17.3125ZM19.5 19.8125V20.4375C19.5 20.6458 19.6042 20.75 19.8125 20.75H22.9375C23.1458 20.75 23.25 20.6458 23.25 20.4375V19.8125C23.25 19.6042 23.1458 19.5 22.9375 19.5H19.8125C19.6042 19.5 19.5 19.6042 19.5 19.8125ZM29.5 31.6875V31.0625C29.5 30.8542 29.3958 30.75 29.1875 30.75H26.0625C25.8542 30.75 25.75 30.8542 25.75 31.0625V31.6875C25.75 31.8958 25.8542 32 26.0625 32H29.1875C29.3958 32 29.5 31.8958 29.5 31.6875ZM29.5 23.875C29.5 23.6927 29.4349 23.5495 29.3047 23.4453C29.2005 23.3151 29.0573 23.25 28.875 23.25H20.125C19.9427 23.25 19.7865 23.3151 19.6562 23.4453C19.5521 23.5495 19.5 23.6927 19.5 23.875V27.625C19.5 27.8073 19.5521 27.9635 19.6562 28.0938C19.7865 28.1979 19.9427 28.25 20.125 28.25H28.875C29.0573 28.25 29.2005 28.1979 29.3047 28.0938C29.4349 27.9635 29.5 27.8073 29.5 27.625V23.875Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
colorClass?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
colorClass: 'text-primary-500',
|
||||
})
|
||||
</script>
|
||||
1
resources/scripts/components/icons/index.ts
Normal file
1
resources/scripts/components/icons/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as MainLogo } from './MainLogo.vue'
|
||||
35
resources/scripts/components/icons/svg/LoginBackground.vue
Normal file
35
resources/scripts/components/icons/svg/LoginBackground.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 1012 1023"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
class="text-primary-500"
|
||||
>
|
||||
<path
|
||||
d="M116.21 472.5C55.1239 693.5 78.5219 837.5 114.349 1023H1030.5V-1L0 -106C147.5 21.5 172.311 269.536 116.21 472.5Z"
|
||||
fill="url(#paint0_linear)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="515.25"
|
||||
y1="-106"
|
||||
x2="515.25"
|
||||
y2="1023"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--color-primary-500)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--color-primary-400)"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<svg
|
||||
width="1122"
|
||||
height="1017"
|
||||
viewBox="0 0 1122 1017"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M226.002 466.5C164.935 687.5 188.326 831.5 224.141 1017H1140V-7L0 -109.5C142.5 -7.5 282.085 263.536 226.002 466.5Z"
|
||||
fill="url(#paint0_linear)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="649.5"
|
||||
y1="-7"
|
||||
x2="649.5"
|
||||
y2="1017"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--color-primary-500)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--color-primary-400)"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 1170 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M690 4.08004C518 -9.91998 231 4.08004 -6 176.361L231 197.08L1170 219.08C1113.33 175.747 909.275 21.928 690 4.08004Z"
|
||||
fill="white"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
109
resources/scripts/components/icons/svg/LoginPlanetCrater.vue
Normal file
109
resources/scripts/components/icons/svg/LoginPlanetCrater.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<svg
|
||||
width="422"
|
||||
height="290"
|
||||
viewBox="0 0 422 290"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0)">
|
||||
<path
|
||||
opacity="0.3"
|
||||
d="M220.111 290.223C341.676 290.223 440.223 191.676 440.223 70.1115C440.223 -51.4527 341.676 -150 220.111 -150C98.5473 -150 0 -51.4527 0 70.1115C0 191.676 98.5473 290.223 220.111 290.223Z"
|
||||
fill="white"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
opacity="0.3"
|
||||
d="M220.111 246.513C317.535 246.513 396.513 167.535 396.513 70.1114C396.513 -27.3124 317.535 -106.29 220.111 -106.29C122.688 -106.29 43.71 -27.3124 43.71 70.1114C43.71 167.535 122.688 246.513 220.111 246.513Z"
|
||||
fill="white"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M97.0093 35.322C96.7863 35.991 96.5633 36.66 96.5633 37.3291C94.1101 46.4725 92.7721 55.6159 92.3261 64.5364C91.88 74.5718 92.7721 84.6073 94.5562 94.1968C103.477 140.137 137.374 179.387 185.545 192.991C224.794 204.141 265.159 195.444 295.712 173.143C309.538 163.107 321.358 150.172 330.278 135.231C335.854 125.864 340.314 115.606 343.436 104.678C346.335 94.6428 347.896 84.6073 348.119 74.7949C348.788 56.954 345.666 39.3362 339.422 23.2794C325.372 -12.6253 295.043 -41.8397 255.124 -52.9902C211.637 -65.2558 167.481 -53.6593 136.036 -26.452C117.749 -10.6182 103.923 10.3448 97.0093 35.322Z"
|
||||
fill="white"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M97.0095 35.3223C96.7865 35.9913 96.5635 36.6603 96.5635 37.3294L347.896 74.7952C348.565 56.9543 345.443 39.3365 339.199 23.2797L136.036 -26.4517C117.749 -10.6179 103.923 10.3451 97.0095 35.3223Z"
|
||||
fill="white"
|
||||
fill-opacity="0.05"
|
||||
/>
|
||||
<path
|
||||
d="M92.3261 64.7598C91.88 74.7952 92.7721 84.8307 94.5562 94.4202L295.489 173.366C309.315 163.33 321.135 150.396 330.055 135.454L92.3261 64.7598Z"
|
||||
fill="white"
|
||||
fill-opacity="0.05"
|
||||
/>
|
||||
<path
|
||||
d="M153.431 11.9056C151.424 19.265 152.316 26.8473 155.661 33.0916C159.229 39.7819 165.473 45.1342 173.279 47.3643C188.443 51.6015 204.5 42.6811 208.737 27.5163C209.853 23.9482 210.076 20.157 209.629 16.5888C208.514 5.21529 200.486 -4.82018 188.889 -7.94233C173.502 -12.1795 157.668 -3.25911 153.431 11.9056Z"
|
||||
class="fill-primary-700"
|
||||
fill-opacity="0.8"
|
||||
/>
|
||||
<path
|
||||
d="M156.553 22.3869C155.438 25.9551 155.215 29.7462 155.661 33.3144C159.229 40.0047 165.473 45.357 173.279 47.5871C188.443 51.8243 204.5 42.9039 208.737 27.7391C209.852 24.171 210.075 20.3798 209.629 16.8116C206.061 10.1213 199.817 4.76908 192.011 2.53897C176.624 -1.92124 160.79 6.99919 156.553 22.3869Z"
|
||||
class="fill-primary-500"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M270.735 95.5343C267.613 100.887 266.944 106.685 268.282 112.26C269.62 118.058 273.411 123.411 278.986 126.533C289.914 132.777 303.74 128.986 309.985 118.281C311.546 115.605 312.438 112.929 312.884 110.03C314.222 101.11 309.985 91.9661 301.733 87.0599C290.806 81.0386 276.979 84.8298 270.735 95.5343Z"
|
||||
class="fill-primary-700"
|
||||
fill-opacity="0.8"
|
||||
/>
|
||||
<path
|
||||
d="M270.958 104.232C269.397 106.908 268.505 109.584 268.059 112.483C269.397 118.281 273.188 123.634 278.764 126.756C289.691 133 303.518 129.209 309.762 118.504C311.323 115.828 312.215 113.152 312.661 110.253C311.323 104.455 307.532 99.1025 301.957 95.9804C291.252 89.5131 277.426 93.3043 270.958 104.232Z"
|
||||
class="fill-primary-500"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M250.663 130.771C247.54 133.001 245.533 136.123 244.864 139.468C243.972 143.036 244.641 147.051 247.094 150.396C251.555 156.863 260.252 158.647 266.942 154.187C268.503 153.072 269.842 151.734 270.734 150.396C273.856 145.712 274.079 139.468 270.734 134.562C265.827 127.872 257.13 126.311 250.663 130.771Z"
|
||||
class="fill-primary-700"
|
||||
fill-opacity="0.8"
|
||||
/>
|
||||
<path
|
||||
d="M248.433 135.677C246.872 136.792 245.534 138.13 244.642 139.468C243.75 143.036 244.419 147.051 246.872 150.396C251.332 156.863 260.03 158.647 266.72 154.187C268.281 153.072 269.619 151.734 270.511 150.396C271.403 146.828 270.734 142.813 268.281 139.468C263.821 132.778 254.901 131.217 248.433 135.677Z"
|
||||
class="fill-primary-500"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M215.651 14.8049C214.759 18.15 215.205 21.4952 216.543 24.1713C218.104 27.0704 220.78 29.5236 224.348 30.4156C231.038 32.4227 238.175 28.4085 240.182 21.4952C240.628 19.9341 240.851 18.15 240.628 16.5889C240.182 11.4597 236.614 6.99951 231.484 5.66145C224.571 4.10037 217.435 8.11456 215.651 14.8049Z"
|
||||
class="fill-primary-700"
|
||||
fill-opacity="0.8"
|
||||
/>
|
||||
<path
|
||||
d="M216.989 19.4876C216.543 21.0487 216.32 22.8328 216.543 24.3939C218.104 27.293 220.78 29.7461 224.348 30.6382C231.039 32.6453 238.175 28.6311 240.182 21.7177C240.628 20.1567 240.851 18.3726 240.628 16.8115C239.067 13.9124 236.391 11.4593 232.823 10.5672C225.91 8.78313 218.773 12.5743 216.989 19.4876Z"
|
||||
class="fill-primary-500"
|
||||
/>
|
||||
<path
|
||||
d="M122.209 124.526C121.763 125.864 121.986 127.202 122.655 128.54C123.324 129.878 124.439 130.77 126.001 131.216C128.9 132.108 131.799 130.324 132.468 127.648C132.691 126.979 132.691 126.31 132.691 125.641C132.468 123.634 130.907 121.627 128.9 121.181C126.001 120.066 123.101 121.85 122.209 124.526Z"
|
||||
class="fill-primary-700"
|
||||
fill-opacity="0.8"
|
||||
/>
|
||||
<path
|
||||
d="M122.878 126.533C122.655 127.202 122.655 127.871 122.655 128.54C123.324 129.878 124.439 130.77 126 131.216C128.899 132.108 131.798 130.324 132.468 127.648C132.691 126.979 132.691 126.31 132.691 125.641C132.021 124.303 130.906 123.411 129.345 122.965C126.446 122.073 123.547 123.634 122.878 126.533Z"
|
||||
class="fill-primary-500"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M169.487 123.188C168.149 123.411 166.811 124.08 166.142 125.195C165.25 126.31 164.804 127.648 165.027 129.209C165.473 132.108 168.149 134.116 171.048 133.67C171.717 133.67 172.386 133.224 172.832 133C174.617 131.885 175.732 129.878 175.286 127.648C175.063 124.749 172.386 122.742 169.487 123.188Z"
|
||||
class="fill-primary-700"
|
||||
fill-opacity="0.8"
|
||||
/>
|
||||
<path
|
||||
d="M167.926 124.526C167.257 124.526 166.588 124.972 166.142 125.195C165.25 126.31 164.804 127.648 165.027 129.209C165.473 132.108 168.149 134.115 171.048 133.669C171.717 133.669 172.386 133.223 172.832 133C173.724 131.885 174.17 130.547 173.947 128.986C173.501 126.087 170.825 124.08 167.926 124.526Z"
|
||||
class="fill-primary-500"
|
||||
fill-opacity="0.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect
|
||||
width="440"
|
||||
height="440"
|
||||
fill="white"
|
||||
transform="translate(0 -150)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
60
resources/scripts/components/index.ts
Normal file
60
resources/scripts/components/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Table components
|
||||
export { DataTable, TablePagination } from './table'
|
||||
export type { ColumnDef, RowData, PaginationData, PaginationInfo } from './table'
|
||||
|
||||
// Form components
|
||||
export { FormGroup, FormGrid, SwitchSection } from './form'
|
||||
|
||||
// Layout components
|
||||
export {
|
||||
Page,
|
||||
PageHeader,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
FilterWrapper,
|
||||
EmptyPlaceholder,
|
||||
ContentPlaceholder,
|
||||
ContentPlaceholderBox,
|
||||
ContentPlaceholderText,
|
||||
ContentPlaceholderHeading,
|
||||
SettingCard,
|
||||
} from './layout'
|
||||
|
||||
// Editor components
|
||||
export { RichEditor } from './editor'
|
||||
|
||||
// Chart components
|
||||
export { LineChart } from './charts'
|
||||
|
||||
// Notification components
|
||||
export { NotificationRoot, NotificationItem } from './notifications'
|
||||
export type { Notification, NotificationType } from './notifications'
|
||||
|
||||
// Icon components
|
||||
export { MainLogo } from './icons'
|
||||
|
||||
// Base components
|
||||
export {
|
||||
BaseList,
|
||||
BaseListItem,
|
||||
BaseCustomInput,
|
||||
BaseCustomerAddressDisplay,
|
||||
BaseCustomerSelectPopup,
|
||||
BaseErrorAlert,
|
||||
BaseInfoAlert,
|
||||
BaseItemSelect,
|
||||
BaseMultiselect,
|
||||
BaseSelectAction,
|
||||
BaseSettingCard,
|
||||
BaseTabGroup,
|
||||
BaseText,
|
||||
BaseWizardNavigation,
|
||||
BaseWizardStep,
|
||||
InvoiceStatusBadge,
|
||||
EstimateStatusBadge,
|
||||
PaidStatusBadge,
|
||||
RecurringInvoiceStatusBadge,
|
||||
NewBadge,
|
||||
InvoiceInformationCard,
|
||||
InvoicePublicPage,
|
||||
} from './base'
|
||||
10
resources/scripts/components/layout/Breadcrumb.vue
Normal file
10
resources/scripts/components/layout/Breadcrumb.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<nav>
|
||||
<ol class="flex flex-wrap py-4 text-heading rounded list-reset">
|
||||
<slot />
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
35
resources/scripts/components/layout/BreadcrumbItem.vue
Normal file
35
resources/scripts/components/layout/BreadcrumbItem.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<li class="pr-2 text-sm">
|
||||
<router-link
|
||||
class="
|
||||
m-0
|
||||
mr-2
|
||||
text-sm
|
||||
font-medium
|
||||
leading-5
|
||||
text-heading
|
||||
outline-hidden
|
||||
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 lang="ts">
|
||||
interface Props {
|
||||
title?: string
|
||||
to?: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
to: '#',
|
||||
active: false,
|
||||
})
|
||||
</script>
|
||||
212
resources/scripts/components/layout/ContentPlaceholder.vue
Normal file
212
resources/scripts/components/layout/ContentPlaceholder.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div :class="classObject">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
rounded?: boolean
|
||||
centered?: boolean
|
||||
animated?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rounded: false,
|
||||
centered: false,
|
||||
animated: true,
|
||||
})
|
||||
|
||||
const classObject = computed<Record<string, boolean>>(() => {
|
||||
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>
|
||||
@keyframes vueContentPlaceholdersAnimation {
|
||||
0% {
|
||||
transform: translate3d(-30%, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-heading {
|
||||
display: flex;
|
||||
|
||||
[class^='base-content-placeholders-'] + & {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-heading__img {
|
||||
margin-right: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 15px;
|
||||
background: var(--color-surface-muted);
|
||||
|
||||
.base-content-placeholders-is-rounded & {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.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%, var(--color-hover-strong) 15%, transparent 30%);
|
||||
animation: vueContentPlaceholdersAnimation 1.5s linear infinite forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-heading__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.base-content-placeholders-heading__title {
|
||||
width: 85%;
|
||||
margin-bottom: 10px;
|
||||
background: var(--color-surface-muted);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 15px;
|
||||
|
||||
.base-content-placeholders-is-rounded & {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.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%, var(--color-hover-strong) 15%, transparent 30%);
|
||||
animation: vueContentPlaceholdersAnimation 1.5s linear infinite forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-heading__subtitle {
|
||||
width: 90%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 15px;
|
||||
background: var(--color-surface-muted);
|
||||
|
||||
.base-content-placeholders-is-rounded & {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.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%, var(--color-hover-strong) 15%, transparent 30%);
|
||||
animation: vueContentPlaceholdersAnimation 1.5s linear infinite forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-text {
|
||||
[class^='base-content-placeholders-'] + & {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-placeholders-text__line {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 15px;
|
||||
background: var(--color-surface-muted);
|
||||
|
||||
.base-content-placeholders-is-rounded & {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.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%, var(--color-hover-strong) 15%, transparent 30%);
|
||||
animation: vueContentPlaceholdersAnimation 1.5s linear infinite forwards;
|
||||
}
|
||||
|
||||
&: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: 15px;
|
||||
background: var(--color-surface-muted);
|
||||
|
||||
.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%, var(--color-hover-strong) 15%, transparent 30%);
|
||||
animation: vueContentPlaceholdersAnimation 1.5s linear infinite forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.base-content-circle {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.base-content-placeholders-is-rounded {
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="base-content-placeholders-box" :class="circleClass" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
circle?: boolean
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
circle: false,
|
||||
rounded: false,
|
||||
})
|
||||
|
||||
const circleClass = computed<Record<string, boolean>>(() => {
|
||||
return {
|
||||
'base-content-circle': props.circle,
|
||||
'base-content-placeholders-is-rounded': props.rounded,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user