refactor: demo page
This commit is contained in:
33
config/cors.php
Normal file
33
config/cors.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Laravel CORS Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| این تنظیمات برای کنترل درخواستهای Cross-Origin استفاده میشن
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'dev/*', 'sanctum/csrf-cookie'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [
|
||||
'http://localhost:8000',
|
||||
'http://megategra.com:8001'
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => true,
|
||||
|
||||
];
|
||||
@@ -40,7 +40,7 @@ const vuetifyTheme = useTheme()
|
||||
const route = useRoute()
|
||||
|
||||
const isCrmRoute = computed(() => {
|
||||
return route.path === '/dashboards/crm'
|
||||
return route.path === '/dashboards/crm' || route.path === '/dashboards/demo'
|
||||
})
|
||||
|
||||
const colors = [
|
||||
|
||||
@@ -10,8 +10,8 @@ import TasksTable from "./task.vue";
|
||||
import ResourcesTable from "./resource.vue";
|
||||
import GanttChart from './gantt.vue'
|
||||
|
||||
|
||||
const isEditMode = ref(false)
|
||||
const autoCompact = ref(true)
|
||||
let grid = null
|
||||
|
||||
const handleEditModeChange = (event) => {
|
||||
@@ -33,6 +33,9 @@ const handleDeleteCard = (cardId) => {
|
||||
const element = document.querySelector(`[data-card-id="${cardId}"]`)
|
||||
if (element && grid) {
|
||||
grid.removeWidget(element)
|
||||
if (autoCompact.value) {
|
||||
grid.compact()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +48,12 @@ onMounted(async () => {
|
||||
resizable: true,
|
||||
disableOneColumnMode: true,
|
||||
animate: true,
|
||||
columnOpts: {
|
||||
breakpoints: [
|
||||
{ w: 768, c: 1 },
|
||||
{ w: 992, c: 12 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
grid.enableMove(false)
|
||||
@@ -60,22 +69,6 @@ onUnmounted(() => {
|
||||
if (grid) {
|
||||
grid.destroy(false)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (gridContainer.value) {
|
||||
grid = GridStack.init({
|
||||
column: 12,
|
||||
cellHeight: 50,
|
||||
float: true,
|
||||
draggable: {
|
||||
handle: '.grid-stack-item-content'
|
||||
},
|
||||
resizable: {
|
||||
handles: 'e, se, s, sw, w'
|
||||
}
|
||||
}, gridContainer.value)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -101,6 +94,16 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="banner-controls">
|
||||
<VSwitch
|
||||
v-model="autoCompact"
|
||||
label="Auto Compact"
|
||||
class="auto-compact-switch"
|
||||
hide-details
|
||||
inset
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="banner-decorations">
|
||||
@@ -203,13 +206,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="grid-stack-item" :class="{ 'edit-mode': isEditMode }" gs-w="4" gs-h="7" gs-max-h="7"
|
||||
data-card-id="analysis">
|
||||
data-card-id="analysis2">
|
||||
<div class="grid-stack-item-content">
|
||||
<div v-if="isEditMode" class="drag-handle" title="Move analysis">
|
||||
<div v-if="isEditMode" class="drag-handle" title="Move analysis2">
|
||||
<VIcon size="18">mdi-drag-horizontal-variant</VIcon>
|
||||
</div>
|
||||
<VBtn v-if="isEditMode" variant="text" size="small" color="error" class="delete-btn-chart"
|
||||
title="Delete analysis" @click.stop="handleDeleteCard('analysis')">
|
||||
title="Delete analysis2" @click.stop="handleDeleteCard('analysis2')">
|
||||
<VIcon icon="tabler-trash-x" size="16" class="me-1" />
|
||||
Delete
|
||||
</VBtn>
|
||||
@@ -247,7 +250,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-stack-item" :class="{ 'edit-mode': isEditMode }" gs-w="12" gs-h="6" gs-max-h="6"
|
||||
<div class="grid-stack-item" :class="{ 'edit-mode': isEditMode }" gs-w="12" gs-h="6" gs-max-h="6"
|
||||
data-card-id="gantt-chart">
|
||||
<div class="grid-stack-item-content">
|
||||
<div v-if="isEditMode" class="drag-handle" title="Move gantt-chart">
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
import HC from "highcharts"
|
||||
import { ref, onMounted } from "vue"
|
||||
|
||||
// Props for component reusability
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "The Germanic Language Tree"
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
showBackground: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const cssRGB = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
const toHex = rgb => {
|
||||
const [r,g,b] = rgb.split(",").map(v => parseInt(v,10))
|
||||
@@ -62,7 +78,7 @@ onMounted(() => {
|
||||
|
||||
const chartOptions = ref({
|
||||
chart: { inverted: true, height: 1200, backgroundColor: 'transparent' },
|
||||
title: { text: "The Germanic Language Tree" },
|
||||
title: { text: props.title },
|
||||
accessibility: { point: { descriptionFormat: "{add index 1}. {toNode.id} comes from {fromNode.id}" } },
|
||||
tooltip: { outside: true },
|
||||
series: [{
|
||||
@@ -133,7 +149,12 @@ const chartOptions = ref({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chart-container">
|
||||
<div
|
||||
class="chart-container"
|
||||
:style="{
|
||||
backgroundColor: showBackground ? backgroundColor : 'transparent'
|
||||
}"
|
||||
>
|
||||
<highcharts :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,7 +162,7 @@ const chartOptions = ref({
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
padding: 16px;
|
||||
background-color: transparent;
|
||||
min-height: 100vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +1,276 @@
|
||||
<script setup>
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import { computed, onMounted, onUnmounted, ref, nextTick } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
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)
|
||||
},
|
||||
donutColors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
default: () => ['success']
|
||||
},
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 75
|
||||
default: null
|
||||
},
|
||||
target: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
roundNumbers: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['dataLoaded', 'error', 'loading'])
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
const display = useDisplay()
|
||||
const cardRef = ref(null)
|
||||
const cardWidth = ref(300)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
const updateCardWidth = () => {
|
||||
if (cardRef.value) {
|
||||
const rect = cardRef.value.getBoundingClientRect()
|
||||
const newWidth = rect.width
|
||||
if (newWidth > 0 && Math.abs(newWidth - cardWidth.value) > 5) {
|
||||
cardWidth.value = newWidth
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
updateCardWidth()
|
||||
|
||||
setTimeout(() => {
|
||||
updateCardWidth()
|
||||
}, 100)
|
||||
|
||||
window.addEventListener('resize', updateCardWidth)
|
||||
|
||||
await fetchData()
|
||||
|
||||
setupRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateCardWidth)
|
||||
if (refreshTimer.value) {
|
||||
clearInterval(refreshTimer.value)
|
||||
}
|
||||
})
|
||||
|
||||
const chartSeries = computed(() => [props.progress, 100 - props.progress])
|
||||
watch(() => props.apiUrl, () => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
watch(() => props.refreshInterval, () => {
|
||||
setupRefresh()
|
||||
})
|
||||
|
||||
const chartSeries = computed(() => [calculatedProgress.value, 100 - calculatedProgress.value])
|
||||
|
||||
const processColors = (colors) => {
|
||||
const currentTheme = vuetifyTheme.current.value.colors
|
||||
@@ -86,28 +317,32 @@ const textSizes = computed(() => {
|
||||
title: 'text-body-2',
|
||||
subtitle: 'text-caption',
|
||||
mainNumber: 'text-h6',
|
||||
percentage: 'text-caption'
|
||||
percentage: 'text-caption',
|
||||
breakdown: 'text-caption'
|
||||
}
|
||||
} else if (display.sm.value || width < 450) {
|
||||
return {
|
||||
title: 'text-body-1',
|
||||
subtitle: 'text-body-2',
|
||||
mainNumber: 'text-h5',
|
||||
percentage: 'text-body-2'
|
||||
percentage: 'text-body-2',
|
||||
breakdown: 'text-body-2'
|
||||
}
|
||||
} else if (width < 600) {
|
||||
return {
|
||||
title: 'text-h6',
|
||||
subtitle: 'text-body-2',
|
||||
mainNumber: 'text-h4',
|
||||
percentage: 'text-body-1'
|
||||
percentage: 'text-body-1',
|
||||
breakdown: 'text-body-2'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: 'text-h5',
|
||||
subtitle: 'text-body-1',
|
||||
mainNumber: 'text-h3',
|
||||
percentage: 'text-body-1'
|
||||
percentage: 'text-body-1',
|
||||
breakdown: 'text-body-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -118,7 +353,7 @@ const chartOptions = computed(() => {
|
||||
const width = cardWidth.value
|
||||
const chartSizeValue = chartSize.value.width
|
||||
|
||||
const defaultDonutColors = [currentTheme.success]
|
||||
const defaultDonutColors = [currentTheme.primary]
|
||||
const usedDonutColors = props.donutColors.length
|
||||
? processColors(props.donutColors)
|
||||
: defaultDonutColors
|
||||
@@ -127,15 +362,15 @@ const chartOptions = computed(() => {
|
||||
const backgroundCircleColor = `rgba(${hexToRgb(usedDonutColors[0])}, 0.15)`
|
||||
|
||||
const valueFontSize = chartSizeValue < 120 ? '0.9rem' :
|
||||
chartSizeValue < 160 ? '1.2rem' :
|
||||
chartSizeValue < 200 ? '1.4rem' : '1.6rem'
|
||||
chartSizeValue < 160 ? '1.2rem' :
|
||||
chartSizeValue < 200 ? '1.4rem' : '1.6rem'
|
||||
|
||||
const labelFontSize = chartSizeValue < 120 ? '0.7rem' :
|
||||
chartSizeValue < 160 ? '0.8rem' :
|
||||
chartSizeValue < 200 ? '0.9rem' : '1rem'
|
||||
chartSizeValue < 160 ? '0.8rem' :
|
||||
chartSizeValue < 200 ? '0.9rem' : '1rem'
|
||||
|
||||
const donutSize = chartSizeValue < 130 ? '60%' :
|
||||
chartSizeValue < 180 ? '65%' : '70%'
|
||||
chartSizeValue < 180 ? '65%' : '70%'
|
||||
|
||||
return {
|
||||
chart: {
|
||||
@@ -202,7 +437,7 @@ const chartOptions = computed(() => {
|
||||
fontFamily: 'Public Sans',
|
||||
fontWeight: 500,
|
||||
formatter() {
|
||||
return `${props.progress}%`
|
||||
return `${Math.round(calculatedProgress.value)}%`
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -214,7 +449,7 @@ const chartOptions = computed(() => {
|
||||
})
|
||||
|
||||
const cardBackgroundStyle = computed(() => {
|
||||
const defaultDonutColors = [vuetifyTheme.current.value.colors.success]
|
||||
const defaultDonutColors = [vuetifyTheme.current.value.colors.primary]
|
||||
const colors = props.donutColors.length ? processColors(props.donutColors) : defaultDonutColors
|
||||
|
||||
const createGradientColor = (color, opacity = 0.08) => {
|
||||
@@ -247,87 +482,112 @@ const layoutClasses = computed(() => {
|
||||
|
||||
return {
|
||||
cardPadding: display.xs.value ? 'pa-3' :
|
||||
display.sm.value ? 'pa-4' :
|
||||
width < 400 ? 'pa-4' : 'pa-5',
|
||||
display.sm.value ? 'pa-4' :
|
||||
width < 400 ? 'pa-4' : 'pa-5',
|
||||
textSpacing: display.xs.value ? 'me-2' :
|
||||
display.sm.value ? 'me-3' :
|
||||
width < 400 ? 'me-3' : 'me-4',
|
||||
display.sm.value ? 'me-3' :
|
||||
width < 400 ? 'me-3' : 'me-4',
|
||||
iconSize: display.xs.value ? 14 :
|
||||
display.sm.value ? 16 :
|
||||
width < 400 ? 16 : 18,
|
||||
display.sm.value ? 16 :
|
||||
width < 400 ? 16 : 18,
|
||||
itemSpacing: display.xs.value ? 'mb-2' :
|
||||
width < 400 ? 'mb-3' : 'mb-3'
|
||||
width < 400 ? 'mb-3' : 'mb-3'
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh: fetchData,
|
||||
getData: () => apiData.value,
|
||||
getTotal: () => totalValue.value,
|
||||
getBreakdown: () => multipleValues.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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;"
|
||||
>
|
||||
<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;">
|
||||
<div :class="layoutClasses.itemSpacing">
|
||||
<h5
|
||||
class="text-no-wrap font-weight-medium text-truncate"
|
||||
:class="textSizes.title"
|
||||
style="line-height: 1.2;"
|
||||
>
|
||||
Generated Leads
|
||||
<h5 class="text-no-wrap font-weight-medium text-truncate" :class="textSizes.title" style="line-height: 1.2;">
|
||||
{{ title }}
|
||||
</h5>
|
||||
<div
|
||||
class="text-medium-emphasis text-truncate"
|
||||
:class="textSizes.subtitle"
|
||||
style="line-height: 1.1;"
|
||||
>
|
||||
Monthly Report
|
||||
<div class="text-medium-emphasis text-truncate" :class="textSizes.subtitle" style="line-height: 1.1;">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
class="font-weight-bold"
|
||||
:class="[textSizes.mainNumber, layoutClasses.itemSpacing]"
|
||||
style="line-height: 1.1;"
|
||||
>
|
||||
4,350
|
||||
</h3>
|
||||
<div class="d-flex align-center">
|
||||
<VIcon
|
||||
icon="tabler-chevron-up"
|
||||
:color="(props.donutColors.length ? processColors(props.donutColors) : [vuetifyTheme.current.value.colors.success])[0]"
|
||||
:size="layoutClasses.iconSize"
|
||||
class="me-1 flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="font-weight-medium text-no-wrap"
|
||||
:class="textSizes.percentage"
|
||||
:style="{ color: (props.donutColors.length ? processColors(props.donutColors) : [vuetifyTheme.current.value.colors.success])[0] }"
|
||||
>
|
||||
15.8%
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-center justify-center flex-shrink-0"
|
||||
>
|
||||
<VueApexCharts
|
||||
:options="chartOptions"
|
||||
:series="chartSeries"
|
||||
:height="chartSize.height"
|
||||
:width="chartSize.width"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.grid-stack-container {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.grid-stack-container.edit-mode-active {
|
||||
@@ -11,7 +10,6 @@
|
||||
|
||||
.grid-stack-item {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.grid-stack-item.edit-mode {
|
||||
@@ -260,6 +258,32 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.custom-toggle {
|
||||
.v-selection-control__input {
|
||||
.v-switch__track {
|
||||
background-color: rgba(255, 255, 255, 0.3) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.v-switch--on .v-selection-control__input {
|
||||
.v-switch__track {
|
||||
background-color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
.v-switch__thumb {
|
||||
background-color: rgba(var(--v-theme-primary), 1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-label {
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.banner-content {
|
||||
flex-direction: column;
|
||||
@@ -291,4 +315,20 @@
|
||||
padding: 3px 6px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
.auto-compact-switch {
|
||||
.v-switch__track {
|
||||
width: 44px !important;
|
||||
height: 22px !important;
|
||||
}
|
||||
|
||||
.v-switch__thumb {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
.v-label {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user