feat(unified-mobile-material-design): Complete US-106 - Dashboard Mobile cu Swipeable KPI Cards
Implemented by Ralph autonomous loop. Iteration: 10 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,64 @@
|
||||
|
||||
<!-- Secțiune Carduri Noi - Adăugare -->
|
||||
<div class="metrics-cards-section" v-if="!isLoading">
|
||||
<!-- Rând 1: Metrici principale -->
|
||||
<div class="metrics-row">
|
||||
<!-- Mobile: Swipeable KPI Cards Carousel -->
|
||||
<SwipeableCards v-if="isMobile" :totalCards="4" class="mobile-kpi-carousel">
|
||||
<template #card-0>
|
||||
<TreasuryDualCard
|
||||
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
||||
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
||||
:casaItems="treasuryData?.breakdown?.casa?.items || []"
|
||||
:bancaItems="treasuryData?.breakdown?.banca?.items || []"
|
||||
:casaTrend="casaTrend"
|
||||
:bancaTrend="bancaTrend"
|
||||
:casaSparklineData="casaSparkline"
|
||||
:bancaSparklineData="bancaSparkline"
|
||||
:casaPreviousSparklineData="casaPreviousSparkline"
|
||||
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
/>
|
||||
</template>
|
||||
<template #card-1>
|
||||
<CashFlowMetricCard
|
||||
:inflowsValue="monthlyInflows"
|
||||
:outflowsValue="monthlyOutflows"
|
||||
:inflowsTrend="inflowsTrend"
|
||||
:outflowsTrend="outflowsTrend"
|
||||
:inflowsSparkline="inflowsSparkline"
|
||||
:outflowsSparkline="outflowsSparkline"
|
||||
:inflowsPreviousSparkline="inflowsPreviousSparkline"
|
||||
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
/>
|
||||
</template>
|
||||
<template #card-2>
|
||||
<ClientiBalanceCard
|
||||
:total="netBalanceData?.clienti_total || 0"
|
||||
:trend="clientiTrend"
|
||||
:sparklineData="clientiSparkline"
|
||||
:previousSparklineData="clientiPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||
/>
|
||||
</template>
|
||||
<template #card-3>
|
||||
<FurnizoriBalanceCard
|
||||
:total="netBalanceData?.furnizori_total || 0"
|
||||
:trend="furnizoriTrend"
|
||||
:sparklineData="furnizoriSparkline"
|
||||
:previousSparklineData="furnizoriPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||
/>
|
||||
</template>
|
||||
</SwipeableCards>
|
||||
|
||||
<!-- Desktop: Grid layout -->
|
||||
<div v-else class="metrics-row">
|
||||
<TreasuryDualCard
|
||||
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
||||
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
||||
@@ -82,7 +138,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
// Import componente noi
|
||||
import MetricCard from "@reports/components/dashboard/cards/MetricCard.vue";
|
||||
@@ -91,6 +147,8 @@ import MaturityAndDetailsCard from "@reports/components/dashboard/cards/Maturity
|
||||
import ClientiBalanceCard from "@reports/components/dashboard/cards/ClientiBalanceCard.vue";
|
||||
import FurnizoriBalanceCard from "@reports/components/dashboard/cards/FurnizoriBalanceCard.vue";
|
||||
import TreasuryDualCard from "@reports/components/dashboard/cards/TreasuryDualCard.vue";
|
||||
// Mobile carousel component
|
||||
import SwipeableCards from "@shared/components/mobile/SwipeableCards.vue";
|
||||
import { useCompanyStore } from "@reports/stores/sharedStores";
|
||||
import { useDashboardStore } from "@reports/stores/dashboard";
|
||||
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
|
||||
@@ -419,8 +477,14 @@ const bancaPreviousSparkline = computed(() => {
|
||||
return previousSparklineData.map((v) => v * bancaProportion);
|
||||
});
|
||||
|
||||
// Detectare mobile
|
||||
const isMobile = computed(() => window.innerWidth < 768);
|
||||
// Detectare mobile - reactive with resize listener
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
// Handle window resize for mobile detection
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
// Computed property pentru luna curentă - folosește perioada din period selector
|
||||
const currentMonthLabel = computed(() => {
|
||||
@@ -849,6 +913,9 @@ watch(
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Add resize listener for mobile detection
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Load companies first
|
||||
if (!companyStore.hasCompanies) {
|
||||
await companyStore.loadCompanies();
|
||||
@@ -871,6 +938,11 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup resize listener
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1206,4 +1278,9 @@ onMounted(async () => {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile KPI Carousel Styles */
|
||||
.mobile-kpi-carousel {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
459
src/shared/components/mobile/SwipeableCards.vue
Normal file
459
src/shared/components/mobile/SwipeableCards.vue
Normal file
@@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<div class="swipeable-cards-container">
|
||||
<!-- Cards Track -->
|
||||
<div
|
||||
class="cards-track"
|
||||
ref="trackRef"
|
||||
:style="trackStyle"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<div
|
||||
v-for="(_, index) in totalCards"
|
||||
:key="index"
|
||||
class="card-slide"
|
||||
:class="{ active: index === currentIndex }"
|
||||
>
|
||||
<slot :name="`card-${index}`"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dots Indicator -->
|
||||
<div class="dots-indicator" v-if="showDots && totalCards > 1">
|
||||
<button
|
||||
v-for="index in totalCards"
|
||||
:key="index"
|
||||
class="dot"
|
||||
:class="{ active: index - 1 === currentIndex }"
|
||||
@click="goToCard(index - 1)"
|
||||
:aria-label="`Mergi la cardul ${index}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* SwipeableCards - Touch swipeable carousel for mobile KPI cards
|
||||
*
|
||||
* Props:
|
||||
* - totalCards: Number of cards in the carousel
|
||||
* - showDots: Whether to show the dots indicator (default: true)
|
||||
* - autoPlay: Whether to auto-advance cards (default: false)
|
||||
* - autoPlayInterval: Interval in ms for auto-play (default: 5000)
|
||||
*
|
||||
* Events:
|
||||
* - update:currentIndex: Emitted when current card index changes
|
||||
*
|
||||
* Slots:
|
||||
* - card-0, card-1, card-2, etc.: Named slots for each card
|
||||
*
|
||||
* Features:
|
||||
* - Horizontal touch swipe (left/right)
|
||||
* - Smooth CSS transitions
|
||||
* - Dots indicator for position
|
||||
* - Visual feedback during drag
|
||||
* - Responsive touch thresholds
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Total number of cards in the carousel
|
||||
*/
|
||||
totalCards: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (value) => value > 0
|
||||
},
|
||||
/**
|
||||
* Whether to show the navigation dots
|
||||
*/
|
||||
showDots: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* Whether to auto-advance cards
|
||||
*/
|
||||
autoPlay: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Auto-play interval in milliseconds
|
||||
*/
|
||||
autoPlayInterval: {
|
||||
type: Number,
|
||||
default: 5000
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentIndex'])
|
||||
|
||||
// Refs
|
||||
const trackRef = ref(null)
|
||||
const currentIndex = ref(0)
|
||||
const isDragging = ref(false)
|
||||
const dragOffset = ref(0)
|
||||
|
||||
// Touch tracking state
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let touchCurrentX = 0
|
||||
let isSwiping = false
|
||||
let autoPlayTimer = null
|
||||
|
||||
// Swipe thresholds
|
||||
const SWIPE_THRESHOLD = 50 // Minimum swipe distance to change card
|
||||
const SWIPE_VELOCITY_THRESHOLD = 0.3 // Minimum velocity for quick swipes
|
||||
const ANGLE_THRESHOLD = 30 // Maximum angle (degrees) for horizontal swipe detection
|
||||
|
||||
/**
|
||||
* Calculate the track transform based on current index and drag offset
|
||||
*/
|
||||
const trackStyle = computed(() => {
|
||||
const translateX = -currentIndex.value * 100 + (dragOffset.value / getTrackWidth()) * 100
|
||||
return {
|
||||
transform: `translateX(${translateX}%)`,
|
||||
transition: isDragging.value ? 'none' : 'transform var(--transition-normal)'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get track container width for calculations
|
||||
*/
|
||||
const getTrackWidth = () => {
|
||||
return trackRef.value?.clientWidth || window.innerWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a specific card index
|
||||
*/
|
||||
const goToCard = (index) => {
|
||||
if (index < 0 || index >= props.totalCards) return
|
||||
currentIndex.value = index
|
||||
emit('update:currentIndex', index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the next card
|
||||
*/
|
||||
const nextCard = () => {
|
||||
if (currentIndex.value < props.totalCards - 1) {
|
||||
goToCard(currentIndex.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the previous card
|
||||
*/
|
||||
const prevCard = () => {
|
||||
if (currentIndex.value > 0) {
|
||||
goToCard(currentIndex.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch start event
|
||||
*/
|
||||
const handleTouchStart = (event) => {
|
||||
// Record starting position
|
||||
touchStartX = event.touches[0].clientX
|
||||
touchStartY = event.touches[0].clientY
|
||||
touchCurrentX = touchStartX
|
||||
isSwiping = false
|
||||
|
||||
// Stop auto-play during interaction
|
||||
stopAutoPlay()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch move event
|
||||
*/
|
||||
const handleTouchMove = (event) => {
|
||||
if (!touchStartX) return
|
||||
|
||||
touchCurrentX = event.touches[0].clientX
|
||||
const touchCurrentY = event.touches[0].clientY
|
||||
|
||||
const deltaX = touchCurrentX - touchStartX
|
||||
const deltaY = touchCurrentY - touchStartY
|
||||
|
||||
// Determine if this is a horizontal swipe
|
||||
// Check angle: if angle is too steep, it's a vertical scroll
|
||||
const angle = Math.abs(Math.atan2(deltaY, deltaX) * (180 / Math.PI))
|
||||
|
||||
if (!isSwiping) {
|
||||
// First significant movement - determine direction
|
||||
if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
|
||||
if (angle < ANGLE_THRESHOLD || angle > (180 - ANGLE_THRESHOLD)) {
|
||||
// Horizontal swipe
|
||||
isSwiping = true
|
||||
isDragging.value = true
|
||||
} else {
|
||||
// Vertical scroll - don't interfere
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSwiping) {
|
||||
// Prevent vertical scroll while swiping horizontally
|
||||
event.preventDefault()
|
||||
|
||||
// Apply resistance at boundaries
|
||||
let adjustedDelta = deltaX
|
||||
if (
|
||||
(currentIndex.value === 0 && deltaX > 0) ||
|
||||
(currentIndex.value === props.totalCards - 1 && deltaX < 0)
|
||||
) {
|
||||
// Apply resistance (50% of movement)
|
||||
adjustedDelta = deltaX * 0.3
|
||||
}
|
||||
|
||||
dragOffset.value = adjustedDelta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch end event
|
||||
*/
|
||||
const handleTouchEnd = (event) => {
|
||||
if (!isSwiping) {
|
||||
resetTouchState()
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = touchCurrentX - touchStartX
|
||||
const trackWidth = getTrackWidth()
|
||||
const percentMoved = Math.abs(deltaX) / trackWidth
|
||||
|
||||
// Calculate swipe velocity (for quick swipes)
|
||||
const touchDuration = event.timeStamp - (event.changedTouches[0]?.timeStamp || event.timeStamp)
|
||||
const velocity = Math.abs(deltaX) / (touchDuration || 1)
|
||||
|
||||
// Determine if we should change cards
|
||||
const shouldChange =
|
||||
Math.abs(deltaX) > SWIPE_THRESHOLD ||
|
||||
(velocity > SWIPE_VELOCITY_THRESHOLD && percentMoved > 0.1)
|
||||
|
||||
if (shouldChange) {
|
||||
if (deltaX > 0 && currentIndex.value > 0) {
|
||||
// Swiped right - go to previous
|
||||
prevCard()
|
||||
} else if (deltaX < 0 && currentIndex.value < props.totalCards - 1) {
|
||||
// Swiped left - go to next
|
||||
nextCard()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state
|
||||
resetTouchState()
|
||||
|
||||
// Restart auto-play after interaction
|
||||
if (props.autoPlay) {
|
||||
startAutoPlay()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset touch tracking state
|
||||
*/
|
||||
const resetTouchState = () => {
|
||||
touchStartX = 0
|
||||
touchStartY = 0
|
||||
touchCurrentX = 0
|
||||
isSwiping = false
|
||||
isDragging.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-play timer
|
||||
*/
|
||||
const startAutoPlay = () => {
|
||||
if (!props.autoPlay) return
|
||||
|
||||
stopAutoPlay()
|
||||
autoPlayTimer = setInterval(() => {
|
||||
if (currentIndex.value < props.totalCards - 1) {
|
||||
nextCard()
|
||||
} else {
|
||||
goToCard(0) // Loop back to first
|
||||
}
|
||||
}, props.autoPlayInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-play timer
|
||||
*/
|
||||
const stopAutoPlay = () => {
|
||||
if (autoPlayTimer) {
|
||||
clearInterval(autoPlayTimer)
|
||||
autoPlayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for totalCards changes to reset index if needed
|
||||
watch(() => props.totalCards, (newTotal) => {
|
||||
if (currentIndex.value >= newTotal) {
|
||||
goToCard(newTotal - 1)
|
||||
}
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (props.autoPlay) {
|
||||
startAutoPlay()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoPlay()
|
||||
})
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
goToCard,
|
||||
nextCard,
|
||||
prevCard,
|
||||
currentIndex
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ================================================
|
||||
SwipeableCards Component Styles
|
||||
Touch swipeable carousel for mobile KPI cards
|
||||
================================================ */
|
||||
|
||||
.swipeable-cards-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
/* Padding for dots indicator */
|
||||
padding-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
/* Cards track - horizontal scrolling container */
|
||||
.cards-track {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
/* Prevent text selection during swipe */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
/* Smooth touch scrolling */
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
|
||||
/* Individual card slide */
|
||||
.card-slide {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 var(--space-sm);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* First and last card padding adjustments */
|
||||
.card-slide:first-child {
|
||||
padding-left: var(--space-md);
|
||||
}
|
||||
|
||||
.card-slide:last-child {
|
||||
padding-right: var(--space-md);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
Dots Indicator
|
||||
================================================ */
|
||||
|
||||
.dots-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
/* Individual dot */
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-border);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
/* Expand touch target */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Expanded touch target (invisible) */
|
||||
.dot::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
/* Active dot */
|
||||
.dot.active {
|
||||
width: 24px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.dot:hover:not(.active) {
|
||||
background: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Focus state for accessibility */
|
||||
.dot:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
Dark Mode Support
|
||||
================================================ */
|
||||
|
||||
/* Manual dark mode via data-theme attribute */
|
||||
[data-theme="dark"] .dot {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dot.active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dot:hover:not(.active) {
|
||||
background: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Auto dark mode (when no manual theme is set) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) .dot {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
:root:not([data-theme]) .dot.active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
:root:not([data-theme]) .dot:hover:not(.active) {
|
||||
background: var(--text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user