Files
roa2web-service-auto/src/modules/reports/components/dashboard/cards/CashFlowMetricCard.vue
Marius Mutu c5e051ad80 feat: Migrate to ultrathin monolith architecture
Consolidate 3 separate applications (reports-app, data-entry-app, telegram-bot) into a unified
architecture with single backend and frontend:

Backend Changes:
- Unified FastAPI backend at backend/ with modular structure
- Modules: reports, data_entry, telegram in backend/modules/
- Centralized config.py and main.py with all routers registered
- Single worker mode (--workers 1) for Telegram bot compatibility
- Shared Oracle connection pool and JWT authentication
- Unified requirements.txt and environment configuration

Frontend Changes:
- Single Vue.js SPA with module-based routing
- Unified frontend at src/ with modules in src/modules/{reports,data-entry}/
- Shared components and stores in src/shared/
- Error boundaries for module isolation
- Dual API proxy in Vite for module communication

Infrastructure:
- New unified startup scripts: start-prod.sh, start-test.sh, start-backend.sh
- Environment templates: .env.dev.example, .env.test.example, .env.prod.example
- Updated deployment scripts for Windows IIS
- Simplified SSH tunnel management

Documentation:
- Comprehensive CLAUDE.md with architecture overview
- Module-specific docs in docs/{data-entry,telegram}/
- Architecture decision records in docs/ARCHITECTURE-DECISIONS.md
- Deployment guides consolidated in deployment/windows/docs/

This migration reduces complexity, improves maintainability, and enables easier
deployment while maintaining all existing functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-29 23:48:14 +02:00

672 lines
17 KiB
Vue

<template>
<div class="metric-card cashflow-card">
<!-- Main values section - Split layout (Încasări | Plăți) -->
<div class="values-section">
<!-- Încasări Section -->
<div class="value-block inflows">
<div class="metric-label">Încasări</div>
<div class="metric-value text-success">
{{ formatCurrency(inflowsValue) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Plăți Section -->
<div class="value-block outflows">
<div class="metric-label">Plăți</div>
<div class="metric-value text-error">
{{ formatCurrency(outflowsValue) }}
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Încasări -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-success">Încasări</div>
<div class="sparkline-chart">
<canvas ref="inflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Plăți -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-error">Plăți</div>
<div class="sparkline-chart">
<canvas ref="outflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
const props = defineProps({
inflowsValue: {
type: Number,
default: 0,
},
outflowsValue: {
type: Number,
default: 0,
},
inflowsTrend: {
type: Object,
default: null,
},
outflowsTrend: {
type: Object,
default: null,
},
inflowsSparkline: {
type: Array,
default: () => [],
},
outflowsSparkline: {
type: Array,
default: () => [],
},
inflowsPreviousSparkline: {
type: Array,
default: () => [],
},
outflowsPreviousSparkline: {
type: Array,
default: () => [],
},
sparklineLabels: {
type: Array,
default: () => [],
},
previousSparklineLabels: {
type: Array,
default: () => [],
},
});
// Refs pentru 2 canvas-uri separate
const inflowsCanvas = ref(null);
const outflowsCanvas = ref(null);
let inflowsChartInstance = null;
let outflowsChartInstance = null;
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return (
props.inflowsSparkline.length > 0 && props.outflowsSparkline.length > 0
);
});
// Initialize Încasări chart
const initializeInflowsChart = async () => {
if (!inflowsCanvas.value || props.inflowsSparkline.length === 0) {
return;
}
// Destroy existing chart
if (inflowsChartInstance) {
inflowsChartInstance.destroy();
inflowsChartInstance = null;
}
await nextTick();
// Double-check canvas is still available after nextTick
if (!inflowsCanvas.value) {
console.warn('[CashFlowMetricCard] Inflows canvas ref not available after nextTick');
return;
}
const ctx = inflowsCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.inflowsSparkline.map((_, i) => `L${i + 1}`);
// Prepare datasets
const datasets = [
{
label: "Încasări (curent)",
data: props.inflowsSparkline,
borderColor: "#10b981",
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#10b981",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (
props.inflowsPreviousSparkline &&
props.inflowsPreviousSparkline.length > 0
) {
datasets.push({
label: "Încasări (anul precedent)",
data: props.inflowsPreviousSparkline,
borderColor: "rgba(16, 185, 129, 0.4)",
backgroundColor: "rgba(16, 185, 129, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(16, 185, 129, 0.4)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
// Calculate limits including both datasets
const allDataPoints = [...props.inflowsSparkline];
if (
props.inflowsPreviousSparkline &&
props.inflowsPreviousSparkline.length > 0
) {
allDataPoints.push(...props.inflowsPreviousSparkline);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataMean =
allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length;
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25; // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2;
const targetRange = Math.max(dataRange, minVisibleRange);
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2;
let calculatedMax = center + targetRange / 2;
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.1; // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
inflowsChartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: "line",
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#10b981",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Initialize Plăți chart
const initializeOutflowsChart = async () => {
if (!outflowsCanvas.value || props.outflowsSparkline.length === 0) {
return;
}
// Destroy existing chart
if (outflowsChartInstance) {
outflowsChartInstance.destroy();
outflowsChartInstance = null;
}
await nextTick();
// Double-check canvas is still available after nextTick
if (!outflowsCanvas.value) {
console.warn('[CashFlowMetricCard] Outflows canvas ref not available after nextTick');
return;
}
const ctx = outflowsCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.outflowsSparkline.map((_, i) => `L${i + 1}`);
// Prepare datasets
const datasets = [
{
label: "Plăți (curent)",
data: props.outflowsSparkline,
borderColor: "#ef4444",
backgroundColor: "rgba(239, 68, 68, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#ef4444",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (
props.outflowsPreviousSparkline &&
props.outflowsPreviousSparkline.length > 0
) {
datasets.push({
label: "Plăți (anul precedent)",
data: props.outflowsPreviousSparkline,
borderColor: "rgba(239, 68, 68, 0.4)",
backgroundColor: "rgba(239, 68, 68, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(239, 68, 68, 0.4)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
// Calculate limits including both datasets
const allDataPoints = [...props.outflowsSparkline];
if (
props.outflowsPreviousSparkline &&
props.outflowsPreviousSparkline.length > 0
) {
allDataPoints.push(...props.outflowsPreviousSparkline);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataMean =
allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length;
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25; // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2;
const targetRange = Math.max(dataRange, minVisibleRange);
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2;
let calculatedMax = center + targetRange / 2;
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.1; // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
outflowsChartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: "line",
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#ef4444",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Watch for data changes
watch(
() => [
props.inflowsSparkline,
props.outflowsSparkline,
props.sparklineLabels,
props.inflowsPreviousSparkline,
props.outflowsPreviousSparkline,
props.previousSparklineLabels,
],
async () => {
await Promise.all([initializeInflowsChart(), initializeOutflowsChart()]);
},
{ deep: true },
);
// Lifecycle hooks
onMounted(async () => {
await Promise.all([initializeInflowsChart(), initializeOutflowsChart()]);
});
onBeforeUnmount(() => {
if (inflowsChartInstance) {
inflowsChartInstance.destroy();
inflowsChartInstance = null;
}
if (outflowsChartInstance) {
outflowsChartInstance.destroy();
outflowsChartInstance = null;
}
});
</script>
<style scoped>
/* Component-specific: Dual-chart layout for CashFlowMetricCard */
/* Override min-height for dual chart layout */
.cashflow-card {
min-height: 420px;
}
/* Split layout: Încasări | Divider | Plăți */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border);
min-height: 60px;
}
/* Dual sparkline container (unique to this card) */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
/* Chart.js canvas sizing (required for proper rendering) */
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive: Stack vertically on mobile */
@media (max-width: 768px) {
.cashflow-card {
min-height: 380px;
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.sparkline-chart {
height: 150px;
}
.sparkline-wrapper {
padding: 0.25rem;
}
}
</style>