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:
Claude Agent
2026-01-12 10:07:38 +00:00
parent 99ceeeff0a
commit 1e6981d2d2
4 changed files with 549 additions and 7 deletions

View File

@@ -169,8 +169,8 @@
"Fallback la layout grid normal pe desktop",
"npm run build passes"
],
"passes": false,
"notes": ""
"passes": true,
"notes": "Completed in iteration 10"
},
{
"id": "US-107",

View File

@@ -60,3 +60,9 @@ Mon Jan 12 09:44:54 AM UTC 2026
[2026-01-12 10:03:03] Working on story: US-112
[2026-01-12 10:03:03] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_9_US-112.log)
[2026-01-12 10:04:31] SUCCESS: Story US-112 passed!
[2026-01-12 10:04:31] Changes committed
[2026-01-12 10:04:31] Progress: 8/20 stories completed
[2026-01-12 10:04:33] === Iteration 10/100 ===
[2026-01-12 10:04:33] Working on story: US-106
[2026-01-12 10:04:33] Running Claude... (log: /workspace/roa2web/scripts/ralph/logs/iteration_10_US-106.log)
[2026-01-12 10:07:38] SUCCESS: Story US-106 passed!

View File

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

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