Files
panel/resources/js/views/dashboards/ecommerce/EcommerceGeneratedLeads.vue

594 lines
17 KiB
Vue
Raw Normal View History

2025-08-04 16:33:07 +03:30
<script setup>
import { useDisplay, useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
2025-09-24 12:12:33 +03:30
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
2025-08-04 16:33:07 +03:30
const props = defineProps({
2025-09-24 12:12:33 +03:30
apiUrl: {
type: String,
required: true
},
apiHeaders: {
type: Object,
default: () => ({})
},
dataKey: {
type: String,
default: 'data'
},
sumFields: {
type: Array,
default: () => ['cost']
},
sumField: {
type: String,
default: 'cost'
},
nameField: {
type: String,
default: 'name'
},
title: {
type: String,
default: 'Generated Leads'
},
subtitle: {
type: String,
default: 'Total Summary'
},
currency: {
type: String,
default: '$'
},
showPercentage: {
type: Boolean,
default: true
},
previousValue: {
type: Number,
default: null
},
displayMode: {
type: String,
default: 'single',
validator: (value) => ['single', 'multiple'].includes(value)
},
2025-08-04 16:33:07 +03:30
donutColors: {
type: Array,
2025-09-24 12:12:33 +03:30
default: () => ['success']
2025-08-04 16:33:07 +03:30
},
progress: {
type: Number,
2025-09-24 12:12:33 +03:30
default: null
},
target: {
type: Number,
default: null
},
refreshInterval: {
type: Number,
default: 0
},
roundNumbers: {
type: Boolean,
default: false
2025-08-04 16:33:07 +03:30
}
})
2025-09-24 12:12:33 +03:30
const emit = defineEmits(['dataLoaded', 'error', 'loading'])
2025-08-04 16:33:07 +03:30
const vuetifyTheme = useTheme()
const display = useDisplay()
const cardRef = ref(null)
2025-09-15 15:46:14 +03:30
const cardWidth = ref(300)
2025-08-04 16:33:07 +03:30
2025-09-24 12:12:33 +03:30
const isLoading = ref(false)
const error = ref(null)
const apiData = ref([])
const totalValue = ref(0)
const multipleValues = ref({})
const previousTotal = ref(0)
const refreshTimer = ref(null)
const effectiveSumFields = computed(() => {
if (props.displayMode === 'multiple' && props.sumFields.length > 0) {
return props.sumFields
}
return [props.sumField]
})
const fetchData = async () => {
try {
isLoading.value = true
error.value = null
emit('loading', true)
const response = await fetch(props.apiUrl, {
headers: {
'Content-Type': 'application/json',
...props.apiHeaders
}
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const responseData = await response.json()
const dataArray = responseData[props.dataKey] || responseData
if (!Array.isArray(dataArray)) {
throw new Error('Response data is not an array')
}
if (totalValue.value > 0) {
previousTotal.value = totalValue.value
}
apiData.value = dataArray
const newMultipleValues = {}
let newTotalValue = 0
effectiveSumFields.value.forEach(field => {
const fieldTotal = dataArray.reduce((sum, item) => {
const value = parseFloat(item[field]) || 0
return sum + value
}, 0)
newMultipleValues[field] = props.roundNumbers ? Math.round(fieldTotal) : fieldTotal
if (field === effectiveSumFields.value[0]) {
newTotalValue = newMultipleValues[field]
}
})
if (props.displayMode === 'multiple') {
newTotalValue = Object.values(newMultipleValues).reduce((sum, val) => sum + val, 0)
}
multipleValues.value = newMultipleValues
totalValue.value = newTotalValue
emit('dataLoaded', {
data: dataArray,
total: newTotalValue,
breakdown: newMultipleValues,
count: dataArray.length
})
} catch (err) {
error.value = err.message
emit('error', err.message)
} finally {
isLoading.value = false
emit('loading', false)
}
}
const setupRefresh = () => {
if (refreshTimer.value) {
clearInterval(refreshTimer.value)
}
if (props.refreshInterval > 0) {
refreshTimer.value = setInterval(fetchData, props.refreshInterval)
}
}
const formatCurrency = (value) => {
if (typeof value !== 'number') return `${props.currency}0`
const roundedValue = Math.round(value)
return `${props.currency}${roundedValue.toLocaleString('en-US')}`
}
const formatFieldName = (field) => {
return field.charAt(0).toUpperCase() + field.slice(1).replace(/([A-Z])/g, ' $1')
}
const formattedTotal = computed(() => {
if (isLoading.value) return '...'
if (error.value) return 'Error'
return formatCurrency(totalValue.value)
})
const formattedBreakdown = computed(() => {
if (isLoading.value || error.value || props.displayMode === 'single') return []
return effectiveSumFields.value.map(field => ({
field,
label: formatFieldName(field),
value: multipleValues.value[field] || 0,
formatted: formatCurrency(multipleValues.value[field] || 0)
}))
})
const percentageChange = computed(() => {
if (!props.showPercentage || isLoading.value || error.value) return null
let baseValue = props.previousValue || previousTotal.value
if (!baseValue || baseValue === 0) return null
const change = ((totalValue.value - baseValue) / baseValue) * 100
return {
value: Math.abs(change).toFixed(1),
isPositive: change >= 0
}
})
const calculatedProgress = computed(() => {
if (props.progress !== null) return props.progress
if (!props.target || props.target === 0) return 75
return Math.min(Math.max((totalValue.value / props.target) * 100, 0), 100)
})
2025-08-04 16:33:07 +03:30
const updateCardWidth = () => {
2025-09-24 12:12:33 +03:30
if (cardRef.value && typeof cardRef.value.getBoundingClientRect === 'function') {
try {
const rect = cardRef.value.getBoundingClientRect()
const newWidth = rect.width
if (newWidth > 0 && Math.abs(newWidth - cardWidth.value) > 5) {
cardWidth.value = newWidth
}
} catch (error) {
console.warn('Error getting card dimensions:', error)
2025-09-15 15:46:14 +03:30
}
2025-08-04 16:33:07 +03:30
}
}
2025-09-15 15:46:14 +03:30
onMounted(async () => {
await nextTick()
2025-09-24 12:12:33 +03:30
setTimeout(() => {
updateCardWidth()
}, 100)
2025-08-04 16:33:07 +03:30
window.addEventListener('resize', updateCardWidth)
2025-09-24 12:12:33 +03:30
await fetchData()
setupRefresh()
2025-08-04 16:33:07 +03:30
})
onUnmounted(() => {
window.removeEventListener('resize', updateCardWidth)
2025-09-24 12:12:33 +03:30
if (refreshTimer.value) {
clearInterval(refreshTimer.value)
}
2025-08-04 16:33:07 +03:30
})
2025-09-24 12:12:33 +03:30
watch(() => props.apiUrl, () => {
fetchData()
})
watch(() => props.refreshInterval, () => {
setupRefresh()
})
const chartSeries = computed(() => [calculatedProgress.value, 100 - calculatedProgress.value])
2025-08-04 16:33:07 +03:30
const processColors = (colors) => {
const currentTheme = vuetifyTheme.current.value.colors
return colors.map(color => {
if (typeof color === 'string' && currentTheme[color]) {
return currentTheme[color]
}
return color
})
}
const chartSize = computed(() => {
2025-09-15 15:46:14 +03:30
const width = cardWidth.value
const breakpoints = {
xs: display.xs.value,
sm: display.sm.value,
md: display.md.value
}
2025-09-24 12:12:33 +03:30
2025-09-15 15:46:14 +03:30
let size = 120
2025-09-24 12:12:33 +03:30
2025-09-15 15:46:14 +03:30
if (breakpoints.xs) {
size = Math.min(width * 0.35, 120)
} else if (breakpoints.sm) {
size = Math.min(width * 0.4, 150)
} else if (width <= 400) {
size = Math.min(width * 0.4, 160)
} else if (width <= 600) {
size = Math.min(width * 0.35, 200)
2025-08-04 16:33:07 +03:30
} else {
2025-09-15 15:46:14 +03:30
size = Math.min(width * 0.3, 240)
2025-08-04 16:33:07 +03:30
}
2025-09-24 12:12:33 +03:30
2025-09-15 15:46:14 +03:30
size = Math.max(size, 100)
2025-09-24 12:12:33 +03:30
2025-08-04 16:33:07 +03:30
return { width: size, height: size }
})
const textSizes = computed(() => {
2025-09-15 15:46:14 +03:30
const width = cardWidth.value
2025-09-24 12:12:33 +03:30
2025-09-15 15:46:14 +03:30
if (display.xs.value || width < 300) {
2025-08-04 16:33:07 +03:30
return {
title: 'text-body-2',
subtitle: 'text-caption',
mainNumber: 'text-h6',
2025-09-24 12:12:33 +03:30
percentage: 'text-caption',
breakdown: 'text-caption'
2025-08-04 16:33:07 +03:30
}
2025-09-15 15:46:14 +03:30
} else if (display.sm.value || width < 450) {
2025-08-04 16:33:07 +03:30
return {
title: 'text-body-1',
2025-09-24 12:12:33 +03:30
subtitle: 'text-body-2',
2025-08-04 16:33:07 +03:30
mainNumber: 'text-h5',
2025-09-24 12:12:33 +03:30
percentage: 'text-body-2',
breakdown: 'text-body-2'
2025-08-04 16:33:07 +03:30
}
2025-09-15 15:46:14 +03:30
} else if (width < 600) {
2025-08-04 16:33:07 +03:30
return {
title: 'text-h6',
subtitle: 'text-body-2',
mainNumber: 'text-h4',
2025-09-24 12:12:33 +03:30
percentage: 'text-body-1',
breakdown: 'text-body-2'
2025-08-04 16:33:07 +03:30
}
} else {
return {
2025-09-15 15:46:14 +03:30
title: 'text-h5',
2025-08-04 16:33:07 +03:30
subtitle: 'text-body-1',
2025-09-15 15:46:14 +03:30
mainNumber: 'text-h3',
2025-09-24 12:12:33 +03:30
percentage: 'text-body-1',
breakdown: 'text-body-1'
2025-08-04 16:33:07 +03:30
}
}
})
const chartOptions = computed(() => {
const currentTheme = vuetifyTheme.current.value.colors
const variableTheme = vuetifyTheme.current.value.variables
2025-09-15 15:46:14 +03:30
const width = cardWidth.value
const chartSizeValue = chartSize.value.width
2025-08-04 16:33:07 +03:30
2025-09-24 12:12:33 +03:30
const defaultDonutColors = [currentTheme.primary]
2025-08-04 16:33:07 +03:30
const usedDonutColors = props.donutColors.length
? processColors(props.donutColors)
: defaultDonutColors
const headingColor = `rgba(${hexToRgb(currentTheme['on-background'])},${variableTheme['high-emphasis-opacity']})`
const backgroundCircleColor = `rgba(${hexToRgb(usedDonutColors[0])}, 0.15)`
2025-09-15 15:46:14 +03:30
const valueFontSize = chartSizeValue < 120 ? '0.9rem' :
2025-09-24 12:12:33 +03:30
chartSizeValue < 160 ? '1.2rem' :
chartSizeValue < 200 ? '1.4rem' : '1.6rem'
2025-09-15 15:46:14 +03:30
const labelFontSize = chartSizeValue < 120 ? '0.7rem' :
2025-09-24 12:12:33 +03:30
chartSizeValue < 160 ? '0.8rem' :
chartSizeValue < 200 ? '0.9rem' : '1rem'
2025-08-04 16:33:07 +03:30
2025-09-15 15:46:14 +03:30
const donutSize = chartSizeValue < 130 ? '60%' :
2025-09-24 12:12:33 +03:30
chartSizeValue < 180 ? '65%' : '70%'
2025-08-04 16:33:07 +03:30
return {
chart: {
parentHeightOffset: 0,
type: 'donut',
2025-09-15 15:46:14 +03:30
sparkline: { enabled: false },
animations: {
enabled: true,
easing: 'easeinout',
speed: 800,
dynamicAnimation: {
enabled: true,
speed: 350
}
}
2025-08-04 16:33:07 +03:30
},
labels: ['Progress', 'Remaining'],
colors: [...usedDonutColors, backgroundCircleColor],
2025-09-15 15:46:14 +03:30
stroke: { width: 0 },
dataLabels: { enabled: false },
legend: { show: false },
2025-08-04 16:33:07 +03:30
tooltip: {
enabled: true,
2025-09-15 15:46:14 +03:30
theme: vuetifyTheme.current.value.dark ? 'dark' : 'light',
2025-08-04 16:33:07 +03:30
style: {
2025-09-15 15:46:14 +03:30
fontSize: chartSizeValue < 140 ? '11px' : '13px'
2025-08-04 16:33:07 +03:30
}
},
grid: {
2025-09-15 15:46:14 +03:30
padding: { top: 0, bottom: 0, right: 0, left: 0 }
2025-08-04 16:33:07 +03:30
},
states: {
hover: {
2025-09-15 15:46:14 +03:30
filter: { type: 'none' }
2025-08-04 16:33:07 +03:30
}
},
plotOptions: {
pie: {
startAngle: -90,
endAngle: 270,
donut: {
size: donutSize,
background: 'transparent',
labels: {
show: true,
value: {
show: true,
fontSize: valueFontSize,
fontFamily: 'Public Sans',
color: headingColor,
fontWeight: 600,
2025-09-15 15:46:14 +03:30
offsetY: chartSizeValue < 130 ? -1 : 0,
2025-08-04 16:33:07 +03:30
formatter(val) {
return `${Math.round(val)}%`
},
},
2025-09-15 15:46:14 +03:30
name: { show: false },
2025-08-04 16:33:07 +03:30
total: {
show: true,
showAlways: true,
color: usedDonutColors[0],
fontSize: labelFontSize,
label: 'Progress',
fontFamily: 'Public Sans',
fontWeight: 500,
formatter() {
2025-09-24 12:12:33 +03:30
return `${Math.round(calculatedProgress.value)}%`
2025-08-04 16:33:07 +03:30
},
},
},
},
},
},
2025-09-15 15:46:14 +03:30
responsive: []
2025-08-04 16:33:07 +03:30
}
})
const cardBackgroundStyle = computed(() => {
2025-09-24 12:12:33 +03:30
const defaultDonutColors = [vuetifyTheme.current.value.colors.primary]
2025-08-04 16:33:07 +03:30
const colors = props.donutColors.length ? processColors(props.donutColors) : defaultDonutColors
const createGradientColor = (color, opacity = 0.08) => {
if (color.includes('rgba')) {
return color.replace(/[\d\.]+\)$/g, `${opacity})`)
}
if (color.startsWith('#')) {
return `rgba(${hexToRgb(color)}, ${opacity})`
}
if (color.includes('rgb')) {
return color.replace('rgb', 'rgba').replace(')', `, ${opacity})`)
}
return `rgba(${hexToRgb(color)}, ${opacity})`
}
const gradientColors = colors.map((color, index) =>
2025-09-15 15:46:14 +03:30
createGradientColor(color, index === 0 ? 0.1 : 0.03)
2025-08-04 16:33:07 +03:30
)
return {
background: `linear-gradient(135deg,
${gradientColors[0]} 0%,
2025-09-15 15:46:14 +03:30
${gradientColors[1] || createGradientColor(colors[0], 0.05)} 50%,
${gradientColors[2] || createGradientColor(colors[0], 0.02)} 100%)`
2025-08-04 16:33:07 +03:30
}
})
const layoutClasses = computed(() => {
2025-09-15 15:46:14 +03:30
const width = cardWidth.value
2025-09-24 12:12:33 +03:30
2025-08-04 16:33:07 +03:30
return {
2025-09-15 15:46:14 +03:30
cardPadding: display.xs.value ? 'pa-3' :
2025-09-24 12:12:33 +03:30
display.sm.value ? 'pa-4' :
width < 400 ? 'pa-4' : 'pa-5',
2025-09-15 15:46:14 +03:30
textSpacing: display.xs.value ? 'me-2' :
2025-09-24 12:12:33 +03:30
display.sm.value ? 'me-3' :
width < 400 ? 'me-3' : 'me-4',
2025-09-15 15:46:14 +03:30
iconSize: display.xs.value ? 14 :
2025-09-24 12:12:33 +03:30
display.sm.value ? 16 :
width < 400 ? 16 : 18,
2025-09-15 15:46:14 +03:30
itemSpacing: display.xs.value ? 'mb-2' :
2025-09-24 12:12:33 +03:30
width < 400 ? 'mb-3' : 'mb-3'
2025-08-04 16:33:07 +03:30
}
})
2025-09-24 12:12:33 +03:30
defineExpose({
refresh: fetchData,
getData: () => apiData.value,
getTotal: () => totalValue.value,
getBreakdown: () => multipleValues.value
})
2025-08-04 16:33:07 +03:30
</script>
<template>
2025-09-24 12:12:33 +03:30
<VCard ref="cardRef" class="overflow-visible" :style="cardBackgroundStyle">
<VCardText class="d-flex align-center justify-space-between" :class="layoutClasses.cardPadding">
<div class="d-flex flex-column flex-grow-1 flex-shrink-1" :class="layoutClasses.textSpacing"
style="min-width: 0;">
2025-09-15 15:46:14 +03:30
<div :class="layoutClasses.itemSpacing">
2025-09-24 12:12:33 +03:30
<h5 class="text-no-wrap font-weight-medium text-truncate" :class="textSizes.title" style="line-height: 1.2;">
{{ title }}
2025-08-04 16:33:07 +03:30
</h5>
2025-09-24 12:12:33 +03:30
<div class="text-medium-emphasis text-truncate" :class="textSizes.subtitle" style="line-height: 1.1;">
{{ subtitle }}
2025-08-04 16:33:07 +03:30
</div>
</div>
<div>
2025-09-24 12:12:33 +03:30
<template v-if="isLoading">
<VSkeletonLoader type="text" :class="[textSizes.mainNumber, layoutClasses.itemSpacing]" width="120" />
<VSkeletonLoader type="text" :class="textSizes.percentage" width="60" />
</template>
<template v-else-if="error">
<h3 class="font-weight-bold text-error" :class="[textSizes.mainNumber, layoutClasses.itemSpacing]"
style="line-height: 1.1;">
Error
</h3>
<div class="d-flex align-center">
<VIcon icon="tabler-alert-circle" color="error" :size="layoutClasses.iconSize"
class="me-1 flex-shrink-0" />
<span class="font-weight-medium text-no-wrap text-error" :class="textSizes.percentage">
Failed to load
</span>
</div>
</template>
<template v-else>
<h3 class="font-weight-bold" :class="[textSizes.mainNumber, layoutClasses.itemSpacing]"
style="line-height: 1.1;">
{{ formattedTotal }}
</h3>
<div v-if="displayMode === 'multiple' && formattedBreakdown.length > 1" class="mb-2">
<div v-for="item in formattedBreakdown" :key="item.field"
class="d-flex justify-space-between align-center mb-1">
<span class="text-medium-emphasis" :class="textSizes.breakdown">
{{ item.label }}:
</span>
<span class="font-weight-medium" :class="textSizes.breakdown">
{{ item.formatted }}
</span>
</div>
<VDivider class="my-2 opacity-60" />
</div>
<div v-if="percentageChange" class="d-flex align-center">
<VIcon :icon="percentageChange.isPositive ? 'tabler-chevron-up' : 'tabler-chevron-down'" :color="percentageChange.isPositive ?
(props.donutColors.length ? processColors(props.donutColors)[0] : vuetifyTheme.current.value.colors.primary) :
vuetifyTheme.current.value.colors.error" :size="layoutClasses.iconSize" class="me-1 flex-shrink-0" />
<span class="font-weight-medium text-no-wrap" :class="textSizes.percentage" :style="{
color: percentageChange.isPositive ?
(props.donutColors.length ? processColors(props.donutColors)[0] : vuetifyTheme.current.value.colors.primary) :
vuetifyTheme.current.value.colors.error
}">
{{ percentageChange.value }}%
</span>
</div>
<div v-else-if="showPercentage" class="d-flex align-center">
<VIcon icon="tabler-list"
:color="props.donutColors.length ? processColors(props.donutColors)[0] : vuetifyTheme.current.value.colors.primary"
:size="layoutClasses.iconSize" class="me-1 flex-shrink-0" />
<span class="font-weight-medium text-no-wrap text-medium-emphasis" :class="textSizes.percentage">
{{ apiData.length }} leads
</span>
</div>
</template>
2025-08-04 16:33:07 +03:30
</div>
</div>
2025-09-24 12:12:33 +03:30
<div class="d-flex align-center justify-center flex-shrink-0">
<VueApexCharts v-if="!isLoading && !error" :options="chartOptions" :series="chartSeries"
:height="chartSize.height" :width="chartSize.width" />
<VSkeletonLoader v-else-if="isLoading" type="avatar" :width="chartSize.width" :height="chartSize.height" />
<div v-else class="d-flex align-center justify-center"
:style="{ width: chartSize.width + 'px', height: chartSize.height + 'px' }">
<VIcon icon="tabler-alert-circle" color="error" :size="chartSize.width * 0.3" />
</div>
2025-08-04 16:33:07 +03:30
</div>
</VCardText>
</VCard>
2025-09-15 15:46:14 +03:30
</template>