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:
Darko Gjorgjijoski
2026-04-04 05:45:00 +02:00
parent 2b996d30bf
commit e43e515614
72 changed files with 7144 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -0,0 +1,178 @@
<template>
<TransitionRoot as="template" :show="dialogStore.active">
<Dialog
as="div"
static
class="fixed inset-0 z-20 overflow-y-auto"
:open="dialogStore.active"
@close="dialogStore.closeDialog"
>
<div
class="
flex
items-end
justify-center
min-h-screen
px-4
pt-4
pb-20
text-center
sm:block sm:p-0
"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay
class="fixed inset-0 transition-opacity bg-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"
>&#8203;</span
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
class="
inline-block
px-4
pt-5
pb-4
overflow-hidden
text-left
align-bottom
transition-all
bg-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>

View File

@@ -0,0 +1,6 @@
<template>
<hr class="w-full border-line-light" />
</template>
<script setup lang="ts">
</script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,8 @@
<template>
<div class="list-none">
<slot />
</div>
</template>
<script setup lang="ts">
</script>

View 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>

View 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"
>&#8203;</span
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
:class="`inline-block
align-middle
bg-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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,69 @@
<template>
<SwitchGroup>
<div class="flex flex-row items-start">
<SwitchLabel v-if="labelLeft" class="mr-4 cursor-pointer">{{
labelLeft
}}</SwitchLabel>
<Switch
v-model="enabled"
:class="enabled ? 'bg-primary-500' : 'bg-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>

View 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>

View 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>

View 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>

View 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>

View File

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

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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'