feat: Frontend CSS refactoring and test improvements

Frontend:
- Refactored CSS architecture with new utility classes
- Updated dashboard components styling
- Improved responsive grid system
- Enhanced typography and variables
- Updated E2E and integration tests

Added:
- Claude Code slash commands for validation
- SSH tunnel and start test scripts

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 21:08:47 +02:00
parent 05fc705fe5
commit 12ac2b671e
58 changed files with 7783 additions and 3539 deletions

View File

@@ -44,493 +44,524 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables)
Chart.register(...registerables);
const props = defineProps({
inflowsValue: {
type: Number,
default: 0
default: 0,
},
outflowsValue: {
type: Number,
default: 0
default: 0,
},
inflowsTrend: {
type: Object,
default: null
default: null,
},
outflowsTrend: {
type: Object,
default: null
default: null,
},
inflowsSparkline: {
type: Array,
default: () => []
default: () => [],
},
outflowsSparkline: {
type: Array,
default: () => []
default: () => [],
},
inflowsPreviousSparkline: {
type: Array,
default: () => []
default: () => [],
},
outflowsPreviousSparkline: {
type: Array,
default: () => []
default: () => [],
},
sparklineLabels: {
type: Array,
default: () => []
default: () => [],
},
previousSparklineLabels: {
type: Array,
default: () => []
}
})
default: () => [],
},
});
// Refs pentru 2 canvas-uri separate
const inflowsCanvas = ref(null)
const outflowsCanvas = ref(null)
let inflowsChartInstance = null
let outflowsChartInstance = null
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',
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))
}
maximumFractionDigits: 0,
}).format(Math.abs(amount));
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.inflowsSparkline.length > 0 && props.outflowsSparkline.length > 0
})
return (
props.inflowsSparkline.length > 0 && props.outflowsSparkline.length > 0
);
});
// Initialize Încasări chart
const initializeInflowsChart = async () => {
if (!inflowsCanvas.value || props.inflowsSparkline.length === 0) {
return
return;
}
// Destroy existing chart
if (inflowsChartInstance) {
inflowsChartInstance.destroy()
inflowsChartInstance = null
inflowsChartInstance.destroy();
inflowsChartInstance = null;
}
await nextTick()
await nextTick();
const ctx = inflowsCanvas.value.getContext('2d')
const ctx = inflowsCanvas.value.getContext("2d");
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.inflowsSparkline.map((_, i) => `L${i + 1}`)
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
}]
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) {
if (
props.inflowsPreviousSparkline &&
props.inflowsPreviousSparkline.length > 0
) {
datasets.push({
label: 'Încasări (anul precedent)',
label: "Încasări (anul precedent)",
data: props.inflowsPreviousSparkline,
borderColor: 'rgba(16, 185, 129, 0.4)',
backgroundColor: 'rgba(16, 185, 129, 0.05)',
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
})
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 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
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)
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
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
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
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
inflowsChartInstance = new Chart(ctx, {
type: 'line',
type: "line",
data: {
labels: labels,
datasets: datasets
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: 'rgba(107, 114, 128, 0.9)',
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: 'line'
}
pointStyle: "line",
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
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 || '',
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',
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}`
}
}
}
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
drawBorder: false,
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
maxTicksLimit: 6,
},
border: {
display: false
}
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: '#10b981',
color: "#10b981",
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function(value) {
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0)
}
return value.toFixed(0);
},
},
border: {
display: false
}
}
}
}
})
}
display: false,
},
},
},
},
});
};
// Initialize Plăți chart
const initializeOutflowsChart = async () => {
if (!outflowsCanvas.value || props.outflowsSparkline.length === 0) {
return
return;
}
// Destroy existing chart
if (outflowsChartInstance) {
outflowsChartInstance.destroy()
outflowsChartInstance = null
outflowsChartInstance.destroy();
outflowsChartInstance = null;
}
await nextTick()
await nextTick();
const ctx = outflowsCanvas.value.getContext('2d')
const ctx = outflowsCanvas.value.getContext("2d");
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.outflowsSparkline.map((_, i) => `L${i + 1}`)
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
}]
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) {
if (
props.outflowsPreviousSparkline &&
props.outflowsPreviousSparkline.length > 0
) {
datasets.push({
label: 'Plăți (anul precedent)',
label: "Plăți (anul precedent)",
data: props.outflowsPreviousSparkline,
borderColor: 'rgba(239, 68, 68, 0.4)',
backgroundColor: 'rgba(239, 68, 68, 0.05)',
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
})
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 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
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)
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
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
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
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
outflowsChartInstance = new Chart(ctx, {
type: 'line',
type: "line",
data: {
labels: labels,
datasets: datasets
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: 'rgba(107, 114, 128, 0.9)',
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: 'line'
}
pointStyle: "line",
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
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 || '',
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',
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}`
}
}
}
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
drawBorder: false,
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
maxTicksLimit: 6,
},
border: {
display: false
}
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: '#ef4444',
color: "#ef4444",
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function(value) {
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0)
}
return value.toFixed(0);
},
},
border: {
display: false
}
}
}
}
})
}
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 })
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()
])
})
await Promise.all([initializeInflowsChart(), initializeOutflowsChart()]);
});
onBeforeUnmount(() => {
if (inflowsChartInstance) {
inflowsChartInstance.destroy()
inflowsChartInstance = null
inflowsChartInstance.destroy();
inflowsChartInstance = null;
}
if (outflowsChartInstance) {
outflowsChartInstance.destroy()
outflowsChartInstance = null
outflowsChartInstance.destroy();
outflowsChartInstance = null;
}
})
});
</script>
<style scoped>