refactor: demo page

This commit is contained in:
2025-09-24 12:12:33 +03:30
parent 3f7af72848
commit 617bddefa1
6 changed files with 479 additions and 122 deletions

33
config/cors.php Normal file
View 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,
];

View File

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

View File

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

View File

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

View File

@@ -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) {
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()
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
@@ -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) => {
@@ -259,75 +494,100 @@ const layoutClasses = computed(() => {
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
<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-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%
<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>

View File

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