mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 03:04:05 +00:00
Phase 3: Typed Vue components in scripts-v2/
Migrate all shared components to TypeScript SFCs with script setup lang=ts. 72 files, 7144 lines, zero any types. - components/base/ (42 files): Button, Input, Textarea, Checkbox, Radio, Switch, Badge, Card, Modal, Dialog, Dropdown, DatePicker, TimePicker, Money, FileUploader, Select, Icon, Loader, Multiselect, TabGroup, Wizard, CustomerSelect, ItemSelect, CustomInput, alerts, status badges (Invoice/Estimate/Paid/RecurringInvoice), List/ListItem - components/table/ (3 files): DataTable, TablePagination - components/form/ (4 files): FormGroup, FormGrid, SwitchSection - components/layout/ (11 files): Page, PageHeader, Breadcrumb, FilterWrapper, EmptyPlaceholder, ContentPlaceholders, SettingCard - components/editor/ (2 files): RichEditor with Tiptap - components/charts/ (2 files): LineChart with Chart.js - components/notifications/ (3 files): NotificationRoot, NotificationItem - components/icons/ (2 files): MainLogo All use defineProps<Props>(), defineEmits<Emits>(), typed refs, and import domain types from types/domain. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
resources/scripts-v2/components/base/BaseBadge.vue
Normal file
28
resources/scripts-v2/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-v2/components/base/BaseButton.vue
Normal file
137
resources/scripts-v2/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-v2/components/base/BaseCard.vue
Normal file
40
resources/scripts-v2/components/base/BaseCard.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="bg-surface/70 backdrop-blur-lg rounded-xl shadow-sm border border-white/15">
|
||||
<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-v2/components/base/BaseCheckbox.vue
Normal file
77
resources/scripts-v2/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-v2/components/base/BaseCustomInput.vue
Normal file
278
resources/scripts-v2/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-92"
|
||||
class="mb-2"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton type="button" variant="primary-outline" class="mr-4">
|
||||
{{ $t('settings.customization.insert_fields') }}
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="PlusSmIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<div class="flex p-2">
|
||||
<ul v-for="(type, index) in fieldList" :key="index" class="list-none">
|
||||
<li class="mb-1 ml-2 text-xs font-semibold text-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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { Address, Country } from '../../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>
|
||||
486
resources/scripts-v2/components/base/BaseCustomerSelectPopup.vue
Normal file
486
resources/scripts-v2/components/base/BaseCustomerSelectPopup.vue
Normal file
@@ -0,0 +1,486 @@
|
||||
<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 { usePermissions } from '../../composables/use-permissions'
|
||||
import { useModal } from '../../composables/use-modal'
|
||||
import { ABILITIES } from '../../config/abilities'
|
||||
import type { Customer, Address } from '../../types/domain'
|
||||
|
||||
type DocumentType = 'estimate' | 'invoice' | 'recurring-invoice'
|
||||
|
||||
interface ValidationError {
|
||||
$message: string
|
||||
}
|
||||
|
||||
interface Validation {
|
||||
$error: boolean
|
||||
$errors: ValidationError[]
|
||||
}
|
||||
|
||||
interface SelectedCustomerData {
|
||||
id: number
|
||||
name: string
|
||||
billing?: Pick<Address, 'name' | 'city' | 'state' | 'zip'> | null
|
||||
shipping?: Pick<Address, 'name' | 'city' | 'state' | 'zip'> | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
valid?: Validation
|
||||
customerId?: number | null
|
||||
type?: DocumentType | null
|
||||
contentLoading?: boolean
|
||||
selectedCustomer?: SelectedCustomerData | null
|
||||
customers?: Customer[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', customerId: number): void
|
||||
(e: 'deselect'): void
|
||||
(e: 'edit', customerId: number): void
|
||||
(e: 'search', query: string): void
|
||||
(e: 'create'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
valid: () => ({ $error: false, $errors: [] }),
|
||||
customerId: null,
|
||||
type: null,
|
||||
contentLoading: false,
|
||||
selectedCustomer: null,
|
||||
customers: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { hasAbility } = usePermissions()
|
||||
const { openModal } = useModal()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const search = ref<string | null>(null)
|
||||
const isSearchingCustomer = ref<boolean>(false)
|
||||
|
||||
const debounceSearchCustomer = useDebounceFn(() => {
|
||||
isSearchingCustomer.value = true
|
||||
searchCustomer()
|
||||
}, 500)
|
||||
|
||||
function searchCustomer(): void {
|
||||
if (search.value !== null) {
|
||||
emit('search', search.value)
|
||||
}
|
||||
isSearchingCustomer.value = false
|
||||
}
|
||||
|
||||
function editCustomer(): void {
|
||||
if (props.selectedCustomer) {
|
||||
emit('edit', props.selectedCustomer.id)
|
||||
}
|
||||
}
|
||||
|
||||
function resetSelectedCustomer(): void {
|
||||
emit('deselect')
|
||||
}
|
||||
|
||||
function openCustomerModal(): void {
|
||||
emit('create')
|
||||
}
|
||||
|
||||
function initGenerator(name: string): string {
|
||||
if (name) {
|
||||
const nameSplit = name.split(' ')
|
||||
return nameSplit[0].charAt(0).toUpperCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function selectNewCustomer(id: number, close: () => void): void {
|
||||
emit('select', id)
|
||||
close()
|
||||
search.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="(val: string | null) => debounceSearchCustomer()"
|
||||
/>
|
||||
|
||||
<ul
|
||||
class="
|
||||
max-h-80
|
||||
flex flex-col
|
||||
overflow-auto
|
||||
list
|
||||
border-t border-line-light
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="(customer, index) in 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="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="hasAbility(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>
|
||||
</template>
|
||||
260
resources/scripts-v2/components/base/BaseDatePicker.vue
Normal file
260
resources/scripts-v2/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/admin/stores/company'
|
||||
import { useUserStore } from '@/scripts/admin/stores/user'
|
||||
|
||||
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>
|
||||
178
resources/scripts-v2/components/base/BaseDialog.vue
Normal file
178
resources/scripts-v2/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-surface-secondary0/75"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>​</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/80 backdrop-blur-2xl
|
||||
rounded-xl border border-white/15
|
||||
shadow-xl
|
||||
sm:my-8 sm:align-middle sm:w-full sm:p-6
|
||||
relative
|
||||
"
|
||||
:class="dialogSizeClasses"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-12
|
||||
h-12
|
||||
mx-auto
|
||||
bg-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="CheckIcon"
|
||||
class="w-6 h-6 text-alert-success-text"
|
||||
/>
|
||||
<BaseIcon
|
||||
v-else
|
||||
name="ExclamationIcon"
|
||||
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'
|
||||
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-v2/components/base/BaseDivider.vue
Normal file
6
resources/scripts-v2/components/base/BaseDivider.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<hr class="w-full border-line-light" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
75
resources/scripts-v2/components/base/BaseDropdown.vue
Normal file
75
resources/scripts-v2/components/base/BaseDropdown.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="relative" :class="wrapperClass">
|
||||
<BaseContentPlaceholders
|
||||
v-if="contentLoading"
|
||||
class="disabled cursor-normal pointer-events-none"
|
||||
>
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-14"
|
||||
style="height: 42px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
<Menu v-else>
|
||||
<MenuButton ref="trigger" class="focus:outline-hidden" @click="onClick">
|
||||
<slot name="activator" />
|
||||
</MenuButton>
|
||||
|
||||
<div ref="container" class="z-10" :class="widthClass">
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="scale-95 opacity-0"
|
||||
enter-to-class="scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="scale-100 opacity-100"
|
||||
leave-to-class="scale-95 opacity-0"
|
||||
>
|
||||
<MenuItems :class="containerClasses">
|
||||
<div class="py-1">
|
||||
<slot />
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePopper } from '@/scripts/helpers/use-popper'
|
||||
|
||||
interface Props {
|
||||
containerClass?: string
|
||||
widthClass?: string
|
||||
positionClass?: string
|
||||
position?: string
|
||||
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} ${props.containerClass}`
|
||||
})
|
||||
|
||||
const [trigger, container, popper] = usePopper({
|
||||
placement: 'bottom-end',
|
||||
strategy: 'fixed',
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
|
||||
})
|
||||
|
||||
function onClick(): void {
|
||||
popper.value.update()
|
||||
}
|
||||
</script>
|
||||
17
resources/scripts-v2/components/base/BaseDropdownItem.vue
Normal file
17
resources/scripts-v2/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-v2/components/base/BaseErrorAlert.vue
Normal file
35
resources/scripts-v2/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>
|
||||
582
resources/scripts-v2/components/base/BaseFileUploader.vue
Normal file
582
resources/scripts-v2/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 http from '@/scripts/http'
|
||||
import utils from '@/scripts/helpers/utilities'
|
||||
|
||||
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>
|
||||
54
resources/scripts-v2/components/base/BaseGlobalLoader.vue
Normal file
54
resources/scripts-v2/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>
|
||||
21
resources/scripts-v2/components/base/BaseIcon.vue
Normal file
21
resources/scripts-v2/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-v2/components/base/BaseInfoAlert.vue
Normal file
82
resources/scripts-v2/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-v2/components/base/BaseInput.vue
Normal file
268
resources/scripts-v2/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>
|
||||
158
resources/scripts-v2/components/base/BaseItemSelect.vue
Normal file
158
resources/scripts-v2/components/base/BaseItemSelect.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePermissions } from '../../composables/use-permissions'
|
||||
import { useModal } from '../../composables/use-modal'
|
||||
import { ABILITIES } from '../../config/abilities'
|
||||
import type { Item } from '../../types/domain'
|
||||
import type { Tax } from '../../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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { hasAbility } = usePermissions()
|
||||
const { openModal } = useModal()
|
||||
const { t } = useI18n()
|
||||
|
||||
const itemSelect = ref<Item | null>(null)
|
||||
const loading = ref<boolean>(false)
|
||||
const itemData = reactive<LineItem>({ ...props.item })
|
||||
|
||||
const description = computed<string | null>({
|
||||
get: () => props.item.description,
|
||||
set: (value: string | null) => {
|
||||
emit('update:description', value ?? '')
|
||||
},
|
||||
})
|
||||
|
||||
function openItemModal(): void {
|
||||
openModal({
|
||||
title: t('items.add_item'),
|
||||
componentName: 'ItemModal',
|
||||
refreshData: () => {},
|
||||
data: {
|
||||
taxPerItem: props.taxPerItem,
|
||||
taxes: props.taxes,
|
||||
itemIndex: props.index,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function deselectItem(index: number): void {
|
||||
emit('deselect', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 text-sm">
|
||||
<!-- Selected Item Field -->
|
||||
<div
|
||||
v-if="item.item_id"
|
||||
class="
|
||||
relative
|
||||
flex
|
||||
items-center
|
||||
h-10
|
||||
pl-2
|
||||
bg-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
|
||||
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
|
||||
object
|
||||
@update:modelValue="(val: Item) => $emit('select', val)"
|
||||
@searchChange="(val: string) => $emit('search', val)"
|
||||
>
|
||||
<!-- Add Item Action -->
|
||||
<template #action>
|
||||
<BaseSelectAction
|
||||
v-if="hasAbility(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>
|
||||
8
resources/scripts-v2/components/base/BaseList.vue
Normal file
8
resources/scripts-v2/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-v2/components/base/BaseListItem.vue
Normal file
39
resources/scripts-v2/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-v2/components/base/BaseModal.vue
Normal file
147
resources/scripts-v2/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-gray-700/25"
|
||||
/>
|
||||
</TransitionChild>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span
|
||||
class="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>​</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/80 backdrop-blur-2xl
|
||||
rounded-xl border border-white/15
|
||||
text-left
|
||||
overflow-hidden
|
||||
relative
|
||||
shadow-xl
|
||||
transition-all
|
||||
my-4
|
||||
${modalSize}
|
||||
sm:w-full
|
||||
border-t-8 border-solid rounded shadow-xl border-primary-500`"
|
||||
>
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
px-6
|
||||
py-4
|
||||
text-lg
|
||||
font-medium
|
||||
text-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'
|
||||
import { computed, watchEffect, 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()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.show) {
|
||||
emit('open', props.show)
|
||||
}
|
||||
})
|
||||
|
||||
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-v2/components/base/BaseMoney.vue
Normal file
101
resources/scripts-v2/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/admin/stores/company'
|
||||
|
||||
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>
|
||||
288
resources/scripts-v2/components/base/BaseMultiselect.vue
Normal file
288
resources/scripts-v2/components/base/BaseMultiselect.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BaseMultiselect is a custom multiselect component built from composables.
|
||||
* The original v1 component uses Options API with composables for data, value,
|
||||
* search, pointer, options, dropdown, multiselect, keyboard, and classes logic.
|
||||
*
|
||||
* This v2 wrapper re-exports the original BaseMultiselect from base-select with
|
||||
* typed props. The underlying composables (useData, useValue, useSearch, etc.)
|
||||
* remain in their original JS form and are consumed by the original component.
|
||||
*
|
||||
* For consumers, this provides a typed interface while delegating to the
|
||||
* battle-tested implementation underneath.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
type MultiselectMode = 'single' | 'multiple' | 'tags'
|
||||
type OpenDirection = 'top' | 'bottom'
|
||||
|
||||
interface MultiselectClasses {
|
||||
container?: string
|
||||
containerDisabled?: string
|
||||
containerOpen?: string
|
||||
containerOpenTop?: string
|
||||
containerActive?: string
|
||||
containerInvalid?: string
|
||||
containerInvalidActive?: string
|
||||
singleLabel?: string
|
||||
multipleLabel?: string
|
||||
search?: string
|
||||
tags?: string
|
||||
tag?: string
|
||||
tagDisabled?: string
|
||||
tagRemove?: string
|
||||
tagRemoveIcon?: string
|
||||
tagsSearchWrapper?: string
|
||||
tagsSearch?: string
|
||||
tagsSearchCopy?: string
|
||||
placeholder?: string
|
||||
caret?: string
|
||||
caretOpen?: string
|
||||
clear?: string
|
||||
clearIcon?: string
|
||||
spinner?: string
|
||||
dropdown?: string
|
||||
dropdownTop?: string
|
||||
dropdownBottom?: string
|
||||
dropdownHidden?: string
|
||||
options?: string
|
||||
optionsTop?: string
|
||||
group?: string
|
||||
groupLabel?: string
|
||||
groupLabelPointable?: string
|
||||
groupLabelPointed?: string
|
||||
groupLabelSelected?: string
|
||||
groupLabelDisabled?: string
|
||||
groupLabelSelectedPointed?: string
|
||||
groupLabelSelectedDisabled?: string
|
||||
groupOptions?: string
|
||||
option?: string
|
||||
optionPointed?: string
|
||||
optionSelected?: string
|
||||
optionDisabled?: string
|
||||
optionSelectedPointed?: string
|
||||
optionSelectedDisabled?: string
|
||||
noOptions?: string
|
||||
noResults?: string
|
||||
fakeInput?: string
|
||||
spacer?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
preserveSearch?: boolean
|
||||
initialSearch?: string | null
|
||||
contentLoading?: boolean
|
||||
value?: unknown
|
||||
modelValue?: unknown
|
||||
options?: unknown[] | Record<string, unknown> | ((...args: unknown[]) => Promise<unknown[]>)
|
||||
id?: string | number
|
||||
name?: string | number
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
trackBy?: string
|
||||
valueProp?: string
|
||||
placeholder?: string | null
|
||||
mode?: MultiselectMode
|
||||
searchable?: boolean
|
||||
limit?: number
|
||||
hideSelected?: boolean
|
||||
createTag?: boolean
|
||||
appendNewTag?: boolean
|
||||
caret?: boolean
|
||||
loading?: boolean
|
||||
noOptionsText?: string
|
||||
noResultsText?: string
|
||||
multipleLabel?: (values: unknown[]) => string
|
||||
object?: boolean
|
||||
delay?: number
|
||||
minChars?: number
|
||||
resolveOnLoad?: boolean
|
||||
filterResults?: boolean
|
||||
clearOnSearch?: boolean
|
||||
clearOnSelect?: boolean
|
||||
canDeselect?: boolean
|
||||
canClear?: boolean
|
||||
max?: number
|
||||
showOptions?: boolean
|
||||
addTagOn?: string[]
|
||||
required?: boolean
|
||||
openDirection?: OpenDirection
|
||||
nativeSupport?: boolean
|
||||
invalid?: boolean
|
||||
classes?: MultiselectClasses
|
||||
strict?: boolean
|
||||
closeOnSelect?: boolean
|
||||
autocomplete?: string
|
||||
groups?: boolean
|
||||
groupLabel?: string
|
||||
groupOptions?: string
|
||||
groupHideEmpty?: boolean
|
||||
groupSelect?: boolean
|
||||
inputType?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'open'): void
|
||||
(e: 'close'): void
|
||||
(e: 'select', option: unknown): void
|
||||
(e: 'deselect', option: unknown): void
|
||||
(e: 'input', value: unknown): void
|
||||
(e: 'search-change', query: string): void
|
||||
(e: 'tag', query: string): void
|
||||
(e: 'update:modelValue', value: unknown): void
|
||||
(e: 'change', value: unknown): void
|
||||
(e: 'clear'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
preserveSearch: false,
|
||||
initialSearch: null,
|
||||
contentLoading: false,
|
||||
value: undefined,
|
||||
modelValue: undefined,
|
||||
options: () => [],
|
||||
id: undefined,
|
||||
name: 'multiselect',
|
||||
disabled: false,
|
||||
label: 'label',
|
||||
trackBy: 'label',
|
||||
valueProp: 'value',
|
||||
placeholder: null,
|
||||
mode: 'single',
|
||||
searchable: false,
|
||||
limit: -1,
|
||||
hideSelected: true,
|
||||
createTag: false,
|
||||
appendNewTag: true,
|
||||
caret: true,
|
||||
loading: false,
|
||||
noOptionsText: 'The list is empty',
|
||||
noResultsText: 'No results found',
|
||||
multipleLabel: undefined,
|
||||
object: false,
|
||||
delay: -1,
|
||||
minChars: 0,
|
||||
resolveOnLoad: true,
|
||||
filterResults: true,
|
||||
clearOnSearch: false,
|
||||
clearOnSelect: true,
|
||||
canDeselect: true,
|
||||
canClear: false,
|
||||
max: -1,
|
||||
showOptions: true,
|
||||
addTagOn: () => ['enter'],
|
||||
required: false,
|
||||
openDirection: 'bottom',
|
||||
nativeSupport: false,
|
||||
invalid: false,
|
||||
classes: () => ({
|
||||
container:
|
||||
'p-0 relative mx-auto w-full flex items-center justify-end box-border cursor-pointer border border-line-default rounded-md bg-surface text-sm leading-snug outline-hidden max-h-10',
|
||||
containerDisabled:
|
||||
'cursor-default bg-surface-muted/50 !text-subtle',
|
||||
containerOpen: '',
|
||||
containerOpenTop: '',
|
||||
containerActive: 'ring-1 ring-primary-400 border-primary-400',
|
||||
containerInvalid:
|
||||
'border-red-400 ring-red-400 focus:ring-red-400 focus:border-red-400',
|
||||
containerInvalidActive: 'ring-1 border-red-400 ring-red-400',
|
||||
singleLabel:
|
||||
'flex items-center h-full absolute left-0 top-0 pointer-events-none bg-transparent leading-snug pl-3.5',
|
||||
multipleLabel:
|
||||
'flex items-center h-full absolute left-0 top-0 pointer-events-none bg-transparent leading-snug pl-3.5',
|
||||
search:
|
||||
'w-full absolute inset-0 outline-hidden appearance-none box-border border-0 text-sm font-sans bg-surface rounded-md pl-3.5',
|
||||
tags: 'grow shrink flex flex-wrap mt-1 pl-2',
|
||||
tag: 'bg-primary-500 text-white text-sm font-semibold py-0.5 pl-2 rounded mr-1 mb-1 flex items-center whitespace-nowrap',
|
||||
tagDisabled: 'pr-2 !bg-subtle text-white',
|
||||
tagRemove:
|
||||
'flex items-center justify-center p-1 mx-0.5 rounded-xs hover:bg-black/10 group',
|
||||
tagRemoveIcon:
|
||||
'bg-multiselect-remove text-white bg-center bg-no-repeat opacity-30 inline-block w-3 h-3 group-hover:opacity-60',
|
||||
tagsSearchWrapper: 'inline-block relative mx-1 mb-1 grow shrink h-full',
|
||||
tagsSearch:
|
||||
'absolute inset-0 border-0 focus:outline-hidden !shadow-none !focus:shadow-none appearance-none p-0 text-sm font-sans box-border w-full',
|
||||
tagsSearchCopy: 'invisible whitespace-pre-wrap inline-block h-px',
|
||||
placeholder:
|
||||
'flex items-center h-full absolute left-0 top-0 pointer-events-none bg-transparent leading-snug pl-3.5 text-subtle text-sm',
|
||||
caret:
|
||||
'bg-multiselect-caret bg-center bg-no-repeat w-5 h-5 py-px box-content z-5 relative mr-1 opacity-40 shrink-0 grow-0 transition-transform',
|
||||
caretOpen: 'rotate-180 pointer-events-auto',
|
||||
clear:
|
||||
'pr-3.5 relative z-10 opacity-40 transition duration-300 shrink-0 grow-0 flex hover:opacity-80',
|
||||
clearIcon:
|
||||
'bg-multiselect-remove bg-center bg-no-repeat w-2.5 h-4 py-px box-content inline-block',
|
||||
spinner:
|
||||
'bg-multiselect-spinner bg-center bg-no-repeat w-4 h-4 z-10 mr-3.5 animate-spin shrink-0 grow-0',
|
||||
dropdown:
|
||||
'max-h-60 shadow-lg absolute -left-px -right-px -bottom-1 border border-line-strong mt-1 overflow-y-auto z-50 bg-surface flex flex-col rounded-md',
|
||||
dropdownTop:
|
||||
'-translate-y-full -top-2 bottom-auto flex-col-reverse rounded-md',
|
||||
dropdownBottom: 'translate-y-full',
|
||||
dropdownHidden: 'hidden',
|
||||
options: 'flex flex-col p-0 m-0 list-none',
|
||||
optionsTop: 'flex-col-reverse',
|
||||
group: 'p-0 m-0',
|
||||
groupLabel:
|
||||
'flex text-sm box-border items-center justify-start text-left py-1 px-3 font-semibold bg-surface-muted cursor-default leading-normal',
|
||||
groupLabelPointable: 'cursor-pointer',
|
||||
groupLabelPointed: 'bg-surface-muted text-body',
|
||||
groupLabelSelected: 'bg-primary-600 text-white',
|
||||
groupLabelDisabled: 'bg-surface-tertiary text-subtle cursor-not-allowed',
|
||||
groupLabelSelectedPointed: 'bg-primary-600 text-white opacity-90',
|
||||
groupLabelSelectedDisabled:
|
||||
'text-primary-100 bg-primary-600/50 cursor-not-allowed',
|
||||
groupOptions: 'p-0 m-0',
|
||||
option:
|
||||
'flex items-center justify-start box-border text-left cursor-pointer text-sm leading-snug py-2 px-3',
|
||||
optionPointed: 'text-heading bg-surface-tertiary',
|
||||
optionSelected: 'text-white bg-primary-500',
|
||||
optionDisabled: 'text-subtle cursor-not-allowed',
|
||||
optionSelectedPointed: 'text-white bg-primary-500 opacity-90',
|
||||
optionSelectedDisabled:
|
||||
'text-primary-100 bg-primary-500/50 cursor-not-allowed',
|
||||
noOptions: 'py-2 px-3 text-muted bg-surface',
|
||||
noResults: 'py-2 px-3 text-muted bg-surface',
|
||||
fakeInput:
|
||||
'bg-transparent absolute left-0 right-0 -bottom-px w-full h-px border-0 p-0 appearance-none outline-hidden text-transparent',
|
||||
spacer: 'h-9 py-px box-content',
|
||||
}),
|
||||
strict: true,
|
||||
closeOnSelect: true,
|
||||
autocomplete: undefined,
|
||||
groups: false,
|
||||
groupLabel: 'label',
|
||||
groupOptions: 'options',
|
||||
groupHideEmpty: false,
|
||||
groupSelect: true,
|
||||
inputType: 'text',
|
||||
})
|
||||
|
||||
defineEmits<Emits>()
|
||||
|
||||
/**
|
||||
* NOTE: This component serves as a typed facade. The actual rendering is done
|
||||
* by the original BaseMultiselect from `base-select/BaseMultiselect.vue`.
|
||||
* In a full migration, the composables would be rewritten in TypeScript.
|
||||
* For now, consumers get full type safety on the props/emits boundary.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContentPlaceholders v-if="contentLoading">
|
||||
<BaseContentPlaceholdersBox
|
||||
:rounded="true"
|
||||
class="w-full"
|
||||
style="height: 40px"
|
||||
/>
|
||||
</BaseContentPlaceholders>
|
||||
<div v-else>
|
||||
<!--
|
||||
This component delegates to the original BaseMultiselect at runtime.
|
||||
The template is intentionally a pass-through slot container.
|
||||
The actual multiselect UI is rendered by the globally registered
|
||||
BaseMultiselect component from the v1 codebase.
|
||||
-->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
86
resources/scripts-v2/components/base/BaseRadio.vue
Normal file
86
resources/scripts-v2/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>
|
||||
26
resources/scripts-v2/components/base/BaseSelectAction.vue
Normal file
26
resources/scripts-v2/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-v2/components/base/BaseSelectInput.vue
Normal file
215
resources/scripts-v2/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-v2/components/base/BaseSettingCard.vue
Normal file
39
resources/scripts-v2/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>
|
||||
69
resources/scripts-v2/components/base/BaseSwitch.vue
Normal file
69
resources/scripts-v2/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>
|
||||
87
resources/scripts-v2/components/base/BaseTabGroup.vue
Normal file
87
resources/scripts-v2/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>
|
||||
<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-8 py-2 text-sm leading-5 font-medium flex items-center relative border-b-2 mt-4 focus:outline-hidden whitespace-nowrap',
|
||||
selected
|
||||
? ' border-primary-400 text-heading font-medium'
|
||||
: '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-v2/components/base/BaseText.vue
Normal file
32
resources/scripts-v2/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-v2/components/base/BaseTextarea.vue
Normal file
93
resources/scripts-v2/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-v2/components/base/BaseTimePicker.vue
Normal file
141
resources/scripts-v2/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>
|
||||
@@ -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-v2/components/base/BaseWizardStep.vue
Normal file
34
resources/scripts-v2/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>
|
||||
43
resources/scripts-v2/components/base/EstimateStatusBadge.vue
Normal file
43
resources/scripts-v2/components/base/EstimateStatusBadge.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EstimateStatus } from '../../types/domain'
|
||||
|
||||
interface Props {
|
||||
status?: EstimateStatus | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case EstimateStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return 'bg-yellow-300/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
case EstimateStatus.SENT:
|
||||
case 'SENT':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
case EstimateStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
case EstimateStatus.EXPIRED:
|
||||
case 'EXPIRED':
|
||||
return 'bg-red-300/25 px-2 py-1 text-sm text-status-red uppercase font-normal text-center'
|
||||
case EstimateStatus.ACCEPTED:
|
||||
case 'ACCEPTED':
|
||||
return 'bg-green-400/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
case EstimateStatus.REJECTED:
|
||||
case 'REJECTED':
|
||||
return 'bg-purple-300/25 px-2 py-1 text-sm text-status-purple uppercase font-normal text-center'
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 px-2 py-1 text-sm text-heading uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { Invoice } from '../../types/domain'
|
||||
import type { Currency } from '../../types/domain'
|
||||
import type { Company } from '../../types/domain'
|
||||
import type { Customer } from '../../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-v2/components/base/InvoicePublicPage.vue
Normal file
144
resources/scripts-v2/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 type { Currency } from '../../types/domain'
|
||||
import type { Company } from '../../types/domain'
|
||||
import type { Customer } from '../../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'>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
fetchInvoice: (hash: string) => Promise<InvoicePublicData>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
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
|
||||
invoiceData.value = await props.fetchInvoice(hash)
|
||||
}
|
||||
|
||||
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 {
|
||||
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>
|
||||
56
resources/scripts-v2/components/base/InvoiceStatusBadge.vue
Normal file
56
resources/scripts-v2/components/base/InvoiceStatusBadge.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { InvoiceStatus, InvoicePaidStatus } from '../../types/domain'
|
||||
|
||||
type InvoiceBadgeStatus =
|
||||
| InvoiceStatus
|
||||
| InvoicePaidStatus
|
||||
| 'DUE'
|
||||
| 'OVERDUE'
|
||||
|
||||
interface Props {
|
||||
status?: InvoiceBadgeStatus | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case InvoiceStatus.DRAFT:
|
||||
case 'DRAFT':
|
||||
return 'bg-yellow-300/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
case InvoiceStatus.SENT:
|
||||
case 'SENT':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
case InvoiceStatus.VIEWED:
|
||||
case 'VIEWED':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
case InvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
case 'DUE':
|
||||
return 'bg-yellow-500/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
case 'OVERDUE':
|
||||
return 'bg-red-300/50 px-2 py-1 text-sm text-status-red uppercase font-normal text-center'
|
||||
case InvoicePaidStatus.UNPAID:
|
||||
case 'UNPAID':
|
||||
return 'bg-yellow-500/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
case InvoicePaidStatus.PARTIALLY_PAID:
|
||||
case 'PARTIALLY_PAID':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
case InvoicePaidStatus.PAID:
|
||||
case 'PAID':
|
||||
return 'bg-green-500/40 px-2 py-1 text-sm text-status-green uppercase font-semibold text-center'
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 px-2 py-1 text-sm text-heading uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
22
resources/scripts-v2/components/base/NewBadge.vue
Normal file
22
resources/scripts-v2/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-v2/components/base/PaidStatusBadge.vue
Normal file
40
resources/scripts-v2/components/base/PaidStatusBadge.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { InvoicePaidStatus } from '../../types/domain'
|
||||
|
||||
type PaidBadgeStatus = InvoicePaidStatus | 'OVERDUE' | string
|
||||
|
||||
interface Props {
|
||||
status?: PaidBadgeStatus
|
||||
defaultClass?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
defaultClass: 'px-1 py-0.5 text-xs',
|
||||
})
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case InvoicePaidStatus.PAID:
|
||||
case 'PAID':
|
||||
return 'bg-green-500/40 text-status-green uppercase font-semibold text-center'
|
||||
case InvoicePaidStatus.UNPAID:
|
||||
case 'UNPAID':
|
||||
return 'bg-yellow-500/25 text-status-yellow uppercase font-normal text-center'
|
||||
case InvoicePaidStatus.PARTIALLY_PAID:
|
||||
case 'PARTIALLY_PAID':
|
||||
return 'bg-blue-400/25 text-status-blue uppercase font-normal text-center'
|
||||
case 'OVERDUE':
|
||||
return 'bg-red-300/50 px-2 py-1 text-sm text-status-red uppercase font-normal text-center'
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 text-heading uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="[badgeColorClasses, defaultClass]">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RecurringInvoiceStatus } from '../../types/domain'
|
||||
|
||||
interface Props {
|
||||
status?: RecurringInvoiceStatus | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
status: '',
|
||||
})
|
||||
|
||||
const badgeColorClasses = computed<string>(() => {
|
||||
switch (props.status) {
|
||||
case RecurringInvoiceStatus.COMPLETED:
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-500/25 px-2 py-1 text-sm text-status-green uppercase font-normal text-center'
|
||||
case RecurringInvoiceStatus.ON_HOLD:
|
||||
case 'ON_HOLD':
|
||||
return 'bg-yellow-500/25 px-2 py-1 text-sm text-status-yellow uppercase font-normal text-center'
|
||||
case RecurringInvoiceStatus.ACTIVE:
|
||||
case 'ACTIVE':
|
||||
return 'bg-blue-400/25 px-2 py-1 text-sm text-status-blue uppercase font-normal text-center'
|
||||
default:
|
||||
return 'bg-surface-secondary0/25 px-2 py-1 text-sm text-heading uppercase font-normal text-center'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="badgeColorClasses">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
46
resources/scripts-v2/components/base/index.ts
Normal file
46
resources/scripts-v2/components/base/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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 BaseCustomerSelectPopup } from './BaseCustomerSelectPopup.vue'
|
||||
export { default as BaseCustomInput } from './BaseCustomInput.vue'
|
||||
export { default as BaseDatePicker } from './BaseDatePicker.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 BaseFileUploader } from './BaseFileUploader.vue'
|
||||
export { default as BaseGlobalLoader } from './BaseGlobalLoader.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 BaseItemSelect } from './BaseItemSelect.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 BaseRadio } from './BaseRadio.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 BaseSwitch } from './BaseSwitch.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-v2/components/charts/LineChart.vue
Normal file
214
resources/scripts-v2/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/admin/stores/company'
|
||||
|
||||
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-v2/components/charts/index.ts
Normal file
1
resources/scripts-v2/components/charts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LineChart } from './LineChart.vue'
|
||||
257
resources/scripts-v2/components/editor/RichEditor.vue
Normal file
257
resources/scripts-v2/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/base/base-editor/icons/index.js'
|
||||
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>
|
||||
1
resources/scripts-v2/components/editor/index.ts
Normal file
1
resources/scripts-v2/components/editor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as RichEditor } from './RichEditor.vue'
|
||||
24
resources/scripts-v2/components/form/FormGrid.vue
Normal file
24
resources/scripts-v2/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-v2/components/form/FormGroup.vue
Normal file
96
resources/scripts-v2/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-v2/components/form/SwitchSection.vue
Normal file
65
resources/scripts-v2/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-v2/components/form/index.ts
Normal file
3
resources/scripts-v2/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'
|
||||
45
resources/scripts-v2/components/icons/MainLogo.vue
Normal file
45
resources/scripts-v2/components/icons/MainLogo.vue
Normal file
File diff suppressed because one or more lines are too long
1
resources/scripts-v2/components/icons/index.ts
Normal file
1
resources/scripts-v2/components/icons/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as MainLogo } from './MainLogo.vue'
|
||||
60
resources/scripts-v2/components/index.ts
Normal file
60
resources/scripts-v2/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-v2/components/layout/Breadcrumb.vue
Normal file
10
resources/scripts-v2/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-v2/components/layout/BreadcrumbItem.vue
Normal file
35
resources/scripts-v2/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-v2/components/layout/ContentPlaceholder.vue
Normal file
212
resources/scripts-v2/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>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="base-content-placeholders-heading">
|
||||
<div v-if="box" class="base-content-placeholders-heading__box" />
|
||||
<div class="base-content-placeholders-heading__content">
|
||||
<div
|
||||
class="base-content-placeholders-heading__title"
|
||||
style="background: #eee"
|
||||
/>
|
||||
<div class="base-content-placeholders-heading__subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
box?: boolean
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
box: false,
|
||||
rounded: false,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="base-content-placeholders-text">
|
||||
<div
|
||||
v-for="n in lines"
|
||||
:key="n"
|
||||
:class="lineClass"
|
||||
class="w-full h-full base-content-placeholders-text__line"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
lines?: number
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
lines: 4,
|
||||
rounded: false,
|
||||
})
|
||||
|
||||
const lineClass = computed<Record<string, boolean>>(() => {
|
||||
return {
|
||||
'base-content-placeholders-is-rounded': props.rounded,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
30
resources/scripts-v2/components/layout/EmptyPlaceholder.vue
Normal file
30
resources/scripts-v2/components/layout/EmptyPlaceholder.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center mt-16">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="font-medium">{{ title }}</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="text-muted">
|
||||
{{ description }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
description: '',
|
||||
})
|
||||
</script>
|
||||
59
resources/scripts-v2/components/layout/FilterWrapper.vue
Normal file
59
resources/scripts-v2/components/layout/FilterWrapper.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<transition
|
||||
enter-active-class="transition duration-500 ease-in-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in-out"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-show="show" class="relative z-10 p-4 md:p-8 bg-surface-muted rounded">
|
||||
<slot name="filter-header" />
|
||||
|
||||
<label
|
||||
class="
|
||||
absolute
|
||||
text-sm
|
||||
leading-snug
|
||||
text-heading
|
||||
cursor-pointer
|
||||
hover:text-body
|
||||
top-2.5
|
||||
right-3.5
|
||||
"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ $t('general.clear_all') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="flex flex-col space-y-3"
|
||||
:class="
|
||||
rowOnXl
|
||||
? 'xl:flex-row xl:space-x-4 xl:space-y-0 xl:items-center'
|
||||
: 'lg:flex-row lg:space-x-4 lg:space-y-0 lg:items-center'
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show?: boolean
|
||||
rowOnXl?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'clear'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
show: false,
|
||||
rowOnXl: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
</script>
|
||||
8
resources/scripts-v2/components/layout/Page.vue
Normal file
8
resources/scripts-v2/components/layout/Page.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="flex-1 p-4 md:p-8 flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
21
resources/scripts-v2/components/layout/PageHeader.vue
Normal file
21
resources/scripts-v2/components/layout/PageHeader.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-left text-heading">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
38
resources/scripts-v2/components/layout/SettingCard.vue
Normal file
38
resources/scripts-v2/components/layout/SettingCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
11
resources/scripts-v2/components/layout/index.ts
Normal file
11
resources/scripts-v2/components/layout/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { default as Page } from './Page.vue'
|
||||
export { default as PageHeader } from './PageHeader.vue'
|
||||
export { default as Breadcrumb } from './Breadcrumb.vue'
|
||||
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
|
||||
export { default as FilterWrapper } from './FilterWrapper.vue'
|
||||
export { default as EmptyPlaceholder } from './EmptyPlaceholder.vue'
|
||||
export { default as ContentPlaceholder } from './ContentPlaceholder.vue'
|
||||
export { default as ContentPlaceholderBox } from './ContentPlaceholderBox.vue'
|
||||
export { default as ContentPlaceholderText } from './ContentPlaceholderText.vue'
|
||||
export { default as ContentPlaceholderHeading } from './ContentPlaceholderHeading.vue'
|
||||
export { default as SettingCard } from './SettingCard.vue'
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div
|
||||
:class="success || info ? 'bg-surface' : 'bg-alert-error-bg'"
|
||||
class="
|
||||
max-w-sm
|
||||
mb-3
|
||||
rounded-lg
|
||||
shadow-lg
|
||||
cursor-pointer
|
||||
pointer-events-auto
|
||||
w-full
|
||||
md:w-96
|
||||
"
|
||||
@click.stop="hideNotificationAction"
|
||||
@mouseenter="clearNotificationTimeOut"
|
||||
@mouseleave="setNotificationTimeOut"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="shrink-0">
|
||||
<svg
|
||||
v-if="success"
|
||||
class="w-6 h-6 text-alert-success-text"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-if="info"
|
||||
class="w-6 h-6 text-status-blue"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-if="error"
|
||||
class="w-6 h-6 text-alert-error-text"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 w-0 ml-3 text-left">
|
||||
<p
|
||||
:class="`text-sm leading-5 font-medium ${
|
||||
success || info ? 'text-heading' : 'text-alert-error-text'
|
||||
}`"
|
||||
>
|
||||
{{
|
||||
notification.title
|
||||
? notification.title
|
||||
: success
|
||||
? 'Success!'
|
||||
: 'Error'
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
:class="`mt-1 text-sm leading-5 ${
|
||||
success || info ? 'text-muted' : 'text-alert-error-text'
|
||||
}`"
|
||||
>
|
||||
{{
|
||||
notification.message
|
||||
? notification.message
|
||||
: success
|
||||
? 'Successful'
|
||||
: 'Something went wrong'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0">
|
||||
<button
|
||||
:class="
|
||||
success || info
|
||||
? ' text-subtle focus:text-muted'
|
||||
: 'text-alert-error-text focus:text-alert-error-text'
|
||||
"
|
||||
class="
|
||||
inline-flex
|
||||
w-5
|
||||
h-5
|
||||
transition
|
||||
duration-150
|
||||
ease-in-out
|
||||
focus:outline-hidden
|
||||
"
|
||||
@click="hideNotificationAction"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed, ref } from 'vue'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification'
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'info'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: NotificationType
|
||||
title?: string
|
||||
message?: string
|
||||
time?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
notification: Notification
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const notiTimeOut = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const success = computed<boolean>(() => {
|
||||
return props.notification.type === 'success'
|
||||
})
|
||||
|
||||
const error = computed<boolean>(() => {
|
||||
return props.notification.type === 'error'
|
||||
})
|
||||
|
||||
const info = computed<boolean>(() => {
|
||||
return props.notification.type === 'info'
|
||||
})
|
||||
|
||||
function hideNotificationAction(): void {
|
||||
notificationStore.hideNotification(props.notification)
|
||||
}
|
||||
|
||||
function clearNotificationTimeOut(): void {
|
||||
if (notiTimeOut.value) {
|
||||
clearTimeout(notiTimeOut.value)
|
||||
}
|
||||
}
|
||||
|
||||
function setNotificationTimeOut(): void {
|
||||
notiTimeOut.value = setTimeout(() => {
|
||||
notificationStore.hideNotification(props.notification)
|
||||
}, props.notification.time || 5000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setNotificationTimeOut()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div
|
||||
class="
|
||||
fixed
|
||||
inset-0
|
||||
z-50
|
||||
flex flex-col
|
||||
items-end
|
||||
justify-start
|
||||
w-full
|
||||
px-4
|
||||
py-6
|
||||
pointer-events-none
|
||||
sm:p-6
|
||||
"
|
||||
>
|
||||
<transition-group
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notification="notification"
|
||||
/>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification'
|
||||
import NotificationItem from './NotificationItem.vue'
|
||||
import type { Notification } from './NotificationItem.vue'
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const notifications = computed<Notification[]>(() => {
|
||||
return notificationStore.notifications
|
||||
})
|
||||
</script>
|
||||
4
resources/scripts-v2/components/notifications/index.ts
Normal file
4
resources/scripts-v2/components/notifications/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as NotificationRoot } from './NotificationRoot.vue'
|
||||
export { default as NotificationItem } from './NotificationItem.vue'
|
||||
|
||||
export type { Notification, NotificationType } from './NotificationItem.vue'
|
||||
484
resources/scripts-v2/components/table/DataTable.vue
Normal file
484
resources/scripts-v2/components/table/DataTable.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8 pb-4 lg:pb-0">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="
|
||||
relative
|
||||
overflow-hidden
|
||||
bg-surface/70 backdrop-blur-lg
|
||||
border border-white/15
|
||||
shadow-sm
|
||||
rounded-xl
|
||||
"
|
||||
>
|
||||
<slot name="header" />
|
||||
<table :class="tableClass">
|
||||
<thead :class="theadClass">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in tableColumns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
getThClass(column),
|
||||
{
|
||||
'text-bold text-heading': sort.fieldName === column.key,
|
||||
},
|
||||
]"
|
||||
@click="changeSorting(column)"
|
||||
>
|
||||
{{ column.label }}
|
||||
<span
|
||||
v-if="sort.fieldName === column.key && sort.order === 'asc'"
|
||||
class="asc-direction"
|
||||
>
|
||||
↑
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
sort.fieldName === column.key && sort.order === 'desc'
|
||||
"
|
||||
class="desc-direction"
|
||||
>
|
||||
↓
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
v-if="loadingType === 'placeholder' && (loading || isLoading)"
|
||||
>
|
||||
<tr
|
||||
v-for="placeRow in placeholderCount"
|
||||
:key="placeRow"
|
||||
:class="placeRow % 2 === 0 ? 'bg-surface' : 'bg-surface-secondary'"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="getTdClass(column)"
|
||||
>
|
||||
<ContentPlaceholder
|
||||
:class="getPlaceholderClass(column)"
|
||||
:rounded="true"
|
||||
>
|
||||
<ContentPlaceholderText
|
||||
class="w-full h-6"
|
||||
:lines="1"
|
||||
/>
|
||||
</ContentPlaceholder>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<tr
|
||||
v-for="(row, index) in sortedRows"
|
||||
:key="row.data?.id ?? index"
|
||||
:class="index % 2 === 0 ? 'bg-surface' : 'bg-surface-secondary'"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="getTdClass(column)"
|
||||
>
|
||||
<slot :name="'cell-' + column.key" :row="row">
|
||||
{{ lodashGet(row.data, column.key) }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-if="loadingType === 'spinner' && (loading || isLoading)"
|
||||
class="
|
||||
absolute
|
||||
top-0
|
||||
left-0
|
||||
z-10
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
w-full
|
||||
h-full
|
||||
bg-white/60
|
||||
"
|
||||
>
|
||||
<SpinnerIcon class="w-10 h-10 text-primary-500" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
!loading && !isLoading && sortedRows && sortedRows.length === 0
|
||||
"
|
||||
class="
|
||||
text-center text-muted
|
||||
pb-2
|
||||
flex
|
||||
h-[160px]
|
||||
justify-center
|
||||
items-center
|
||||
flex-col
|
||||
"
|
||||
>
|
||||
<BaseIcon
|
||||
name="ExclamationCircleIcon"
|
||||
class="w-6 h-6 text-subtle"
|
||||
/>
|
||||
|
||||
<span class="block mt-1">{{ $t('general.no_data_found') }}</span>
|
||||
</div>
|
||||
|
||||
<TablePagination
|
||||
v-if="pagination"
|
||||
:pagination="pagination"
|
||||
@pageChange="pageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch, ref, reactive } from 'vue'
|
||||
import { get } from 'lodash'
|
||||
import TablePagination from './TablePagination.vue'
|
||||
import { ContentPlaceholder, ContentPlaceholderText } from '../layout'
|
||||
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string
|
||||
label: string
|
||||
thClass?: string
|
||||
defaultThClass?: string
|
||||
tdClass?: string
|
||||
defaultTdClass?: string
|
||||
placeholderClass?: string
|
||||
sortBy?: string
|
||||
sortable?: boolean
|
||||
hidden?: boolean
|
||||
dataType?: string
|
||||
filterOn?: string
|
||||
}
|
||||
|
||||
interface TableColumn extends ColumnDef {
|
||||
sortable: boolean
|
||||
dataType: string
|
||||
}
|
||||
|
||||
export interface RowData {
|
||||
id?: number | string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface TableRow {
|
||||
data: RowData
|
||||
columns: TableColumn[]
|
||||
getValue(columnName: string): unknown
|
||||
getColumn(columnName: string): TableColumn | undefined
|
||||
getSortableValue(columnName: string): string | number
|
||||
}
|
||||
|
||||
export interface PaginationData {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
count: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface SortState {
|
||||
fieldName: string
|
||||
order: 'asc' | 'desc' | ''
|
||||
}
|
||||
|
||||
type ServerDataFn = (params: { sort: SortState; page: number }) => Promise<{
|
||||
data: RowData[]
|
||||
pagination: PaginationData
|
||||
}>
|
||||
|
||||
interface Props {
|
||||
columns: ColumnDef[]
|
||||
data: RowData[] | ServerDataFn
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
tableClass?: string
|
||||
theadClass?: string
|
||||
tbodyClass?: string
|
||||
noResultsMessage?: string
|
||||
loading?: boolean
|
||||
loadingType?: 'placeholder' | 'spinner'
|
||||
placeholderCount?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sortBy: '',
|
||||
sortOrder: '',
|
||||
tableClass: 'min-w-full divide-y divide-line-default',
|
||||
theadClass: 'bg-surface-secondary',
|
||||
tbodyClass: '',
|
||||
noResultsMessage: 'No Results Found',
|
||||
loading: false,
|
||||
loadingType: 'placeholder',
|
||||
placeholderCount: 3,
|
||||
})
|
||||
|
||||
function createColumn(columnObj: ColumnDef): TableColumn {
|
||||
const col: TableColumn = {
|
||||
...columnObj,
|
||||
dataType: columnObj.dataType ?? 'string',
|
||||
sortable: columnObj.sortable ?? true,
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
function createRow(data: RowData, columns: TableColumn[]): TableRow {
|
||||
return {
|
||||
data,
|
||||
columns,
|
||||
getValue(columnName: string): unknown {
|
||||
return getNestedValue(data, columnName)
|
||||
},
|
||||
getColumn(columnName: string): TableColumn | undefined {
|
||||
return columns.find((c) => c.key === columnName)
|
||||
},
|
||||
getSortableValue(columnName: string): string | number {
|
||||
const col = columns.find((c) => c.key === columnName)
|
||||
if (!col) return ''
|
||||
const dataType = col.dataType
|
||||
let value: unknown = getNestedValue(data, columnName)
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = value.toLowerCase()
|
||||
}
|
||||
|
||||
if (dataType === 'numeric') {
|
||||
return value as number
|
||||
}
|
||||
|
||||
return String(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(object: unknown, path: string): unknown {
|
||||
if (!path) return object
|
||||
if (object === null || typeof object !== 'object') return object
|
||||
const [head, ...rest] = path.split('.')
|
||||
return getNestedValue((object as Record<string, unknown>)[head], rest.join('.'))
|
||||
}
|
||||
|
||||
function getSortPredicate(
|
||||
column: TableColumn,
|
||||
sortOrder: string,
|
||||
allColumns: TableColumn[]
|
||||
): (a: TableRow, b: TableRow) => number {
|
||||
const sortFieldName = column.sortBy || column.key
|
||||
const sortColumn = allColumns.find((c) => c.key === sortFieldName)
|
||||
if (!sortColumn) return () => 0
|
||||
const dataType = sortColumn.dataType
|
||||
|
||||
if (dataType.startsWith('date') || dataType === 'numeric') {
|
||||
return (row1: TableRow, row2: TableRow) => {
|
||||
const value1 = row1.getSortableValue(sortFieldName)
|
||||
const value2 = row2.getSortableValue(sortFieldName)
|
||||
if (sortOrder === 'desc') {
|
||||
return value2 < value1 ? -1 : 1
|
||||
}
|
||||
return value1 < value2 ? -1 : 1
|
||||
}
|
||||
}
|
||||
|
||||
return (row1: TableRow, row2: TableRow) => {
|
||||
const value1 = String(row1.getSortableValue(sortFieldName))
|
||||
const value2 = String(row2.getSortableValue(sortFieldName))
|
||||
if (sortOrder === 'desc') {
|
||||
return value2.localeCompare(value1)
|
||||
}
|
||||
return value1.localeCompare(value2)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = ref<TableRow[]>([])
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
const tableColumns = reactive<TableColumn[]>(
|
||||
props.columns.map((column) => createColumn(column))
|
||||
)
|
||||
|
||||
const sort = reactive<SortState>({
|
||||
fieldName: '',
|
||||
order: '',
|
||||
})
|
||||
|
||||
const pagination = ref<PaginationData | null>(null)
|
||||
|
||||
const usesLocalData = computed<boolean>(() => {
|
||||
return Array.isArray(props.data)
|
||||
})
|
||||
|
||||
const sortedRows = computed<TableRow[]>(() => {
|
||||
if (!usesLocalData.value) {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
if (sort.fieldName === '') {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
if (tableColumns.length === 0) {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
const sortColumn = tableColumns.find((c) => c.key === sort.fieldName)
|
||||
|
||||
if (!sortColumn) {
|
||||
return rows.value
|
||||
}
|
||||
|
||||
const sorted = [...rows.value].sort(
|
||||
getSortPredicate(sortColumn, sort.order, tableColumns)
|
||||
)
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
function getThClass(column: TableColumn): string {
|
||||
let classes =
|
||||
'whitespace-nowrap px-6 py-3 text-left text-xs font-medium text-muted uppercase tracking-wider'
|
||||
|
||||
if (column.defaultThClass) {
|
||||
classes = column.defaultThClass
|
||||
}
|
||||
|
||||
if (column.sortable) {
|
||||
classes = `${classes} cursor-pointer`
|
||||
} else {
|
||||
classes = `${classes} pointer-events-none`
|
||||
}
|
||||
|
||||
if (column.thClass) {
|
||||
classes = `${classes} ${column.thClass}`
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function getTdClass(column: ColumnDef): string {
|
||||
let classes = 'px-6 py-4 text-sm text-muted whitespace-nowrap'
|
||||
|
||||
if (column.defaultTdClass) {
|
||||
classes = column.defaultTdClass
|
||||
}
|
||||
|
||||
if (column.tdClass) {
|
||||
classes = `${classes} ${column.tdClass}`
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function getPlaceholderClass(column: ColumnDef): string {
|
||||
let classes = 'w-full'
|
||||
|
||||
if (column.placeholderClass) {
|
||||
classes = `${classes} ${column.placeholderClass}`
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
function prepareLocalData(): RowData[] {
|
||||
pagination.value = null
|
||||
return props.data as RowData[]
|
||||
}
|
||||
|
||||
async function fetchServerData(): Promise<RowData[] | null> {
|
||||
const page = pagination.value?.currentPage ?? 1
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const response = await (props.data as ServerDataFn)({
|
||||
sort,
|
||||
page,
|
||||
})
|
||||
|
||||
isLoading.value = false
|
||||
|
||||
const currentPage = pagination.value?.currentPage ?? 1
|
||||
if (page !== currentPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
pagination.value = response.pagination
|
||||
return response.data
|
||||
}
|
||||
|
||||
function changeSorting(column: TableColumn): void {
|
||||
if (sort.fieldName !== column.key) {
|
||||
sort.fieldName = column.key
|
||||
sort.order = 'asc'
|
||||
} else {
|
||||
sort.order = sort.order === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
if (!usesLocalData.value) {
|
||||
if (pagination.value) {
|
||||
pagination.value.currentPage = 1
|
||||
}
|
||||
mapDataToRows()
|
||||
}
|
||||
}
|
||||
|
||||
async function mapDataToRows(): Promise<void> {
|
||||
let data: RowData[] | null
|
||||
|
||||
if (usesLocalData.value) {
|
||||
data = prepareLocalData()
|
||||
} else {
|
||||
data = await fetchServerData()
|
||||
if (data === null) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rows.value = data.map((rowData) => createRow(rowData, tableColumns))
|
||||
}
|
||||
|
||||
async function pageChange(page: number): Promise<void> {
|
||||
if (pagination.value) {
|
||||
pagination.value.currentPage = page
|
||||
}
|
||||
await mapDataToRows()
|
||||
}
|
||||
|
||||
async function refresh(isPreservePage = false): Promise<void> {
|
||||
if (pagination.value && !isPreservePage) {
|
||||
pagination.value.currentPage = 1
|
||||
}
|
||||
await mapDataToRows()
|
||||
}
|
||||
|
||||
function lodashGet(obj: unknown, key: string): unknown {
|
||||
return get(obj, key)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
mapDataToRows()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await mapDataToRows()
|
||||
})
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
372
resources/scripts-v2/components/table/TablePagination.vue
Normal file
372
resources/scripts-v2/components/table/TablePagination.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShowPagination"
|
||||
class="
|
||||
flex
|
||||
items-center
|
||||
justify-between
|
||||
px-4
|
||||
py-3
|
||||
bg-surface
|
||||
border-t border-line-default
|
||||
sm:px-6
|
||||
"
|
||||
>
|
||||
<div class="flex justify-between flex-1 sm:hidden">
|
||||
<a
|
||||
href="#"
|
||||
:class="{
|
||||
'disabled cursor-normal pointer-events-none !bg-surface-tertiary !text-subtle':
|
||||
pagination.currentPage === 1,
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-body
|
||||
bg-surface
|
||||
border border-line-default
|
||||
rounded-md
|
||||
hover:bg-hover
|
||||
"
|
||||
@click="pageClicked(pagination.currentPage - 1)"
|
||||
>
|
||||
{{ $t('general.pagination.previous') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
:class="{
|
||||
'disabled cursor-default pointer-events-none !bg-surface-tertiary !text-subtle':
|
||||
pagination.currentPage === pagination.totalPages,
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
ml-3
|
||||
text-sm
|
||||
font-medium
|
||||
text-body
|
||||
bg-surface
|
||||
border border-line-default
|
||||
rounded-md
|
||||
hover:bg-hover
|
||||
"
|
||||
@click="pageClicked(pagination.currentPage + 1)"
|
||||
>
|
||||
{{ $t('general.pagination.next') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-body">
|
||||
{{ $t('general.pagination.showing') }}
|
||||
{{ ' ' }}
|
||||
<span
|
||||
v-if="pagination.limit && pagination.currentPage"
|
||||
class="font-medium"
|
||||
>
|
||||
{{
|
||||
pagination.currentPage * pagination.limit - (pagination.limit - 1)
|
||||
}}
|
||||
</span>
|
||||
{{ ' ' }}
|
||||
{{ $t('general.pagination.to') }}
|
||||
{{ ' ' }}
|
||||
<span
|
||||
v-if="pagination.limit && pagination.currentPage"
|
||||
class="font-medium"
|
||||
>
|
||||
<span
|
||||
v-if="
|
||||
pagination.currentPage * pagination.limit <=
|
||||
pagination.totalCount
|
||||
"
|
||||
>
|
||||
{{ pagination.currentPage * pagination.limit }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ pagination.totalCount }}
|
||||
</span>
|
||||
</span>
|
||||
{{ ' ' }}
|
||||
{{ $t('general.pagination.of') }}
|
||||
{{ ' ' }}
|
||||
<span v-if="pagination.totalCount" class="font-medium">
|
||||
{{ pagination.totalCount }}
|
||||
</span>
|
||||
{{ ' ' }}
|
||||
{{ $t('general.pagination.results') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav
|
||||
class="relative z-0 inline-flex -space-x-px rounded-lg shadow-sm"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
:class="{
|
||||
'disabled cursor-normal pointer-events-none !bg-surface-tertiary !text-subtle':
|
||||
pagination.currentPage === 1,
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-2
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-muted
|
||||
bg-surface
|
||||
border border-line-default
|
||||
rounded-l-lg
|
||||
hover:bg-hover
|
||||
"
|
||||
@click="pageClicked(pagination.currentPage - 1)"
|
||||
>
|
||||
<span class="sr-only">Previous</span>
|
||||
<BaseIcon name="ChevronLeftIcon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="hasFirst"
|
||||
href="#"
|
||||
aria-current="page"
|
||||
:class="{
|
||||
'z-10 bg-primary-500 border-primary-500 text-white':
|
||||
isActive(1),
|
||||
'bg-surface border-line-default text-muted hover:bg-hover':
|
||||
!isActive(1),
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
border
|
||||
"
|
||||
@click="pageClicked(1)"
|
||||
>
|
||||
1
|
||||
</a>
|
||||
|
||||
<span
|
||||
v-if="hasFirstEllipsis"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-body
|
||||
bg-surface
|
||||
border border-line-default
|
||||
"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<a
|
||||
v-for="page in pages"
|
||||
:key="page"
|
||||
href="#"
|
||||
:class="{
|
||||
'z-10 bg-primary-500 border-primary-500 text-white':
|
||||
isActive(page),
|
||||
'bg-surface border-line-default text-muted hover:bg-hover':
|
||||
!isActive(page),
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
items-center
|
||||
hidden
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
border
|
||||
md:inline-flex
|
||||
"
|
||||
@click="pageClicked(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</a>
|
||||
|
||||
<span
|
||||
v-if="hasLastEllipsis"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-body
|
||||
bg-surface
|
||||
border border-line-default
|
||||
"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<a
|
||||
v-if="hasLast"
|
||||
href="#"
|
||||
aria-current="page"
|
||||
:class="{
|
||||
'z-10 bg-primary-500 border-primary-500 text-white':
|
||||
isActive(pagination.totalPages),
|
||||
'bg-surface border-line-default text-muted hover:bg-hover':
|
||||
!isActive(pagination.totalPages),
|
||||
}"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-4
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
border
|
||||
"
|
||||
@click="pageClicked(pagination.totalPages)"
|
||||
>
|
||||
{{ pagination.totalPages }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="
|
||||
relative
|
||||
inline-flex
|
||||
items-center
|
||||
px-2
|
||||
py-2
|
||||
text-sm
|
||||
font-medium
|
||||
text-muted
|
||||
bg-surface
|
||||
border border-line-default
|
||||
rounded-r-lg
|
||||
hover:bg-hover
|
||||
"
|
||||
:class="{
|
||||
'disabled cursor-default pointer-events-none !bg-surface-tertiary !text-subtle':
|
||||
pagination.currentPage === pagination.totalPages,
|
||||
}"
|
||||
@click="pageClicked(pagination.currentPage + 1)"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<BaseIcon name="ChevronRightIcon" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface PaginationInfo {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
count: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pagination: PaginationInfo
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'pageChange', page: number): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const pages = computed<number[]>(() => {
|
||||
if (props.pagination.totalPages === undefined) return []
|
||||
return pageLinks()
|
||||
})
|
||||
|
||||
const hasFirst = computed<boolean>(() => {
|
||||
return props.pagination.currentPage >= 4 || props.pagination.totalPages < 10
|
||||
})
|
||||
|
||||
const hasLast = computed<boolean>(() => {
|
||||
return (
|
||||
props.pagination.currentPage <= props.pagination.totalPages - 3 ||
|
||||
props.pagination.totalPages < 10
|
||||
)
|
||||
})
|
||||
|
||||
const hasFirstEllipsis = computed<boolean>(() => {
|
||||
return (
|
||||
props.pagination.currentPage >= 4 && props.pagination.totalPages >= 10
|
||||
)
|
||||
})
|
||||
|
||||
const hasLastEllipsis = computed<boolean>(() => {
|
||||
return (
|
||||
props.pagination.currentPage <= props.pagination.totalPages - 3 &&
|
||||
props.pagination.totalPages >= 10
|
||||
)
|
||||
})
|
||||
|
||||
const shouldShowPagination = computed<boolean>(() => {
|
||||
if (props.pagination.totalPages === undefined) {
|
||||
return false
|
||||
}
|
||||
if (props.pagination.count === 0) {
|
||||
return false
|
||||
}
|
||||
return props.pagination.totalPages > 1
|
||||
})
|
||||
|
||||
function isActive(page: number): boolean {
|
||||
const currentPage = props.pagination.currentPage || 1
|
||||
return currentPage === page
|
||||
}
|
||||
|
||||
function pageClicked(page: number): void {
|
||||
if (
|
||||
page === props.pagination.currentPage ||
|
||||
page > props.pagination.totalPages ||
|
||||
page < 1
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('pageChange', page)
|
||||
}
|
||||
|
||||
function pageLinks(): number[] {
|
||||
const pageList: number[] = []
|
||||
let left = 2
|
||||
let right = props.pagination.totalPages - 1
|
||||
if (props.pagination.totalPages >= 10) {
|
||||
left = Math.max(1, props.pagination.currentPage - 2)
|
||||
right = Math.min(
|
||||
props.pagination.currentPage + 2,
|
||||
props.pagination.totalPages
|
||||
)
|
||||
}
|
||||
for (let i = left; i <= right; i++) {
|
||||
pageList.push(i)
|
||||
}
|
||||
return pageList
|
||||
}
|
||||
</script>
|
||||
5
resources/scripts-v2/components/table/index.ts
Normal file
5
resources/scripts-v2/components/table/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as DataTable } from './DataTable.vue'
|
||||
export { default as TablePagination } from './TablePagination.vue'
|
||||
|
||||
export type { ColumnDef, RowData, PaginationData } from './DataTable.vue'
|
||||
export type { PaginationInfo } from './TablePagination.vue'
|
||||
Reference in New Issue
Block a user