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:
21
src/App.vue
21
src/App.vue
@@ -48,6 +48,7 @@ import { createCompaniesStore } from '@shared/stores/companies.js'
|
||||
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod.js'
|
||||
import { menuSections } from '@/config/menu.js'
|
||||
import { getEnabledMenuSections } from '@/config/features.js'
|
||||
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -68,6 +69,14 @@ const authApi = axios.create({
|
||||
|
||||
// Add interceptor to inject auth token from localStorage
|
||||
authApi.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}`
|
||||
@@ -75,6 +84,18 @@ authApi.interceptors.request.use(config => {
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor to handle 401 errors
|
||||
authApi.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
// Use shared handler to prevent race conditions
|
||||
handleUnauthorized()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Store definitions (factories return store definitions)
|
||||
const useAuthStore = createAuthStore(authApi)
|
||||
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||
|
||||
// Use relative path - works with both Vite dev proxy and IIS production proxy
|
||||
const baseURL = import.meta.env.BASE_URL + 'api/data-entry'
|
||||
@@ -10,6 +11,14 @@ const api = axios.create({
|
||||
|
||||
// Request interceptor for auth token and company header
|
||||
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}`
|
||||
@@ -57,18 +66,18 @@ api.interceptors.response.use(
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ API Error:', {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
code: error.code,
|
||||
message: error.message
|
||||
})
|
||||
// Skip logging for aborted requests (happens during auth redirect)
|
||||
if (error.code !== 'ERR_CANCELED') {
|
||||
console.error('❌ API Error:', {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
code: error.code,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
123
src/shared/components/CacheFooter.vue
Normal file
123
src/shared/components/CacheFooter.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="cache-footer" v-if="showCacheInfo">
|
||||
<span class="cache-badge" :class="badgeClass">
|
||||
{{ cacheText }} | {{ (responseTimeMs || 0).toFixed(2) }}ms
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
cacheHit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
responseTimeMs: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
cacheSource: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Show only when we have valid response time
|
||||
const showCacheInfo = computed(() => {
|
||||
return props.responseTimeMs > 0;
|
||||
});
|
||||
|
||||
// Text to display: "cached L1", "cached L2", or "db"
|
||||
const cacheText = computed(() => {
|
||||
if (props.cacheHit && props.cacheSource) {
|
||||
return `cached ${props.cacheSource}`;
|
||||
} else if (props.cacheHit) {
|
||||
return "cached";
|
||||
}
|
||||
return "db";
|
||||
});
|
||||
|
||||
// CSS class for styling based on cache status
|
||||
const badgeClass = computed(() => {
|
||||
if (props.cacheHit) {
|
||||
return props.cacheSource === "L1" ? "cache-l1" : "cache-l2";
|
||||
}
|
||||
return "cache-db";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cache-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-xs) 0;
|
||||
margin-top: var(--space-sm);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.cache-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-medium);
|
||||
font-family: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* L1 Cache - Memory (fastest) */
|
||||
.cache-badge.cache-l1 {
|
||||
background: var(--green-50);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
/* L2 Cache - SQLite */
|
||||
.cache-badge.cache-l2 {
|
||||
background: var(--blue-50);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
/* Database - Fresh query */
|
||||
.cache-badge.cache-db {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme="dark"] .cache-badge.cache-l1 {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--green-400);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cache-badge.cache-l2 {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--blue-400);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cache-badge.cache-db {
|
||||
background: var(--surface-100);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Respect system dark mode preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .cache-badge.cache-l1 {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--green-400);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .cache-badge.cache-l2 {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--blue-400);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .cache-badge.cache-db {
|
||||
background: var(--surface-100);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
src/shared/utils/authRedirect.js
Normal file
58
src/shared/utils/authRedirect.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Auth Redirect Utility
|
||||
*
|
||||
* Handles 401 Unauthorized responses with protection against race conditions.
|
||||
* When multiple API calls return 401 simultaneously, only the first one
|
||||
* triggers the redirect to prevent UI flickering and double redirects.
|
||||
*/
|
||||
|
||||
// Flag to prevent multiple simultaneous redirects
|
||||
let isRedirecting = false
|
||||
|
||||
/**
|
||||
* Handle 401 Unauthorized error by clearing auth data and redirecting to login.
|
||||
* Uses a flag to prevent race conditions when multiple API calls fail simultaneously.
|
||||
*
|
||||
* @returns {boolean} true if redirect was initiated, false if already redirecting
|
||||
*/
|
||||
export function handleUnauthorized() {
|
||||
// Prevent multiple redirects
|
||||
if (isRedirecting) {
|
||||
console.log('[Auth] Redirect already in progress, skipping...')
|
||||
return false
|
||||
}
|
||||
|
||||
isRedirecting = true
|
||||
console.log('[Auth] 401 Unauthorized - clearing auth data and redirecting to login')
|
||||
|
||||
// Clear all auth-related data from localStorage
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user')
|
||||
|
||||
// Small delay to ensure localStorage is cleared before redirect
|
||||
// This also allows any pending API calls to complete/fail gracefully
|
||||
setTimeout(() => {
|
||||
window.location.href = import.meta.env.BASE_URL + 'login'
|
||||
}, 100)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a redirect is currently in progress.
|
||||
* Useful for components that want to skip error handling during redirect.
|
||||
*
|
||||
* @returns {boolean} true if redirect is in progress
|
||||
*/
|
||||
export function isAuthRedirectInProgress() {
|
||||
return isRedirecting
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the redirect flag.
|
||||
* Should only be used in tests or special cases.
|
||||
*/
|
||||
export function resetAuthRedirectFlag() {
|
||||
isRedirecting = false
|
||||
}
|
||||
Reference in New Issue
Block a user