feat(dashboard): Complete dashboard desktop cleanup and improvements

User Stories Completed:
- US-001: Eliminare SolduriCompactCard de pe Desktop
- US-002: Eliminare Icoane din Header-ul CollapsibleCard
- US-003: Reorganizare TreasuryDualCard - Text Înainte de Grafice
- US-004: Reorganizare ClientiBalanceCard - Text Înainte de Grafice
- US-005: Reorganizare FurnizoriBalanceCard - Text Înainte de Grafice
- US-006: Grafice Colapsabile în TreasuryDualCard
- US-007: Grafice Colapsabile în ClientiBalanceCard
- US-008: Grafice Colapsabile în FurnizoriBalanceCard
- US-009: Grafice Colapsabile în CashFlowMetricCard

Additional Improvements:
- Add cache metadata display (CacheFooter component) for all dashboard cards
- Add @cached decorators to get_monthly_flows and get_indicators_with_sparklines
- Fix financial indicators calculations and sparkline sync
- Add state reset on company change to prevent stale data
- New shared components: CacheFooter.vue, authRedirect.js
- Enhanced FinancialIndicatorsCard with sparklines and period selection

Squashed from branch: ralph/dashboard-desktop-cleanup (11 commits)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-22 07:27:27 +00:00
parent 69683b2d65
commit 1b9ebf1d8f
23 changed files with 4034 additions and 1396 deletions

View File

@@ -22,8 +22,22 @@
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Dual sparkline charts - stacked vertical (collapsible) -->
<div v-show="chartsExpanded" class="charts-content">
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Încasări -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-success">Încasări</div>
@@ -40,6 +54,14 @@
</div>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -53,6 +75,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -97,6 +120,10 @@ const props = defineProps({
type: Array,
default: () => [],
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs pentru 2 canvas-uri separate
@@ -105,6 +132,13 @@ const outflowsCanvas = ref(null);
let inflowsChartInstance = null;
let outflowsChartInstance = null;
// Charts collapsible state
const chartsExpanded = ref(false);
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -607,6 +641,39 @@ onBeforeUnmount(() => {
min-height: 60px;
}
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Dual sparkline container (unique to this card) */
.sparkline-dual-container {
width: 100%;

View File

@@ -1,25 +1,23 @@
<template>
<div class="metric-card clienti-balance-card">
<!-- Main value section -->
<div class="value-section">
<div class="metric-label">Clienți</div>
<div class="metric-value" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
<!-- Header cu total și trend -->
<div class="card-header-mobile">
<div class="header-left">
<span class="header-dot clienti"></span>
<span class="header-label">Clienți</span>
</div>
</div>
<div
class="value-trend trend-indicator"
:class="getTrendClass(trend)"
v-if="trend"
>
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
<div class="header-values">
<span class="header-total" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</span>
<div
class="header-trend"
:class="getTrendClass(trend)"
v-if="trend"
>
<i :class="getTrendIconClass(trend)"></i>
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
</div>
</div>
@@ -61,6 +59,35 @@
</div>
</div>
</div>
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Sparkline chart (collapsible) -->
<div v-show="chartsExpanded" class="charts-content">
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -74,6 +101,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -106,18 +134,27 @@ const props = defineProps({
type: Object,
default: null,
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs
const chartCanvas = ref(null);
let chartInstance = null;
const isRestantExpanded = ref(false);
const chartsExpanded = ref(false);
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value;
};
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
};
};
// Trend icon
// Trend icon (text version)
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
@@ -174,6 +211,21 @@ const getTrendIcon = (trend) => {
}
};
// Trend icon class (PrimeIcons version)
const getTrendIconClass = (trend) => {
if (!trend) return "pi pi-minus";
switch (trend.direction) {
case "up":
return "pi pi-arrow-up";
case "down":
return "pi pi-arrow-down";
case "neutral":
return "pi pi-minus";
default:
return "pi pi-minus";
}
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0;
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
/* Override min-height for balance card */
.clienti-balance-card {
min-height: 320px;
min-height: 280px;
}
/* Value section: horizontal layout */
.value-section {
/* Mobile header with total and trend */
.card-header-mobile {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: var(--space-sm) 0;
margin-bottom: var(--space-sm);
border-bottom: 1px solid var(--surface-border);
}
/* Color classes for positive/negative/neutral (component-specific logic) */
.header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.header-dot.clienti {
background: var(--green-500);
}
.header-label {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.header-values {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-total {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}
.header-total.negative {
color: var(--red-600);
}
.header-total.neutral {
color: var(--text-color);
}
.header-trend {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.header-trend i {
font-size: var(--text-xs);
}
/* Color classes for positive/negative/neutral */
.positive {
color: var(--color-success);
color: var(--green-600);
}
.negative {
color: var(--color-error);
color: var(--red-600);
}
.neutral {
color: var(--color-text);
color: var(--text-color);
}
/* Trend colors */
.trend-up {
color: var(--green-600);
}
.trend-down {
color: var(--red-600);
}
.trend-neutral {
color: var(--text-color-secondary);
}
/* Dark mode */
[data-theme="dark"] .header-total.positive,
[data-theme="dark"] .positive,
[data-theme="dark"] .trend-up {
color: var(--green-400);
}
[data-theme="dark"] .header-total.negative,
[data-theme="dark"] .negative,
[data-theme="dark"] .trend-down {
color: var(--red-400);
}
/* Sparkline chart dimensions */
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
display: block;
}
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Responsive */
@media (max-width: 768px) {
.clienti-balance-card {

View File

@@ -1,49 +1,65 @@
<template>
<div class="balance-dual-card">
<!-- Header -->
<!-- Header cu sold net -->
<div class="card-header">
<span class="card-icon">💰</span>
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
<div class="header-left">
<span class="card-icon">💰</span>
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
</div>
<div class="header-right">
<span class="header-total" :class="getBalanceClass(netBalance)">
{{ formatCurrency(netBalance) }}
</span>
<div
class="header-trend"
:class="getTrendClass(netTrend)"
v-if="netTrend"
>
<i :class="getTrendIconClass(netTrend)"></i>
<span>{{ Math.round(Math.abs(netTrend.value)) }}%</span>
</div>
</div>
</div>
<!-- Main values section - Split layout -->
<div class="values-section">
<!-- Clienți Section -->
<div class="value-block clienti">
<div class="value-label">Clienți</div>
<div class="value-amount" :class="getBalanceClass(clientiTotal)">
{{ formatCurrency(clientiTotal) }}
<!-- Detailed values section - Clienți și Furnizori -->
<div class="balance-items">
<!-- Clienți -->
<div class="balance-row">
<div class="balance-label">
<span class="balance-dot clienti"></span>
Clienți
</div>
<div
class="value-trend"
:class="getTrendClass(clientiTrend)"
v-if="clientiTrend"
>
<span class="trend-icon">{{ getTrendIcon(clientiTrend) }}</span>
<span class="trend-value"
>{{ Math.round(Math.abs(clientiTrend.value)) }}%</span
<div class="balance-values">
<span class="balance-amount" :class="getBalanceClass(clientiTotal)">
{{ formatCurrency(clientiTotal) }}
</span>
<span
class="balance-trend"
:class="getTrendClass(clientiTrend)"
v-if="clientiTrend"
>
{{ getTrendIcon(clientiTrend) }}{{ Math.round(Math.abs(clientiTrend.value)) }}%
</span>
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Furnizori Section -->
<div class="value-block furnizori">
<div class="value-label">Furnizori</div>
<div class="value-amount" :class="getBalanceClass(furnizoriTotal)">
{{ formatCurrency(furnizoriTotal) }}
<!-- Furnizori -->
<div class="balance-row">
<div class="balance-label">
<span class="balance-dot furnizori"></span>
Furnizori
</div>
<div
class="value-trend"
:class="getTrendClass(furnizoriTrend)"
v-if="furnizoriTrend"
>
<span class="trend-icon">{{ getTrendIcon(furnizoriTrend) }}</span>
<span class="trend-value"
>{{ Math.round(Math.abs(furnizoriTrend.value)) }}%</span
<div class="balance-values">
<span class="balance-amount" :class="getBalanceClass(-furnizoriTotal)">
{{ formatCurrency(furnizoriTotal) }}
</span>
<span
class="balance-trend"
:class="getTrendClass(furnizoriTrend)"
v-if="furnizoriTrend"
>
{{ getTrendIcon(furnizoriTrend) }}{{ Math.round(Math.abs(furnizoriTrend.value)) }}%
</span>
</div>
</div>
</div>
@@ -300,7 +316,7 @@ const getTrendClass = (trend) => {
};
};
// Trend icon
// Trend icon (text version)
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
@@ -315,6 +331,43 @@ const getTrendIcon = (trend) => {
}
};
// Trend icon class (PrimeIcons version)
const getTrendIconClass = (trend) => {
if (!trend) return "pi pi-minus";
switch (trend.direction) {
case "up":
return "pi pi-arrow-up";
case "down":
return "pi pi-arrow-down";
case "neutral":
return "pi pi-minus";
default:
return "pi pi-minus";
}
};
// Computed: Net balance (Clienți - Furnizori)
const netBalance = computed(() => {
return props.clientiTotal - props.furnizoriTotal;
});
// Computed: Net trend (average of both trends or dominant)
const netTrend = computed(() => {
if (!props.clientiTrend && !props.furnizoriTrend) return null;
// Use clienti trend as primary if available
if (props.clientiTrend && !props.furnizoriTrend) return props.clientiTrend;
if (!props.clientiTrend && props.furnizoriTrend) return props.furnizoriTrend;
// Calculate combined trend based on net balance change
const avgValue = (props.clientiTrend.value - props.furnizoriTrend.value) / 2;
let direction = "neutral";
if (avgValue > 2) direction = "up";
else if (avgValue < -2) direction = "down";
return { value: Math.abs(avgValue), direction };
});
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return (
@@ -647,12 +700,20 @@ onBeforeUnmount(() => {
border-color: var(--color-primary, #3b82f6);
}
/* Header */
/* Header cu sold net */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
margin-bottom: var(--space-md);
flex-wrap: wrap;
gap: var(--space-sm);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.card-icon {
@@ -662,87 +723,147 @@ onBeforeUnmount(() => {
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-sm, 4px);
background: var(--surface-hover);
border-radius: var(--radius-sm);
}
.card-title {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Values section - Split layout */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.value-label {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--font-medium, 500);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.value-amount {
font-size: var(--text-xl, 1.5rem);
font-weight: var(--font-bold, 700);
line-height: 1.2;
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.value-trend {
.header-right {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
gap: var(--space-sm);
}
.header-total {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}
.header-total.negative {
color: var(--red-600);
}
.header-total.neutral {
color: var(--text-color);
}
.header-trend {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.header-trend i {
font-size: var(--text-xs);
}
/* Balance items - Clienți și Furnizori */
.balance-items {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm) 0;
border-top: 1px solid var(--surface-border);
border-bottom: 1px solid var(--surface-border);
}
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-xs) 0;
}
.balance-label {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
}
.balance-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.balance-dot.clienti {
background: var(--green-500);
}
.balance-dot.furnizori {
background: var(--red-500);
}
.balance-values {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.balance-amount {
font-size: var(--text-base);
font-weight: var(--font-semibold);
font-family: var(--font-mono, monospace);
}
.balance-amount.positive {
color: var(--green-600);
}
.balance-amount.negative {
color: var(--red-600);
}
.balance-amount.neutral {
color: var(--text-color);
}
.balance-trend {
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
/* Trend colors */
.trend-up {
color: var(--color-success, #10b981);
color: var(--green-600);
}
.trend-down {
color: var(--color-danger, #ef4444);
color: var(--red-600);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
color: var(--text-color-secondary);
}
.trend-icon {
font-size: 0.75rem;
/* Dark mode */
[data-theme="dark"] .header-total.positive,
[data-theme="dark"] .balance-amount.positive,
[data-theme="dark"] .trend-up {
color: var(--green-400);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
[data-theme="dark"] .header-total.negative,
[data-theme="dark"] .balance-amount.negative,
[data-theme="dark"] .trend-down {
color: var(--red-400);
}
/* Dual sparkline container - stack vertical */
@@ -955,23 +1076,27 @@ onBeforeUnmount(() => {
/* Responsive */
@media (max-width: 768px) {
.balance-dual-card {
min-height: 380px;
padding: var(--space-md, 1rem);
min-height: 320px;
padding: var(--space-md);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
.card-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
}
.divider {
.header-right {
width: 100%;
height: 1px;
min-height: 1px;
justify-content: space-between;
}
.value-amount {
font-size: var(--text-lg, 1.125rem);
.header-total {
font-size: var(--text-lg);
}
.balance-amount {
font-size: var(--text-sm);
}
.sparkline-chart {
@@ -981,15 +1106,15 @@ onBeforeUnmount(() => {
@media (max-width: 480px) {
.balance-dual-card {
min-height: 340px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
min-height: 280px;
padding: var(--space-sm);
gap: var(--space-sm);
border-radius: 0;
}
.card-header {
padding: 0.25rem;
margin-bottom: 0.25rem;
padding: var(--space-xs);
margin-bottom: var(--space-xs);
}
.card-icon {
@@ -999,11 +1124,15 @@ onBeforeUnmount(() => {
}
.card-title {
font-size: 0.625rem;
font-size: var(--text-xs);
}
.value-amount {
font-size: var(--text-base, 1rem);
.header-total {
font-size: var(--text-base);
}
.balance-amount {
font-size: var(--text-xs);
}
.sparkline-chart {
@@ -1014,10 +1143,6 @@ onBeforeUnmount(() => {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
/* Dark mode support */

View File

@@ -1,25 +1,23 @@
<template>
<div class="metric-card furnizori-balance-card">
<!-- Main value section -->
<div class="value-section">
<div class="metric-label">Furnizori</div>
<div class="metric-value" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
<!-- Header cu total și trend -->
<div class="card-header-mobile">
<div class="header-left">
<span class="header-dot furnizori"></span>
<span class="header-label">Furnizori</span>
</div>
</div>
<div
class="value-trend trend-indicator"
:class="getTrendClass(trend)"
v-if="trend"
>
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
<div class="header-values">
<span class="header-total" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</span>
<div
class="header-trend"
:class="getTrendClass(trend)"
v-if="trend"
>
<i :class="getTrendIconClass(trend)"></i>
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
</div>
</div>
@@ -61,6 +59,35 @@
</div>
</div>
</div>
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Sparkline chart (collapsible) -->
<div v-show="chartsExpanded" class="charts-content">
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -74,6 +101,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -106,18 +134,27 @@ const props = defineProps({
type: Object,
default: null,
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs
const chartCanvas = ref(null);
let chartInstance = null;
const isRestantExpanded = ref(false);
const chartsExpanded = ref(false);
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value;
};
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
};
};
// Trend icon
// Trend icon (text version)
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
@@ -174,6 +211,21 @@ const getTrendIcon = (trend) => {
}
};
// Trend icon class (PrimeIcons version)
const getTrendIconClass = (trend) => {
if (!trend) return "pi pi-minus";
switch (trend.direction) {
case "up":
return "pi pi-arrow-up";
case "down":
return "pi pi-arrow-down";
case "neutral":
return "pi pi-minus";
default:
return "pi pi-minus";
}
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0;
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
/* Override min-height for balance card */
.furnizori-balance-card {
min-height: 320px;
min-height: 280px;
}
/* Value section: horizontal layout */
.value-section {
/* Mobile header with total and trend */
.card-header-mobile {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: var(--space-sm) 0;
margin-bottom: var(--space-sm);
border-bottom: 1px solid var(--surface-border);
}
/* Color classes for positive/negative/neutral (component-specific logic) */
.header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.header-dot.furnizori {
background: var(--red-500);
}
.header-label {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.header-values {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-total {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}
.header-total.negative {
color: var(--red-600);
}
.header-total.neutral {
color: var(--text-color);
}
.header-trend {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.header-trend i {
font-size: var(--text-xs);
}
/* Color classes for positive/negative/neutral */
.positive {
color: var(--color-success);
color: var(--green-600);
}
.negative {
color: var(--color-error);
color: var(--red-600);
}
.neutral {
color: var(--color-text);
color: var(--text-color);
}
/* Trend colors */
.trend-up {
color: var(--green-600);
}
.trend-down {
color: var(--red-600);
}
.trend-neutral {
color: var(--text-color-secondary);
}
/* Dark mode */
[data-theme="dark"] .header-total.positive,
[data-theme="dark"] .positive,
[data-theme="dark"] .trend-up {
color: var(--green-400);
}
[data-theme="dark"] .header-total.negative,
[data-theme="dark"] .negative,
[data-theme="dark"] .trend-down {
color: var(--red-400);
}
/* Sparkline chart dimensions */
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
display: block;
}
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Responsive */
@media (max-width: 768px) {
.furnizori-balance-card {

View File

@@ -1,10 +1,21 @@
<template>
<div class="indicator-item" :class="statusClass">
<!-- Label (top) -->
<div class="indicator-label">{{ label }}</div>
<!-- Label (top) cu toggle pentru descriere -->
<div class="indicator-label">
{{ label }}
<i
v-if="description"
class="pi desc-toggle"
:class="descExpanded ? 'pi-chevron-up' : 'pi-chevron-down'"
@click.stop="toggleDescription"
title="Toggle descriere"
></i>
</div>
<!-- Description (optional) -->
<div v-if="description" class="indicator-description">{{ description }}</div>
<!-- Description (collapsible) -->
<div v-if="description && descExpanded" class="indicator-description slide-down">
{{ description }}
</div>
<!-- Main content: Value centered + Status icon on right -->
<div class="indicator-main">
@@ -62,6 +73,17 @@
</div>
</div>
<!-- YoY Trend indicator (shows variation from first to last sparkline value) -->
<div
v-if="hasSparklineData && trendInfo.text !== '-'"
class="yoy-trend"
:class="trendInfo.class"
>
<i :class="trendInfo.icon"></i>
<span class="trend-value">{{ trendInfo.text }}</span>
<span class="trend-label">vs 12 luni</span>
</div>
<!-- Threshold info -->
<div v-if="thresholdText" class="indicator-threshold">
{{ thresholdText }}
@@ -70,7 +92,14 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed } from 'vue'
// Description toggle state
const descExpanded = ref(false)
const toggleDescription = () => {
descExpanded.value = !descExpanded.value
}
const props = defineProps({
label: {
@@ -264,6 +293,39 @@ const thresholdText = computed(() => {
return parts.join(' | ')
})
// Computed: YoY Trend information (comparing first and last sparkline values)
const trendInfo = computed(() => {
if (!props.sparklineData || props.sparklineData.length < 2) {
return { text: '-', icon: 'pi pi-minus', class: 'trend-neutral' }
}
const validData = props.sparklineData.filter(v => v !== null && v !== undefined)
if (validData.length < 2) {
return { text: '-', icon: 'pi pi-minus', class: 'trend-neutral' }
}
const first = validData[0]
const last = validData[validData.length - 1]
// Handle division by zero
if (first === 0) {
if (last > 0) return { text: '+∞', icon: 'pi pi-arrow-up', class: 'trend-up' }
if (last < 0) return { text: '-∞', icon: 'pi pi-arrow-down', class: 'trend-down' }
return { text: '0%', icon: 'pi pi-minus', class: 'trend-neutral' }
}
const change = ((last - first) / Math.abs(first)) * 100
const sign = change > 0 ? '+' : ''
const text = `${sign}${change.toFixed(1)}%`
if (change > 0) {
return { text, icon: 'pi pi-arrow-up', class: 'trend-up' }
} else if (change < 0) {
return { text, icon: 'pi pi-arrow-down', class: 'trend-down' }
}
return { text: '0%', icon: 'pi pi-minus', class: 'trend-neutral' }
})
// Methods
const handleMouseMove = (event) => {
if (!pointPositions.value.length || !sparklineContainer.value) return
@@ -312,22 +374,62 @@ const handleMouseLeave = () => {
border-color: var(--surface-hover);
}
/* Label (top) */
/* Label (top) with toggle */
.indicator-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
/* Description (optional subtitle) */
/* Description toggle icon */
.desc-toggle {
font-size: var(--text-xs);
color: var(--text-color-secondary);
cursor: pointer;
padding: 2px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
opacity: 0.6;
}
.desc-toggle:hover {
color: var(--primary-color);
opacity: 1;
background: var(--surface-hover);
}
/* Description (collapsible) */
.indicator-description {
font-size: var(--text-xs);
color: var(--text-color-secondary);
text-align: center;
opacity: 0.8;
line-height: 1.3;
margin-top: calc(var(--space-xs) * -1);
padding: var(--space-xs);
background: var(--surface-hover);
border-radius: var(--radius-sm);
margin-bottom: var(--space-xs);
}
/* Slide down animation */
.slide-down {
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Main section: Value + Status Icon */
@@ -450,6 +552,72 @@ const handleMouseLeave = () => {
font-family: var(--font-mono);
}
/* YoY Trend indicator */
.yoy-trend {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
font-size: var(--text-xs);
font-weight: var(--font-medium);
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
background: var(--surface-hover);
}
.yoy-trend i {
font-size: var(--text-sm);
}
.yoy-trend .trend-value {
font-family: var(--font-mono);
font-weight: var(--font-semibold);
}
.yoy-trend .trend-label {
color: var(--text-color-secondary);
font-size: var(--text-2xs);
}
/* Trend colors */
.yoy-trend.trend-up {
color: var(--green-600);
background: var(--green-50);
}
.yoy-trend.trend-down {
color: var(--red-600);
background: var(--red-50);
}
.yoy-trend.trend-neutral {
color: var(--text-color-secondary);
}
/* Dark mode for YoY trend */
[data-theme="dark"] .yoy-trend.trend-up {
color: var(--green-400);
background: rgba(34, 197, 94, 0.15);
}
[data-theme="dark"] .yoy-trend.trend-down {
color: var(--red-400);
background: rgba(239, 68, 68, 0.15);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .yoy-trend.trend-up {
color: var(--green-400);
background: rgba(34, 197, 94, 0.15);
}
:root:not([data-theme]) .yoy-trend.trend-down {
color: var(--red-400);
background: rgba(239, 68, 68, 0.15);
}
}
/* Threshold info */
.indicator-threshold {
font-size: var(--text-xs);

View File

@@ -1,29 +1,81 @@
<template>
<div class="metric-card treasury-dual-card">
<!-- Main values section - Split layout (Casa | Bancă) -->
<div class="values-section">
<!-- Casa Section -->
<div class="value-block casa">
<div class="metric-label">Casa</div>
<div class="metric-value text-success">
{{ formatCurrency(casaTotal) }}
<!-- Treasury items - Casa și Bancă stacked vertical cu expand individual -->
<div class="treasury-items">
<!-- Casa -->
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal > 0">
<div class="treasury-header" @click="toggleCasaExpanded">
<div class="treasury-header-left">
<i
class="pi pi-chevron-right treasury-toggle"
:class="{ expanded: isCasaExpanded }"
></i>
<span class="treasury-label">Casa</span>
</div>
<span class="treasury-value text-success">{{ formatCurrency(casaTotal) }}</span>
</div>
<!-- Casa Sub-items -->
<div v-show="isCasaExpanded && casaItems.length > 0" class="treasury-subitems slide-down">
<div
v-for="(item, idx) in casaItems"
:key="idx"
class="treasury-subitem"
>
<span class="treasury-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
</span>
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Bancă -->
<div class="treasury-group" v-if="bancaItems.length > 0 || bancaTotal > 0">
<div class="treasury-header" @click="toggleBancaExpanded">
<div class="treasury-header-left">
<i
class="pi pi-chevron-right treasury-toggle"
:class="{ expanded: isBancaExpanded }"
></i>
<span class="treasury-label">Bancă</span>
</div>
<span class="treasury-value text-primary">{{ formatCurrency(bancaTotal) }}</span>
</div>
<!-- Bancă Section -->
<div class="value-block banca">
<div class="metric-label">Bancă</div>
<div class="metric-value text-primary">
{{ formatCurrency(bancaTotal) }}
<!-- Bancă Sub-items -->
<div v-show="isBancaExpanded && bancaItems.length > 0" class="treasury-subitems slide-down">
<div
v-for="(item, idx) in bancaItems"
:key="idx"
class="treasury-subitem"
>
<span class="treasury-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
</span>
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Dual sparkline charts - stacked vertical (at the end) -->
<div v-show="chartsExpanded" class="charts-content sparkline-dual-container">
<!-- Grafic Casa -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-success">Casa</div>
@@ -41,77 +93,12 @@
</div>
</div>
<!-- Breakdown section -->
<div
class="breakdown-section"
v-if="casaItems.length > 0 || bancaItems.length > 0"
>
<!-- Casa Breakdown -->
<div class="breakdown-group" v-if="casaItems.length > 0">
<div class="breakdown-header" @click="toggleCasaExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isCasaExpanded }"
></i>
<span class="breakdown-label">Casa</span>
</div>
<span class="breakdown-value">{{ formatCurrency(casaTotal) }}</span>
</div>
<!-- Casa Sub-items -->
<div v-show="isCasaExpanded" class="breakdown-subitems slide-down">
<div
v-for="(item, idx) in casaItems"
:key="idx"
class="breakdown-subitem"
>
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont"
>({{ item.cont }})</span
>
</span>
<span class="breakdown-subvalue">{{
formatCurrency(item.sold)
}}</span>
</div>
</div>
</div>
<!-- Bancă Breakdown -->
<div class="breakdown-group" v-if="bancaItems.length > 0">
<div class="breakdown-header" @click="toggleBancaExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isBancaExpanded }"
></i>
<span class="breakdown-label">Bancă</span>
</div>
<span class="breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
</div>
<!-- Bancă Sub-items -->
<div v-show="isBancaExpanded" class="breakdown-subitems slide-down">
<div
v-for="(item, idx) in bancaItems"
:key="idx"
class="breakdown-subitem"
>
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont"
>({{ item.cont }})</span
>
</span>
<span class="breakdown-subvalue">{{
formatCurrency(item.sold)
}}</span>
</div>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -125,6 +112,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -173,6 +161,10 @@ const props = defineProps({
type: Object,
default: null,
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs pentru 2 canvas-uri separate
@@ -182,6 +174,7 @@ let casaChartInstance = null;
let bancaChartInstance = null;
const isCasaExpanded = ref(false);
const isBancaExpanded = ref(false);
const chartsExpanded = ref(false);
// Toggle functions
const toggleCasaExpanded = () => {
@@ -192,6 +185,10 @@ const toggleBancaExpanded = () => {
isBancaExpanded.value = !isBancaExpanded.value;
};
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -612,34 +609,101 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* Component-specific: Dual-layout for TreasuryDualCard (Casa | Bancă) */
/* Component-specific: TreasuryDualCard (Casa | Bancă) */
/* Override min-height for dual chart layout */
/* Override min-height for treasury card */
.treasury-dual-card {
min-height: 420px;
min-height: 320px;
}
/* Split layout: Casa | Divider | Bancă */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
/* Treasury items container - stacked vertical */
.treasury-items {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* Treasury group (Casa sau Bancă) */
.treasury-group {
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
/* Treasury header - clickable */
.treasury-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: var(--space-md);
cursor: pointer;
user-select: none;
background: var(--surface-ground);
transition: background-color var(--transition-fast);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border);
min-height: 60px;
.treasury-header:hover {
background: var(--surface-hover);
}
.treasury-header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.treasury-toggle {
color: var(--text-color-secondary);
font-size: var(--text-xs);
transition: transform var(--transition-fast);
}
.treasury-toggle.expanded {
transform: rotate(90deg);
}
.treasury-label {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.treasury-value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
/* Treasury sub-items */
.treasury-subitems {
padding: var(--space-sm) var(--space-md) var(--space-md);
background: var(--surface-card);
border-top: 1px solid var(--surface-border);
}
.treasury-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-xs) 0;
}
.treasury-sublabel {
font-size: var(--text-sm);
color: var(--text-color-secondary);
}
.treasury-cont {
font-size: var(--text-xs);
opacity: 0.7;
margin-left: var(--space-xs);
}
.treasury-subvalue {
font-size: var(--text-sm);
font-weight: var(--font-medium);
font-family: var(--font-mono, monospace);
color: var(--text-color);
}
/* Dual sparkline container (unique to this card) */
@@ -672,28 +736,47 @@ onBeforeUnmount(() => {
display: block;
}
/* Component-specific: Account number display in breakdown */
.breakdown-cont {
font-size: 0.8125rem;
opacity: 0.7;
margin-left: 0.25rem;
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Responsive: Stack vertically on mobile */
@media (max-width: 768px) {
.treasury-dual-card {
min-height: 380px;
min-height: 280px;
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
.treasury-value {
font-size: var(--text-lg);
}
.sparkline-chart {
@@ -703,7 +786,15 @@ onBeforeUnmount(() => {
@media (max-width: 480px) {
.treasury-dual-card {
min-height: 340px;
min-height: 240px;
}
.treasury-header {
padding: var(--space-sm);
}
.treasury-value {
font-size: var(--text-base);
}
.sparkline-chart {
@@ -714,9 +805,5 @@ onBeforeUnmount(() => {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
</style>

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
const api = axios.create({
baseURL: import.meta.env.BASE_URL + 'api/reports',
@@ -7,6 +8,14 @@ const api = axios.create({
// Request interceptor for auth token
api.interceptors.request.use((config) => {
// Skip requests if we're already redirecting to login
if (isAuthRedirectInProgress()) {
const controller = new AbortController()
controller.abort()
config.signal = controller.signal
return config
}
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
@@ -19,14 +28,30 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid - redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = import.meta.env.BASE_URL + 'login'
// Use shared handler to prevent race conditions
handleUnauthorized()
}
return Promise.reject(error)
}
)
/**
* Helper for GET requests that include cache metadata
* Returns response data with cache_hit, response_time_ms, cache_source fields
*
* @param {string} url - API endpoint path
* @param {object} options - Axios request config (params, etc.)
* @returns {Promise<object>} Response data with cache metadata
*/
export const getWithCacheInfo = async (url, options = {}) => {
const response = await api.get(url, {
...options,
headers: {
...options.headers,
'X-Include-Cache-Metadata': 'true',
},
})
return response.data
}
export default api

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@reports/services/api";
import api, { getWithCacheInfo } from "@reports/services/api";
export const useDashboardStore = defineStore("dashboard", () => {
// State existent
@@ -20,6 +20,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
loading: false,
error: null,
data: null,
cacheInfo: { hit: false, time: 0, source: null },
});
// State pentru detailed data pagination
@@ -499,14 +500,21 @@ export const useDashboardStore = defineStore("dashboard", () => {
if (luna !== null) params.luna = luna;
if (an !== null) params.an = an;
const response = await api.get("/dashboard/financial-indicators", {
const data = await getWithCacheInfo("/dashboard/financial-indicators", {
params,
});
financialIndicators.value.data = response.data;
financialIndicators.value.data = data;
financialIndicators.value.loading = false;
return { success: true, data: response.data };
// Extract cache metadata
financialIndicators.value.cacheInfo = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
return { success: true, data: data };
} catch (err) {
console.error("Failed to load financial indicators:", err);
@@ -524,6 +532,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
financialIndicators.value.error = errorMessage;
financialIndicators.value.loading = false;
financialIndicators.value.data = null;
financialIndicators.value.cacheInfo = { hit: false, time: 0, source: null };
return { success: false, error: errorMessage };
}
@@ -557,6 +566,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
loading: false,
error: null,
data: null,
cacheInfo: { hit: false, time: 0, source: null },
};
clearCache();
};

View File

@@ -83,6 +83,7 @@
:bancaPreviousSparklineData="bancaPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="treasuryCacheInfo"
/>
</template>
<!-- Page 3: CashFlowMetricCard (original graph card) -->
@@ -98,6 +99,7 @@
:outflowsPreviousSparkline="outflowsPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="cashflowCacheInfo"
/>
</template>
<!-- Page 4: ClientiBalanceCard (original graph card) -->
@@ -110,6 +112,7 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.clienti"
:cacheInfo="netBalanceCacheInfo"
/>
</template>
<!-- Page 5: FurnizoriBalanceCard (original graph card) -->
@@ -122,6 +125,7 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.furnizori"
:cacheInfo="netBalanceCacheInfo"
/>
</template>
<!-- Page 6: FinancialIndicatorsCard (US-015) -->
@@ -130,44 +134,19 @@
:loading="dashboardStore.financialIndicators.loading"
:error="dashboardStore.financialIndicators.error"
:data="dashboardStore.financialIndicators.data"
:initial-period="periodStore.selectedPeriod"
:initial-period="previousPeriodForIndicators"
:cache-info="dashboardStore.financialIndicators.cacheInfo"
mobile
@period-change="handleFinancialIndicatorsPeriodChange"
/>
</template>
</SwipeableCards>
<!-- US-2004: Desktop Solduri Section (sus, fără titlu) -->
<div v-if="!isMobile" class="desktop-solduri-section">
<SolduriCompactCard
type="trezorerie"
:total="totalTrezorerie"
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
:breakdown="treasuryData?.breakdown"
/>
<SolduriCompactCard
type="clienti"
:total="netBalanceData?.clienti_total || 0"
:breakdown="netBalanceData?.breakdown?.clienti"
/>
<SolduriCompactCard
type="furnizori"
:total="netBalanceData?.furnizori_total || 0"
:breakdown="netBalanceData?.breakdown?.furnizori"
/>
<SolduriCompactCard
type="tva"
:total="tvaTotal"
/>
</div>
<!-- Desktop: Grid layout (carduri grafice originale) - collapsible by default -->
<div v-if="!isMobile" class="metrics-row">
<CollapsibleCard
label="Trezorerie"
:value="totalTrezorerie"
icon="pi pi-wallet"
:value-class="totalTrezorerie >= 0 ? 'positive' : 'negative'"
>
<TreasuryDualCard
@@ -183,12 +162,12 @@
:bancaPreviousSparklineData="bancaPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="treasuryCacheInfo"
/>
</CollapsibleCard>
<CollapsibleCard
label="Cash Flow"
:value="netCashFlow"
icon="pi pi-arrows-h"
:value-class="netCashFlow >= 0 ? 'positive' : 'negative'"
>
<CashFlowMetricCard
@@ -202,12 +181,12 @@
:outflowsPreviousSparkline="outflowsPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="cashflowCacheInfo"
/>
</CollapsibleCard>
<CollapsibleCard
label="Clienți"
:value="netBalanceData?.clienti_total || 0"
icon="pi pi-users"
:value-class="(netBalanceData?.clienti_total || 0) >= 0 ? 'positive' : 'negative'"
>
<ClientiBalanceCard
@@ -218,12 +197,12 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.clienti"
:cacheInfo="netBalanceCacheInfo"
/>
</CollapsibleCard>
<CollapsibleCard
label="Furnizori"
:value="netBalanceData?.furnizori_total || 0"
icon="pi pi-truck"
:value-class="(netBalanceData?.furnizori_total || 0) <= 0 ? 'positive' : 'negative'"
>
<FurnizoriBalanceCard
@@ -234,6 +213,7 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.furnizori"
:cacheInfo="netBalanceCacheInfo"
/>
</CollapsibleCard>
</div>
@@ -246,7 +226,8 @@
:loading="dashboardStore.financialIndicators.loading"
:error="dashboardStore.financialIndicators.error"
:data="dashboardStore.financialIndicators.data"
:initial-period="periodStore.selectedPeriod"
:initial-period="previousPeriodForIndicators"
:cache-info="dashboardStore.financialIndicators.cacheInfo"
@period-change="handleFinancialIndicatorsPeriodChange"
/>
</div>
@@ -285,7 +266,7 @@ import MobileDrawerMenu from "@shared/components/mobile/MobileDrawerMenu.vue";
import { useCompanyStore, useAuthStore } from "@reports/stores/sharedStores";
import { useDashboardStore } from "@reports/stores/dashboard";
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
import api from "@reports/services/api";
import api, { getWithCacheInfo } from "@reports/services/api";
import {
exportToExcel,
exportToPDF,
@@ -310,6 +291,11 @@ const monthlyOutflows = ref(0);
const treasuryData = ref(null);
const netBalanceData = ref(null);
// Cache info state for each card
const treasuryCacheInfo = ref({ hit: false, time: 0, source: null });
const netBalanceCacheInfo = ref({ hit: false, time: 0, source: null });
const cashflowCacheInfo = ref({ hit: false, time: 0, source: null });
// New dashboard state
const selectedPeriod = ref("12m");
const selectedChartType = ref("line");
@@ -693,6 +679,20 @@ const currentMonthLabel = computed(() => {
return "Se încarcă...";
});
// Computed property pentru luna anterioară - pentru indicatorii financiari
// Luna curentă e în lucru, deci folosim luna anterioară pentru date finale
const previousPeriodForIndicators = computed(() => {
if (!periodStore.selectedPeriod) return null;
const { luna, an } = periodStore.selectedPeriod;
// Calculează luna anterioară cu rollover la decembrie anul anterior
if (luna === 1) {
return { luna: 12, an: an - 1 };
}
return { luna: luna - 1, an };
});
// Methods
const handleCompanyChanged = async (company) => {
if (company) {
@@ -949,7 +949,7 @@ const handleCompanySelect = async (event) => {
};
// Fixed: Changed company_id to company parameter
// Updated: Added luna/an from period selector
// Updated: Added luna/an from period selector + cache info
const loadMonthlyFlows = async () => {
if (!companyStore.selectedCompany) return;
@@ -960,9 +960,16 @@ const loadMonthlyFlows = async () => {
params.an = periodStore.selectedPeriod.an;
}
const response = await api.get("/dashboard/monthly-flows", { params });
monthlyInflows.value = response.data.inflows || 0;
monthlyOutflows.value = response.data.outflows || 0;
const data = await getWithCacheInfo("/dashboard/monthly-flows", { params });
monthlyInflows.value = data.inflows || 0;
monthlyOutflows.value = data.outflows || 0;
// Extract cache metadata
cashflowCacheInfo.value = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
} catch (error) {
console.error("Failed to load monthly flows:", error);
}
@@ -978,8 +985,15 @@ const loadTreasuryBreakdown = async () => {
params.an = periodStore.selectedPeriod.an;
}
const response = await api.get("/dashboard/treasury-breakdown", { params });
treasuryData.value = response.data;
const data = await getWithCacheInfo("/dashboard/treasury-breakdown", { params });
treasuryData.value = data;
// Extract cache metadata
treasuryCacheInfo.value = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
} catch (error) {
console.error("Failed to load treasury breakdown:", error);
}
@@ -995,13 +1009,13 @@ const loadNetBalanceBreakdown = async () => {
params.an = periodStore.selectedPeriod.an;
}
const response = await api.get("/dashboard/net-balance-breakdown", { params });
const data = await getWithCacheInfo("/dashboard/net-balance-breakdown", { params });
// Folosește direct datele structurate de la backend
netBalanceData.value = {
clienti_total: response.data.clienti_total || 0,
furnizori_total: response.data.furnizori_total || 0,
breakdown: response.data.breakdown || {
clienti_total: data.clienti_total || 0,
furnizori_total: data.furnizori_total || 0,
breakdown: data.breakdown || {
clienti: {
total: 0,
in_termen: { total: 0 },
@@ -1015,6 +1029,13 @@ const loadNetBalanceBreakdown = async () => {
},
};
// Extract cache metadata
netBalanceCacheInfo.value = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
console.log("[NetBalance] Loaded balance data:", {
clienti_total: netBalanceData.value.clienti_total,
furnizori_total: netBalanceData.value.furnizori_total,
@@ -1029,9 +1050,32 @@ const loadDashboardData = async () => {
if (!companyStore.selectedCompany) return;
isLoading.value = true;
// FIX: Reset state înainte de a încărca date noi
// Previne afișarea datelor de la firma anterioară în timpul încărcării
treasuryData.value = null;
netBalanceData.value = null;
monthlyInflows.value = 0;
monthlyOutflows.value = 0;
// Reset cache info
treasuryCacheInfo.value = { hit: false, time: 0, source: null };
netBalanceCacheInfo.value = { hit: false, time: 0, source: null };
cashflowCacheInfo.value = { hit: false, time: 0, source: null };
// Reset dashboard store financial indicators (afișează loading state imediat)
dashboardStore.financialIndicators.loading = true;
dashboardStore.financialIndicators.error = null;
dashboardStore.financialIndicators.data = null;
dashboardStore.financialIndicators.cacheInfo = { hit: false, time: 0, source: null };
const luna = periodStore.selectedPeriod?.luna || null;
const an = periodStore.selectedPeriod?.an || null;
// Pentru indicatori financiari folosim luna anterioară (luna curentă e în lucru)
const prevPeriod = previousPeriodForIndicators.value;
const indicatorLuna = prevPeriod?.luna || null;
const indicatorAn = prevPeriod?.an || null;
try {
await Promise.all([
dashboardStore.loadDashboardSummary(
@@ -1044,11 +1088,11 @@ const loadDashboardData = async () => {
loadMonthlyFlows(),
loadTreasuryBreakdown(),
loadNetBalanceBreakdown(),
// US-014: Load financial indicators for desktop card
// US-014: Load financial indicators for desktop card (luna anterioară)
dashboardStore.loadFinancialIndicators(
companyStore.selectedCompany.id_firma,
luna,
an,
indicatorLuna,
indicatorAn,
),
]);
} catch (error) {
@@ -1449,14 +1493,6 @@ onUnmounted(() => {
padding: 0 var(--space-md);
}
/* US-2004: Desktop Solduri Section - 2x2 grid (2 cards per row) */
.desktop-solduri-section {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
/* Metrics Cards Layout - Component-specific grid layouts */
.metrics-row {
display: grid;