Initial commit: ROA2WEB - FastAPI + Vue.js + Telegram Bot

Modern ERP Reports Application with microservices architecture

Tech Stack:
- Backend: FastAPI + python-oracledb (Oracle DB integration)
- Frontend: Vue.js 3 + PrimeVue + Vite
- Telegram Bot: python-telegram-bot + SQLite
- Infrastructure: Shared database pool, JWT authentication, SSH tunnel

Features:
- FastAPI backend with async Oracle connection pool
- Vue.js 3 responsive frontend with PrimeVue components
- Telegram bot alternative interface
- Microservices architecture with shared components
- Complete deployment support (Linux Docker + Windows IIS)
- Comprehensive testing (Playwright E2E + pytest)

Repository Structure:
- reports-app/ - Main application (backend, frontend, telegram-bot)
- shared/ - Shared components (database pool, auth, utils)
- deployment/ - Deployment scripts (Linux & Windows)
- docs/ - Project documentation
- security/ - Security scanning and git hooks
This commit is contained in:
2025-10-25 14:55:08 +03:00
commit 6b13ffa183
237 changed files with 70035 additions and 0 deletions

View File

@@ -0,0 +1,715 @@
<template>
<div class="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="value-label">Încasări</div>
<div class="value-amount positive">
{{ formatCurrency(inflowsValue) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Plăți Section -->
<div class="value-block outflows">
<div class="value-label">Plăți</div>
<div class="value-amount negative">
{{ 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-label">Î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-label">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()
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.10 // 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()
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.10 // 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>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.cashflow-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.cashflow-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* 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: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
}
/* Dual sparkline container - stack vertical */
.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, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-label {
font-size: var(--card-label-size);
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
/* Culori distinctive pentru label-uri */
.sparkline-wrapper:first-child .sparkline-label {
color: #10b981; /* Verde pentru Încasări */
}
.sparkline-wrapper:last-child .sparkline-label {
color: #ef4444; /* Roșu pentru Plăți */
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive */
@media (max-width: 768px) {
.cashflow-card {
min-height: 380px;
padding: var(--space-md, 1rem);
}
.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) {
.cashflow-card {
min-height: 380px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.sparkline-chart {
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
}
.sparkline-wrapper {
padding: 0.25rem;
border: 1px solid var(--color-border, #e5e7eb);
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.cashflow-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>