Files
roa2web-service-auto/src/modules/reports/components/dashboard/cards/MaturityAnalysisCard.vue
Marius Mutu d507a81b0a feat: Implement unified Vue SPA with granular service control
Consolidate Reports and Data Entry apps into a single Vue.js SPA with:

Architecture:
- Module-based structure with lazy-loaded routes (@reports, @data-entry)
- Error boundaries per module to prevent cascade failures
- Dual API proxy in Vite for microservices (reports:8001, data-entry:8003)
- Pinia store factories for shared auth, company, and period stores
- Vite path aliases for clear module boundaries (@shared, @reports, @data-entry)

Service Management:
- Granular service control scripts (backend-reports.sh, backend-data-entry.sh, bot.sh, frontend.sh)
- 87% faster frontend restart: 7s vs 53s full restart
- 38% faster full startup: 33s vs 53s via parallel backend initialization
- Enhanced start-dev.sh with proper service timeouts (OCR: 30s, Vite: 15s, Bot: 10s)
- status.sh for comprehensive health checks

Features:
- Auto-select first company on login with period auto-load
- Hamburger menu with feature toggle support
- JWT token auto-injection via axios interceptors
- Unified header with company/period selectors
- IIS web.config for production deployment with multi-API routing

UX Improvements:
- Vue watchers for reactive company/period loading
- Lazy store initialization with graceful error handling
- Period persistence per user+company in localStorage
- Feature flags for optional modules

Deployment:
- Single IIS site serves unified frontend with API proxy rules
- Maintains separate backend processes for microservices
- Windows line ending fixes (.env CRLF → LF conversion)

Stats: 112 files changed, 38,342 insertions(+), 2,342 deletions(-)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-24 19:06:23 +02:00

814 lines
18 KiB
Vue

<template>
<div class="maturity-card">
<div class="card-header">
<h3>Analiză Comparativă Scadențe</h3>
<select
v-model="selectedPeriod"
@change="handlePeriodChange"
class="period-selector"
:disabled="isLoading"
>
<option value="7d">7 zile</option>
<option value="1m">1 lună</option>
<option value="3m">3 luni</option>
<option value="6m">6 luni</option>
<option value="12m">12 luni</option>
<option value="all">Toate</option>
</select>
</div>
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se încarcă analiza scadențelor...</p>
</div>
<div v-else-if="error" class="error-state">
<div class="error-icon">!</div>
<p>{{ error }}</p>
<button @click="loadData" class="retry-btn">Încearcă din nou</button>
</div>
<div v-else class="maturity-comparison">
<!-- Clients Side -->
<div class="clients-side">
<h4 class="side-title clients-title">
Clienți - De încasat
<span class="total-amount">{{ formatCurrency(clientsTotal) }}</span>
</h4>
<div class="maturity-list">
<div
v-for="(client, index) in clientsData"
:key="`client-${index}`"
class="maturity-item"
:class="{
overdue: client.daysOverdue > 0,
critical: client.daysOverdue > 30,
}"
>
<div class="item-info">
<span class="client-name">{{ client.name }}</span>
<span class="due-info">
<span v-if="client.daysOverdue > 0" class="overdue-days">
Restant {{ client.daysOverdue }} zile
</span>
<span v-else class="due-date">
Scadent în {{ Math.abs(client.daysOverdue) }} zile
</span>
</span>
</div>
<div class="amount-bar">
<div class="bar-container">
<div
class="bar-fill clients-bar"
:style="{
width: getBarWidth(client.amount, maxClientAmount) + '%',
}"
></div>
</div>
<span class="amount-value">{{
formatCurrency(client.amount)
}}</span>
</div>
</div>
<div v-if="clientsData.length === 0" class="empty-state">
<p>Nu există facturi de încasat pentru această perioadă</p>
</div>
</div>
</div>
<!-- Divider -->
<div class="comparison-divider"></div>
<!-- Suppliers Side -->
<div class="suppliers-side">
<h4 class="side-title suppliers-title">
Furnizori - De plătit
<span class="total-amount">{{ formatCurrency(suppliersTotal) }}</span>
</h4>
<div class="maturity-list">
<div
v-for="(supplier, index) in suppliersData"
:key="`supplier-${index}`"
class="maturity-item"
:class="{
overdue: supplier.daysOverdue > 0,
critical: supplier.daysOverdue > 30,
}"
>
<div class="item-info">
<span class="supplier-name">{{ supplier.name }}</span>
<span class="due-info">
<span v-if="supplier.daysOverdue > 0" class="overdue-days">
Restant {{ supplier.daysOverdue }} zile
</span>
<span v-else class="due-date">
Scadent în {{ Math.abs(supplier.daysOverdue) }} zile
</span>
</span>
</div>
<div class="amount-bar">
<div class="bar-container">
<div
class="bar-fill suppliers-bar"
:style="{
width:
getBarWidth(supplier.amount, maxSupplierAmount) + '%',
}"
></div>
</div>
<span class="amount-value">{{
formatCurrency(supplier.amount)
}}</span>
</div>
</div>
<div v-if="suppliersData.length === 0" class="empty-state">
<p>Nu există facturi de plătit pentru această perioadă</p>
</div>
</div>
</div>
</div>
<!-- Balance Indicator -->
<div v-if="!isLoading && !error" class="balance-indicator">
<div class="balance-content">
<div class="balance-text">
<span class="balance-label">{{ balanceLabel }}</span>
<span class="balance-amount" :class="balanceClass">
{{ formatCurrency(Math.abs(balance)) }}
</span>
</div>
<div v-if="recommendations.length > 0" class="recommendations">
<details>
<summary>Recomandări</summary>
<ul>
<li v-for="(rec, index) in recommendations" :key="index">
{{ rec }}
</li>
</ul>
</details>
</div>
</div>
</div>
<!-- Footer with period info -->
<div v-if="!isLoading && !error" class="card-footer">
<div class="period-info">
<span class="period-label">Perioada analizată:</span>
<span class="period-value">{{ getPeriodLabel(selectedPeriod) }}</span>
</div>
<div class="last-updated">
<span class="update-label">Actualizat:</span>
<span class="update-time">{{ formatLastUpdated(lastUpdated) }}</span>
<button
@click="refreshData"
class="refresh-btn"
:disabled="isLoading"
title="Reîmprospătează datele"
>
<i
class="pi pi-refresh refresh-icon"
:class="{ spinning: isLoading }"
></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useDashboardStore } from "@reports/stores/dashboard";
// Props
const props = defineProps({
companyId: {
type: [Number, String],
required: true,
},
});
// Emits
const emit = defineEmits(["periodChanged"]);
// Store
const dashboardStore = useDashboardStore();
// Reactive state
const selectedPeriod = ref("1m");
const isLoading = ref(false);
const error = ref(null);
const lastUpdated = ref(null);
// Mock data structure - in production this would come from API
const maturityData = ref({
clients: [],
suppliers: [],
balance: 0,
recommendations: [],
});
// Romanian currency formatter
const formatCurrency = (value) => {
if (value === null || value === undefined) return "0,00 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
// Computed properties
const clientsData = computed(() => maturityData.value.clients || []);
const suppliersData = computed(() => maturityData.value.suppliers || []);
const recommendations = computed(
() => maturityData.value.recommendations || [],
);
const clientsTotal = computed(() =>
clientsData.value.reduce((sum, client) => sum + (client.amount || 0), 0),
);
const suppliersTotal = computed(() =>
suppliersData.value.reduce(
(sum, supplier) => sum + (supplier.amount || 0),
0,
),
);
const balance = computed(() => clientsTotal.value - suppliersTotal.value);
const balanceClass = computed(() =>
balance.value < 0 ? "deficit" : "surplus",
);
const balanceIcon = computed(() => (balance.value < 0 ? "📉" : "📈"));
const balanceLabel = computed(() =>
balance.value < 0 ? "Deficit estimat:" : "Surplus estimat:",
);
const maxClientAmount = computed(() =>
Math.max(...clientsData.value.map((c) => c.amount || 0), 1),
);
const maxSupplierAmount = computed(() =>
Math.max(...suppliersData.value.map((s) => s.amount || 0), 1),
);
// Methods
const getBarWidth = (amount, maxAmount) => {
return maxAmount > 0 ? Math.min((amount / maxAmount) * 100, 100) : 0;
};
const getPeriodLabel = (period) => {
const labels = {
"7d": "Toate restanțele + următoarele 7 zile",
"1m": "Toate restanțele + următoarea lună",
"3m": "Toate restanțele + următoarele 3 luni",
"6m": "Toate restanțele + următoarele 6 luni",
"12m": "Toate restanțele + următorul an",
all: "Toate soldurile (fără filtru)",
};
return labels[period] || period;
};
const formatLastUpdated = (timestamp) => {
if (!timestamp) return "Necunoscut";
return new Date(timestamp).toLocaleString("ro-RO");
};
const handlePeriodChange = () => {
emit("periodChanged", selectedPeriod.value);
loadData();
};
const refreshData = () => {
loadData(true);
};
const loadData = async (forceRefresh = false) => {
if (!props.companyId) {
error.value = "ID firmă necunoscut";
return;
}
isLoading.value = true;
error.value = null;
try {
// Apelăm API-ul real pentru a obține datele de scadențe
const response = await dashboardStore.loadMaturityData(
props.companyId,
selectedPeriod.value,
);
if (response && response.success) {
maturityData.value = response.data;
lastUpdated.value = new Date();
} else {
throw new Error(response?.error || "Eroare la încărcarea datelor");
}
} catch (err) {
console.error("Failed to load maturity data:", err);
error.value =
err.message ||
"Eroare la încărcarea datelor. Vă rugăm încercați din nou.";
} finally {
isLoading.value = false;
}
};
// Watchers
watch(
() => props.companyId,
(newCompanyId) => {
if (newCompanyId) {
loadData();
}
},
{ immediate: false },
);
// Lifecycle
onMounted(() => {
if (props.companyId) {
loadData();
}
});
</script>
<style scoped>
/* Base Card Styles */
.maturity-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius, 8px);
padding: 0;
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast, 0.3s ease);
overflow: hidden;
}
.maturity-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
/* Card Header */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-lg, 1rem);
border-bottom: 1px solid var(--color-border);
background: transparent;
}
.card-header h3 {
margin: 0;
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text);
}
.period-selector {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm, 0.875rem);
cursor: pointer;
transition: border-color 0.2s ease;
}
.period-selector:hover {
border-color: var(--color-primary);
}
.period-selector:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading and Error States */
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl, 2rem);
text-align: center;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top: 3px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-md, 1rem);
}
.error-icon {
font-size: 2rem;
margin-bottom: var(--space-sm, 0.5rem);
}
.retry-btn {
margin-top: var(--space-md, 1rem);
padding: 0.5rem 1rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
transition: background-color 0.2s ease;
}
.retry-btn:hover {
background: var(--color-primary-dark);
}
/* Comparison Layout */
.maturity-comparison {
display: grid;
grid-template-columns: 1fr 1px 1fr;
gap: var(--space-lg, 1rem);
padding: var(--space-lg, 1rem);
min-height: 300px;
}
.comparison-divider {
background: var(--color-border);
margin: var(--space-md, 1rem) 0;
}
/* Side Headers */
.side-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 var(--space-md, 1rem) 0;
font-size: var(--text-base, 1rem);
font-weight: var(--font-semibold, 600);
padding-bottom: var(--space-sm, 0.5rem);
border-bottom: 1px solid var(--color-border);
}
.clients-title {
color: var(--color-text);
}
.suppliers-title {
color: var(--color-text);
}
.total-amount {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-bold, 700);
padding: 0.25rem 0.5rem;
background: var(--color-bg-secondary, #f8f9fa);
border-radius: var(--radius-sm, 4px);
}
/* Maturity Lists */
.maturity-list {
display: flex;
flex-direction: column;
gap: var(--space-sm, 0.5rem);
max-height: 250px;
overflow-y: auto;
padding-right: var(--space-xs, 0.25rem);
}
.maturity-list::-webkit-scrollbar {
width: 4px;
}
.maturity-list::-webkit-scrollbar-track {
background: var(--color-bg-secondary, #f8f9fa);
border-radius: 2px;
}
.maturity-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 2px;
}
/* Maturity Items */
.maturity-item {
padding: var(--space-sm, 0.5rem);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.maturity-item:hover {
background: var(--color-bg-secondary, #f8f9fa);
border-color: var(--color-primary);
}
.maturity-item.overdue {
border: 1px solid var(--color-border);
}
.maturity-item.critical {
border: 1px solid var(--color-border);
}
.item-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs, 0.25rem);
}
.client-name,
.supplier-name {
font-weight: var(--font-medium, 500);
color: var(--color-text);
font-size: var(--text-sm, 0.875rem);
}
.due-info {
font-size: var(--text-xs, 0.75rem);
color: var(--color-text-secondary);
}
.overdue-days {
color: var(--color-text);
font-weight: var(--font-medium, 500);
}
.due-date {
color: var(--color-text-secondary);
}
/* Amount Bars */
.amount-bar {
display: flex;
align-items: center;
gap: var(--space-sm, 0.5rem);
}
.bar-container {
flex: 1;
height: 8px;
background: var(--color-bg-secondary, #f8f9fa);
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.clients-bar {
background: var(--color-primary);
}
.suppliers-bar {
background: var(--color-secondary, #6b7280);
}
.amount-value {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--font-bold, 700);
color: var(--color-text);
white-space: nowrap;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl, 2rem);
text-align: center;
color: var(--color-text-secondary);
}
/* Balance Indicator */
.balance-indicator {
padding: var(--space-lg, 1rem) 0;
border-top: 1px solid var(--color-border);
background: transparent;
}
.balance-content {
display: flex;
align-items: center;
gap: var(--space-md, 1rem);
}
.balance-text {
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.25rem);
}
.balance-label {
font-size: var(--text-sm, 0.875rem);
color: var(--color-text-secondary);
}
.balance-amount {
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-bold, 700);
color: var(--color-text);
}
.balance-amount.surplus {
color: var(--color-text);
}
.balance-amount.deficit {
color: var(--color-text);
}
/* Recommendations */
.recommendations {
margin-left: auto;
}
.recommendations details {
cursor: pointer;
}
.recommendations summary {
font-size: var(--text-sm, 0.875rem);
color: var(--color-primary);
list-style: none;
padding: 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: background-color 0.2s ease;
}
.recommendations summary:hover {
background: rgba(59, 130, 246, 0.1);
}
.recommendations ul {
margin: var(--space-sm, 0.5rem) 0 0 0;
padding-left: var(--space-lg, 1rem);
}
.recommendations li {
font-size: var(--text-sm, 0.875rem);
color: var(--color-text-secondary);
margin-bottom: var(--space-xs, 0.25rem);
}
/* Card Footer */
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md, 0.75rem) var(--space-lg, 1rem);
border-top: 1px solid var(--color-border);
background: var(--color-bg);
}
.period-info,
.last-updated {
display: flex;
align-items: center;
gap: var(--space-xs, 0.25rem);
font-size: var(--text-xs, 0.75rem);
}
.period-label,
.update-label {
color: var(--color-text-secondary);
}
.period-value,
.update-time {
color: var(--color-text);
font-weight: var(--font-medium, 500);
}
.refresh-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
margin-left: var(--space-sm, 0.5rem);
border-radius: var(--radius-sm, 4px);
transition: background-color 0.2s ease;
}
.refresh-btn:hover {
background: var(--color-bg-secondary, #f8f9fa);
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-icon {
display: inline-block;
font-size: var(--text-sm, 0.875rem);
transition: transform 0.3s ease;
}
.refresh-icon.spinning {
animation: spin 1s linear infinite;
}
/* Animations */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Responsive Design */
@media (max-width: 1024px) {
.maturity-comparison {
grid-template-columns: 1fr;
gap: var(--space-md, 1rem);
}
.comparison-divider {
display: none;
}
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
gap: var(--space-sm, 0.5rem);
align-items: stretch;
}
.card-header h3 {
text-align: center;
font-size: var(--text-base, 1rem);
}
.period-selector {
width: 100%;
}
.maturity-comparison {
padding: var(--space-md, 0.75rem);
}
.balance-content {
flex-direction: column;
text-align: center;
}
.recommendations {
margin-left: 0;
width: 100%;
}
.card-footer {
flex-direction: column;
gap: var(--space-sm, 0.5rem);
align-items: center;
}
.side-title {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs, 0.25rem);
}
.total-amount {
align-self: flex-end;
}
}
@media (max-width: 480px) {
.maturity-card {
margin: 0 -var(--space-sm, 0.5rem);
}
.card-header,
.maturity-comparison,
.balance-indicator,
.card-footer {
padding: var(--space-md, 0.75rem);
}
.maturity-list {
max-height: 200px;
}
}
</style>