Add missing base components and global alias registrations

Create BaseCustomTag (dynamic tag render), BaseFormatMoney,
BaseHeading, BaseScrollPane, BaseDescriptionList/Item, BaseLabel,
BaseCustomerSelectInput, BaseSpinner, BaseRating, and status label
components. Register all renamed v2 components under their old
Base* names (BaseInputGroup->FormGroup, BasePage->Page,
BaseTable->DataTable, etc.) so templates resolve correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 10:45:00 +02:00
parent 97f88eaf2c
commit cab785172e
15 changed files with 651 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { h, defineComponent } from 'vue'
export default defineComponent({
name: 'BaseCustomTag',
props: {
tag: {
type: String,
default: 'button',
},
},
setup(props, { slots, attrs }) {
return () => h(props.tag, attrs, slots)
},
})
</script>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '../../features/company/customers/store'
import { useModalStore } from '../../stores/modal.store'
import { useUserStore } from '../../stores/user.store'
import { ABILITIES } from '../../config/abilities'
import CustomerModal from '../../features/company/customers/components/CustomerModal.vue'
interface Props {
modelValue?: string | number | Record<string, unknown> | null
fetchAll?: boolean
showAction?: boolean
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
fetchAll: false,
showAction: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | Record<string, unknown> | null): void
}>()
const { t } = useI18n()
const modalStore = useModalStore()
const customerStore = useCustomerStore()
const userStore = useUserStore()
const selectedCustomer = computed({
get: () => props.modelValue,
set: (value) => {
emit('update:modelValue', value as string | number | Record<string, unknown> | null)
},
})
async function searchCustomers(search: string) {
const data: Record<string, unknown> = { search }
if (props.fetchAll) {
data.limit = 'all'
}
const response = await customerStore.fetchCustomers(data)
const results = response.data ?? []
if (results.length > 0 && customerStore.editCustomer) {
const customerFound = results.find(
(c: Record<string, unknown>) => c.id === customerStore.editCustomer?.id
)
if (!customerFound) {
const editCopy = { ...customerStore.editCustomer }
results.unshift(editCopy as typeof results[0])
}
}
return results
}
function addCustomer(): void {
customerStore.resetCurrentCustomer()
modalStore.openModal({
title: t('customers.add_new_customer'),
componentName: 'CustomerModal',
})
}
</script>
<template>
<BaseMultiselect
v-model="selectedCustomer"
v-bind="$attrs"
track-by="name"
value-prop="id"
label="name"
:filter-results="false"
resolve-on-load
:delay="500"
:searchable="true"
:options="searchCustomers"
label-value="name"
:placeholder="$t('customers.type_or_click')"
:can-deselect="false"
class="w-full"
>
<template v-if="showAction" #action>
<BaseSelectAction
v-if="userStore.hasAbilities(ABILITIES.CREATE_CUSTOMER)"
@click="addCustomer"
>
<BaseIcon
name="UserPlusIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('customers.add_new_customer') }}
</BaseSelectAction>
</template>
</BaseMultiselect>
<CustomerModal />
</template>

View File

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

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
interface Props {
label: string
value?: string | number
contentLoading?: boolean
}
withDefaults(defineProps<Props>(), {
value: '',
contentLoading: false,
})
</script>
<template>
<div>
<BaseContentPlaceholders v-if="contentLoading">
<BaseContentPlaceholdersBox class="w-20 h-5 mb-1" />
<BaseContentPlaceholdersBox class="w-40 h-5" />
</BaseContentPlaceholders>
<div v-else>
<BaseLabel class="font-normal mb-1">
{{ label }}
</BaseLabel>
<p class="text-sm font-bold leading-5 text-heading non-italic">
{{ value }}
<slot />
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { EstimateStatus } from '@v2/types/domain'
interface Props {
status?: string
}
const props = withDefaults(defineProps<Props>(), {
status: '',
})
const { t } = useI18n()
const labelStatus = computed<string>(() => {
switch (props.status) {
case EstimateStatus.DRAFT:
case 'DRAFT':
return t('estimates.draft')
case EstimateStatus.SENT:
case 'SENT':
return t('estimates.sent')
case EstimateStatus.VIEWED:
case 'VIEWED':
return t('estimates.viewed')
case EstimateStatus.EXPIRED:
case 'EXPIRED':
return t('estimates.expired')
case EstimateStatus.ACCEPTED:
case 'ACCEPTED':
return t('estimates.accepted')
case EstimateStatus.REJECTED:
case 'REJECTED':
return t('estimates.rejected')
default:
return props.status
}
})
</script>
<template>
{{ labelStatus }}
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import { formatMoney } from '../../utils/format-money'
import type { CurrencyConfig } from '../../utils/format-money'
interface Props {
amount: number | string
currency?: CurrencyConfig | null
}
const props = withDefaults(defineProps<Props>(), {
currency: null,
})
const companyStore = useCompanyStore()
const formattedAmount = computed<string>(() => {
const amountNum = typeof props.amount === 'string' ? Number(props.amount) : props.amount
const currencyConfig = props.currency ?? companyStore.selectedCompanyCurrency
return formatMoney(amountNum, currencyConfig ?? undefined)
})
</script>
<template>
<span style="font-family: sans-serif">{{ formattedAmount }}</span>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
type?: 'section-title' | 'heading-title'
}
const props = withDefaults(defineProps<Props>(), {
type: 'section-title',
})
const typeClass = computed<Record<string, boolean>>(() => ({
'text-heading text-lg font-medium': props.type === 'heading-title',
'text-muted uppercase text-base': props.type === 'section-title',
}))
</script>
<template>
<h6 :class="typeClass">
<slot />
</h6>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { InvoiceStatus, InvoicePaidStatus } from '@v2/types/domain'
interface Props {
status?: string
}
const props = withDefaults(defineProps<Props>(), {
status: '',
})
const { t } = useI18n()
const labelStatus = computed<string>(() => {
switch (props.status) {
case InvoiceStatus.DRAFT:
case 'DRAFT':
return t('general.draft')
case InvoiceStatus.SENT:
case 'SENT':
return t('general.sent')
case InvoiceStatus.VIEWED:
case 'VIEWED':
return t('invoices.viewed')
case InvoiceStatus.COMPLETED:
case 'COMPLETED':
return t('invoices.completed')
case 'DUE':
return t('general.due')
case 'OVERDUE':
return t('invoices.overdue')
case InvoicePaidStatus.UNPAID:
case 'UNPAID':
return t('invoices.unpaid')
case InvoicePaidStatus.PARTIALLY_PAID:
case 'PARTIALLY_PAID':
return t('invoices.partially_paid')
case InvoicePaidStatus.PAID:
case 'PAID':
return t('invoices.paid')
default:
return props.status
}
})
</script>
<template>
{{ labelStatus }}
</template>

View File

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

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { ref, computed, onBeforeMount } from 'vue'
interface RatingConfig {
style?: Partial<RatingStyle>
isIndicatorActive?: boolean
}
interface RatingStyle {
fullStarColor: string
emptyStarColor: string
starWidth: number
starHeight: number
}
interface StarData {
raw: number
percent: string
}
interface Props {
config?: RatingConfig | null
rating?: number
}
const props = withDefaults(defineProps<Props>(), {
config: null,
rating: 0,
})
const EMPTY_STAR = 0
const FULL_STAR = 1
const TOTAL_STARS = 5
const stars = ref<StarData[]>([])
const isIndicatorActive = ref<boolean>(false)
const style = ref<RatingStyle>({
fullStarColor: '#F1C644',
emptyStarColor: '#D4D4D4',
starWidth: 20,
starHeight: 20,
})
const getStarPoints = computed<string>(() => {
const centerX = style.value.starWidth / 2
const centerY = style.value.starHeight / 2
const innerCircleArms = 5
const innerRadius = style.value.starWidth / innerCircleArms
const innerOuterRadiusRatio = 2.5
const outerRadius = innerRadius * innerOuterRadiusRatio
return calcStarPoints(centerX, centerY, innerCircleArms, innerRadius, outerRadius)
})
function calcStarPoints(
centerX: number,
centerY: number,
innerCircleArms: number,
innerRadius: number,
outerRadius: number
): string {
const angle = Math.PI / innerCircleArms
const angleOffsetToCenterStar = 60
const totalArms = innerCircleArms * 2
let points = ''
for (let i = 0; i < totalArms; i++) {
const isEvenIndex = i % 2 === 0
const r = isEvenIndex ? outerRadius : innerRadius
const currX = centerX + Math.cos(i * angle + angleOffsetToCenterStar) * r
const currY = centerY + Math.sin(i * angle + angleOffsetToCenterStar) * r
points += currX + ',' + currY + ' '
}
return points
}
function calcStarFullness(starData: StarData): string {
return starData.raw * 100 + '%'
}
function getFullFillColor(starData: StarData): string {
return starData.raw !== EMPTY_STAR
? style.value.fullStarColor
: style.value.emptyStarColor
}
function initStars(): void {
for (let i = 0; i < TOTAL_STARS; i++) {
stars.value.push({
raw: EMPTY_STAR,
percent: EMPTY_STAR + '%',
})
}
}
function setStars(): void {
let fullStarsCounter = Math.floor(props.rating)
for (let i = 0; i < stars.value.length; i++) {
if (fullStarsCounter !== 0) {
stars.value[i].raw = FULL_STAR
stars.value[i].percent = calcStarFullness(stars.value[i])
fullStarsCounter--
} else {
const surplus = Math.round((props.rating % 1) * 10) / 10
const roundedOneDecimalPoint = Math.round(surplus * 10) / 10
stars.value[i].raw = roundedOneDecimalPoint
stars.value[i].percent = calcStarFullness(stars.value[i])
return
}
}
}
function setConfigData(): void {
if (props.config) {
if (props.config.style?.fullStarColor) {
style.value.fullStarColor = props.config.style.fullStarColor
}
if (props.config.style?.emptyStarColor) {
style.value.emptyStarColor = props.config.style.emptyStarColor
}
if (props.config.style?.starWidth) {
style.value.starWidth = props.config.style.starWidth
}
if (props.config.style?.starHeight) {
style.value.starHeight = props.config.style.starHeight
}
if (props.config.isIndicatorActive) {
isIndicatorActive.value = props.config.isIndicatorActive
}
}
}
onBeforeMount(() => {
initStars()
setStars()
setConfigData()
})
</script>
<template>
<div class="star-rating">
<div
v-for="(star, index) in stars"
:key="index"
:title="String(rating)"
class="star-container"
>
<svg
:style="[
{ fill: `url(#gradient${star.raw})` },
{ width: style.starWidth },
{ height: style.starHeight },
]"
class="star-svg"
>
<polygon :points="getStarPoints" style="fill-rule: nonzero" />
<defs>
<linearGradient :id="`gradient${star.raw}`">
<stop
id="stop1"
:offset="star.percent"
:stop-color="getFullFillColor(star)"
stop-opacity="1"
/>
<stop
id="stop2"
:offset="star.percent"
:stop-color="getFullFillColor(star)"
stop-opacity="0"
/>
<stop
id="stop3"
:offset="star.percent"
:stop-color="style.emptyStarColor"
stop-opacity="1"
/>
<stop
id="stop4"
:stop-color="style.emptyStarColor"
offset="100%"
stop-opacity="1"
/>
</linearGradient>
</defs>
</svg>
</div>
<div v-if="isIndicatorActive" class="indicator">{{ rating }}</div>
</div>
</template>
<style scoped>
.star-rating {
display: flex;
align-items: center;
.star-container {
display: flex;
}
.star-container:not(:last-child) {
margin-right: 5px;
}
}
</style>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { RecurringInvoiceStatus } from '@v2/types/domain'
interface Props {
status?: string
}
const props = withDefaults(defineProps<Props>(), {
status: '',
})
const { t } = useI18n()
const labelStatus = computed<string>(() => {
switch (props.status) {
case RecurringInvoiceStatus.COMPLETED:
case 'COMPLETED':
return t('recurring_invoices.complete')
case RecurringInvoiceStatus.ON_HOLD:
case 'ON_HOLD':
return t('recurring_invoices.on_hold')
case RecurringInvoiceStatus.ACTIVE:
case 'ACTIVE':
return t('recurring_invoices.active')
default:
return props.status
}
})
</script>
<template>
{{ labelStatus }}
</template>

View File

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

View File

@@ -0,0 +1,22 @@
<template>
<svg
class="animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</template>

View File

@@ -3,29 +3,41 @@ export { default as BaseButton } from './BaseButton.vue'
export { default as BaseCard } from './BaseCard.vue'
export { default as BaseCheckbox } from './BaseCheckbox.vue'
export { default as BaseCustomerAddressDisplay } from './BaseCustomerAddressDisplay.vue'
export { default as BaseCustomerSelectInput } from './BaseCustomerSelectInput.vue'
export { default as BaseCustomerSelectPopup } from './BaseCustomerSelectPopup.vue'
export { default as BaseCustomInput } from './BaseCustomInput.vue'
export { default as BaseDatePicker } from './BaseDatePicker.vue'
export { default as BaseDescriptionList } from './BaseDescriptionList.vue'
export { default as BaseDescriptionListItem } from './BaseDescriptionListItem.vue'
export { default as BaseDialog } from './BaseDialog.vue'
export { default as BaseDivider } from './BaseDivider.vue'
export { default as BaseDropdown } from './BaseDropdown.vue'
export { default as BaseDropdownItem } from './BaseDropdownItem.vue'
export { default as BaseErrorAlert } from './BaseErrorAlert.vue'
export { default as BaseEstimateStatusLabel } from './BaseEstimateStatusLabel.vue'
export { default as BaseFileUploader } from './BaseFileUploader.vue'
export { default as BaseFormatMoney } from './BaseFormatMoney.vue'
export { default as BaseGlobalLoader } from './BaseGlobalLoader.vue'
export { default as BaseHeading } from './BaseHeading.vue'
export { default as BaseIcon } from './BaseIcon.vue'
export { default as BaseInfoAlert } from './BaseInfoAlert.vue'
export { default as BaseInput } from './BaseInput.vue'
export { default as BaseInvoiceStatusLabel } from './BaseInvoiceStatusLabel.vue'
export { default as BaseItemSelect } from './BaseItemSelect.vue'
export { default as BaseLabel } from './BaseLabel.vue'
export { default as BaseList } from './BaseList.vue'
export { default as BaseListItem } from './BaseListItem.vue'
export { default as BaseMoney } from './BaseMoney.vue'
export { default as BaseModal } from './BaseModal.vue'
export { default as BaseMultiselect } from './BaseMultiselect.vue'
export { default as BaseRadio } from './BaseRadio.vue'
export { default as BaseRating } from './BaseRating.vue'
export { default as BaseRecurringInvoiceStatusLabel } from './BaseRecurringInvoiceStatusLabel.vue'
export { default as BaseScrollPane } from './BaseScrollPane.vue'
export { default as BaseSelectAction } from './BaseSelectAction.vue'
export { default as BaseSelectInput } from './BaseSelectInput.vue'
export { default as BaseSettingCard } from './BaseSettingCard.vue'
export { default as BaseSpinner } from './BaseSpinner.vue'
export { default as BaseSwitch } from './BaseSwitch.vue'
export { default as BaseTabGroup } from './BaseTabGroup.vue'
export { default as BaseText } from './BaseText.vue'