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>
This commit is contained in:
551
src/modules/reports/components/dashboard/CompanySelectorMini.vue
Normal file
551
src/modules/reports/components/dashboard/CompanySelectorMini.vue
Normal file
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<div class="company-selector-mini" ref="dropdownContainer">
|
||||
<div class="company-dropdown" ref="dropdown">
|
||||
<button
|
||||
class="company-trigger"
|
||||
@click="toggleDropdown"
|
||||
:aria-expanded="dropdownOpen"
|
||||
aria-label="Select company"
|
||||
title="Alt+Q to quick select"
|
||||
>
|
||||
<div class="company-info">
|
||||
<span class="company-name">{{ selectedCompanyName }}</span>
|
||||
<span class="company-code">{{ selectedCompanyCode }}</span>
|
||||
</div>
|
||||
<i
|
||||
class="pi pi-chevron-down"
|
||||
:class="{ 'rotate-180': dropdownOpen }"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="dropdownOpen"
|
||||
class="company-dropdown-panel"
|
||||
:class="{ 'panel-open': dropdownOpen }"
|
||||
>
|
||||
<div class="dropdown-search">
|
||||
<div class="search-wrapper">
|
||||
<i class="pi pi-search search-icon"></i>
|
||||
<input
|
||||
ref="searchInput"
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search companies..."
|
||||
class="search-input"
|
||||
@keydown="handleKeyDown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="company-list">
|
||||
<div
|
||||
v-for="(company, index) in filteredCompanies"
|
||||
:key="company.id_firma || company.id"
|
||||
class="company-item"
|
||||
:class="{
|
||||
active: company.id_firma === selectedCompany?.id_firma,
|
||||
'keyboard-highlighted': isHighlighted(index),
|
||||
}"
|
||||
@click="selectCompany(company)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
>
|
||||
<div class="company-details">
|
||||
<div class="company-main-name">{{ company.name }}</div>
|
||||
<div class="company-sub-info">
|
||||
<span class="company-cui">CUI: {{ company.fiscal_code }}</span>
|
||||
<span class="company-separator">•</span>
|
||||
<span class="company-status" :class="company.status">{{
|
||||
company.status
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i
|
||||
v-if="company.id_firma === selectedCompany?.id_firma"
|
||||
class="pi pi-check company-selected-icon"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCompanies.length === 0" class="no-results">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>No companies found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
|
||||
import { useCompanyStore } from "../../stores/companies";
|
||||
|
||||
export default {
|
||||
name: "CompanySelectorMini",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "company-changed"],
|
||||
setup(props, { emit }) {
|
||||
const companiesStore = useCompanyStore();
|
||||
const dropdown = ref(null);
|
||||
const dropdownContainer = ref(null);
|
||||
const searchInput = ref(null);
|
||||
const dropdownOpen = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const highlightedIndex = ref(-1);
|
||||
|
||||
const selectedCompany = computed({
|
||||
get: () => props.modelValue || companiesStore.selectedCompany,
|
||||
set: (value) => {
|
||||
emit("update:modelValue", value);
|
||||
companiesStore.setSelectedCompany(value);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCompanyName = computed(() => {
|
||||
return selectedCompany.value?.name || "Select Company";
|
||||
});
|
||||
|
||||
const selectedCompanyCode = computed(() => {
|
||||
return selectedCompany.value?.fiscal_code
|
||||
? `CUI: ${selectedCompany.value.fiscal_code}`
|
||||
: "";
|
||||
});
|
||||
|
||||
const filteredCompanies = computed(() => {
|
||||
const companies = companiesStore.companies || [];
|
||||
if (!searchQuery.value || searchQuery.value.trim() === "") {
|
||||
return companies;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim();
|
||||
return companies.filter(
|
||||
(company) =>
|
||||
company.name?.toLowerCase().includes(query) ||
|
||||
company.fiscal_code?.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
if (dropdownOpen.value) {
|
||||
searchQuery.value = "";
|
||||
highlightedIndex.value = -1;
|
||||
// Focus on search input after dropdown opens
|
||||
await nextTick();
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownOpen.value = false;
|
||||
searchQuery.value = "";
|
||||
};
|
||||
|
||||
const selectCompany = (company) => {
|
||||
selectedCompany.value = company;
|
||||
emit("company-changed", company);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const scrollToHighlighted = () => {
|
||||
nextTick(() => {
|
||||
const highlightedElement = document.querySelector(
|
||||
".company-item.keyboard-highlighted",
|
||||
);
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (!dropdownOpen.value || filteredCompanies.value.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + 1) % filteredCompanies.value.length;
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex.value <= 0) {
|
||||
highlightedIndex.value = filteredCompanies.value.length - 1;
|
||||
} else {
|
||||
highlightedIndex.value--;
|
||||
}
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (
|
||||
highlightedIndex.value >= 0 &&
|
||||
highlightedIndex.value < filteredCompanies.value.length
|
||||
) {
|
||||
selectCompany(filteredCompanies.value[highlightedIndex.value]);
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const isHighlighted = (index) => {
|
||||
return index === highlightedIndex.value;
|
||||
};
|
||||
|
||||
const openWithShortcut = async () => {
|
||||
// Scroll to selector
|
||||
if (dropdownContainer.value) {
|
||||
dropdownContainer.value.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for scroll to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Open dropdown and focus
|
||||
if (!dropdownOpen.value) {
|
||||
dropdownOpen.value = true;
|
||||
highlightedIndex.value = -1;
|
||||
searchQuery.value = "";
|
||||
await nextTick();
|
||||
searchInput.value?.focus();
|
||||
} else {
|
||||
// If already open, just focus
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalKeyDown = (event) => {
|
||||
// Check for Alt+Q (left-hand shortcut)
|
||||
if (event.altKey && event.key === "q") {
|
||||
event.preventDefault();
|
||||
openWithShortcut();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for search query changes and reset highlighted index
|
||||
watch(searchQuery, () => {
|
||||
highlightedIndex.value = -1;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
document.addEventListener("keydown", handleGlobalKeyDown);
|
||||
// Load companies if not already loaded
|
||||
if (companiesStore.companies.length === 0) {
|
||||
companiesStore.loadCompanies();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
dropdown,
|
||||
dropdownContainer,
|
||||
searchInput,
|
||||
dropdownOpen,
|
||||
searchQuery,
|
||||
highlightedIndex,
|
||||
selectedCompany,
|
||||
selectedCompanyName,
|
||||
selectedCompanyCode,
|
||||
filteredCompanies,
|
||||
toggleDropdown,
|
||||
closeDropdown,
|
||||
selectCompany,
|
||||
handleKeyDown,
|
||||
isHighlighted,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.company-selector-mini {
|
||||
position: relative;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.company-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.company-trigger:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.company-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.company-code {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pi-chevron-down {
|
||||
transition: transform var(--transition-fast);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.company-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.panel-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
padding: var(--space-sm);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--space-sm);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-xl);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.company-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.company-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.company-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.company-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.company-item.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.company-item.keyboard-highlighted {
|
||||
background: var(--color-bg-secondary);
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.company-item.active.keyboard-highlighted {
|
||||
/* When both active and highlighted, outline with semi-transparent white */
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.company-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-main-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.company-sub-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.company-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.company-status.active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.company-status.inactive {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.company-selected-icon {
|
||||
color: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.company-selector-mini {
|
||||
max-width: 200px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
min-width: auto;
|
||||
max-width: 200px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.company-info {
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
font-size: var(--text-xs);
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.company-code {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.company-dropdown-panel {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: 60px;
|
||||
width: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens */
|
||||
@media (max-width: 400px) {
|
||||
.company-selector-mini {
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.company-info {
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
max-width: 110px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
738
src/modules/reports/components/dashboard/DetailedDataTable.vue
Normal file
738
src/modules/reports/components/dashboard/DetailedDataTable.vue
Normal file
@@ -0,0 +1,738 @@
|
||||
<template>
|
||||
<div class="detailed-data-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Date Detaliate</h2>
|
||||
<div class="section-controls">
|
||||
<!-- Selector tip date -->
|
||||
<select
|
||||
v-model="selectedType"
|
||||
@change="loadData"
|
||||
class="data-type-select"
|
||||
>
|
||||
<option value="clients">Clienți</option>
|
||||
<option value="suppliers">Furnizori</option>
|
||||
<option value="treasury">Trezorerie</option>
|
||||
</select>
|
||||
|
||||
<!-- Căutare -->
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
@input="handleSearch"
|
||||
type="text"
|
||||
placeholder="Căutare..."
|
||||
class="search-input"
|
||||
/>
|
||||
<i class="pi pi-search"></i>
|
||||
</div>
|
||||
|
||||
<!-- Export buttons -->
|
||||
<button @click="exportExcel" class="btn btn-sm btn-outline">
|
||||
<i class="pi pi-file-excel"></i> Excel
|
||||
</button>
|
||||
<button @click="exportPDF" class="btn btn-sm btn-outline">
|
||||
<i class="pi pi-file-pdf"></i> PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabel cu date -->
|
||||
<div class="table-container">
|
||||
<table class="detailed-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in displayColumns" :key="column.field">
|
||||
{{ column.header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="selectedType === 'treasury'">
|
||||
<!-- Treasury - normal table without grouping -->
|
||||
<tr v-for="row in paginatedData" :key="row.id">
|
||||
<td v-for="column in displayColumns" :key="column.field">
|
||||
{{ formatValue(row[column.field], column.type) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<!-- Clients/Suppliers - grouped with expand/collapse -->
|
||||
<template v-for="group in paginatedGroups" :key="group.name">
|
||||
<!-- Single invoice: show direct row -->
|
||||
<tr
|
||||
v-if="group.facturi.length === 1"
|
||||
class="single-invoice-row"
|
||||
:class="{ 'row-restant': group.hasRestant }"
|
||||
>
|
||||
<td>
|
||||
<strong>{{ group.name }}</strong>
|
||||
</td>
|
||||
<td>{{ group.facturi[0].numar_document }}</td>
|
||||
<td>{{ formatValue(group.facturi[0].data_document, "date") }}</td>
|
||||
<td>{{ formatValue(group.facturi[0].data_scadenta, "date") }}</td>
|
||||
<td>{{ formatValue(group.facturi[0].facturat, "currency") }}</td>
|
||||
<td>
|
||||
{{
|
||||
formatValue(
|
||||
group.facturi[0][
|
||||
selectedType === "clients" ? "incasat" : "achitat"
|
||||
],
|
||||
"currency",
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
:class="{
|
||||
'sold-restant': group.facturi[0].status === 'Restant',
|
||||
}"
|
||||
>
|
||||
{{ formatValue(group.facturi[0].sold, "currency") }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Multiple invoices: show expand/collapse -->
|
||||
<template v-else>
|
||||
<!-- Group row (client/supplier header with subtotal) -->
|
||||
<tr
|
||||
class="group-row"
|
||||
:class="{ 'has-restant': group.hasRestant }"
|
||||
@click="toggleClient(group.name)"
|
||||
>
|
||||
<td class="group-name-cell">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span class="facturi-count"
|
||||
>({{ group.facturi.length }})</span
|
||||
>
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
<td
|
||||
class="subtotal-cell"
|
||||
:class="{ 'sold-restant': group.hasRestant }"
|
||||
>
|
||||
<strong>{{
|
||||
formatValue(group.totalSold, "currency")
|
||||
}}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Detail rows (invoices) - only if expanded -->
|
||||
<template v-if="isExpanded(group.name)">
|
||||
<tr
|
||||
v-for="(factura, idx) in group.facturi"
|
||||
:key="`${group.name}-${idx}`"
|
||||
class="detail-row"
|
||||
:class="getRowClass(factura)"
|
||||
>
|
||||
<td class="detail-name">
|
||||
{{ factura.client || factura.furnizor || "" }}
|
||||
</td>
|
||||
<td>{{ factura.numar_document }}</td>
|
||||
<td>{{ formatValue(factura.data_document, "date") }}</td>
|
||||
<td>{{ formatValue(factura.data_scadenta, "date") }}</td>
|
||||
<td>
|
||||
{{
|
||||
formatValue(
|
||||
factura[
|
||||
selectedType === "clients" ? "facturat" : "facturat"
|
||||
],
|
||||
"currency",
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
formatValue(
|
||||
factura[
|
||||
selectedType === "clients" ? "incasat" : "achitat"
|
||||
],
|
||||
"currency",
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td :class="{ 'sold-restant': factura.status === 'Restant' }">
|
||||
{{ formatValue(factura.sold, "currency") }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="totals-row">
|
||||
<td><strong>TOTAL</strong></td>
|
||||
<td v-for="column in displayColumns.slice(1)" :key="column.field">
|
||||
<strong v-if="column.showTotal">
|
||||
{{ formatValue(calculateTotal(column.field), column.type) }}
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginare -->
|
||||
<div class="pagination-wrapper">
|
||||
<Paginator
|
||||
:rows="rowsPerPage"
|
||||
:totalRecords="totalRecords"
|
||||
v-model:first="firstRow"
|
||||
:rowsPerPageOptions="[10, 25, 50, 100]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useDashboardStore } from "@/stores/dashboard";
|
||||
import { useCompanyStore } from "@/stores/companies";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Paginator from "primevue/paginator";
|
||||
import * as XLSX from "xlsx";
|
||||
import jsPDF from "jspdf";
|
||||
import "jspdf-autotable";
|
||||
|
||||
const dashboardStore = useDashboardStore();
|
||||
const companyStore = useCompanyStore();
|
||||
const toast = useToast();
|
||||
|
||||
// State
|
||||
const selectedType = ref("clients");
|
||||
const searchTerm = ref("");
|
||||
const data = ref([]);
|
||||
const firstRow = ref(0);
|
||||
const rowsPerPage = ref(25);
|
||||
const expandedClients = ref(new Set());
|
||||
|
||||
// Columns configuration based on type
|
||||
const columns = computed(() => {
|
||||
switch (selectedType.value) {
|
||||
case "clients":
|
||||
return [
|
||||
{ field: "client", header: "Client", type: "text" },
|
||||
{ field: "numar_document", header: "Nr. Document", type: "text" },
|
||||
{ field: "data_document", header: "Data Document", type: "date" },
|
||||
{ field: "data_scadenta", header: "Data Scadență", type: "date" },
|
||||
{
|
||||
field: "facturat",
|
||||
header: "Facturat",
|
||||
type: "currency",
|
||||
showTotal: true,
|
||||
},
|
||||
{
|
||||
field: "incasat",
|
||||
header: "Încasat",
|
||||
type: "currency",
|
||||
showTotal: true,
|
||||
},
|
||||
{ field: "sold", header: "Sold", type: "currency", showTotal: true },
|
||||
];
|
||||
case "suppliers":
|
||||
return [
|
||||
{ field: "furnizor", header: "Furnizor", type: "text" },
|
||||
{ field: "numar_document", header: "Nr. Document", type: "text" },
|
||||
{ field: "data_document", header: "Data Document", type: "date" },
|
||||
{ field: "data_scadenta", header: "Data Scadență", type: "date" },
|
||||
{
|
||||
field: "facturat",
|
||||
header: "Facturat",
|
||||
type: "currency",
|
||||
showTotal: true,
|
||||
},
|
||||
{
|
||||
field: "achitat",
|
||||
header: "Achitat",
|
||||
type: "currency",
|
||||
showTotal: true,
|
||||
},
|
||||
{ field: "sold", header: "Sold", type: "currency", showTotal: true },
|
||||
];
|
||||
case "treasury":
|
||||
return [
|
||||
{ field: "cont", header: "Cont", type: "text" },
|
||||
{ field: "nume_cont", header: "Nume Cont", type: "text" },
|
||||
{ field: "sold", header: "Sold", type: "currency", showTotal: true },
|
||||
{ field: "valuta", header: "Valută", type: "text" },
|
||||
{ field: "tip", header: "Tip", type: "text" },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Display columns for header (without first column for grouped tables)
|
||||
const displayColumns = computed(() => {
|
||||
if (selectedType.value === "treasury") {
|
||||
return columns.value;
|
||||
}
|
||||
// For clients/suppliers, keep all columns in header
|
||||
return columns.value;
|
||||
});
|
||||
|
||||
// Filtered data based on search
|
||||
const filteredData = computed(() => {
|
||||
if (!searchTerm.value) return data.value;
|
||||
|
||||
return data.value.filter((row) => {
|
||||
return Object.values(row).some((val) =>
|
||||
String(val).toLowerCase().includes(searchTerm.value.toLowerCase()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Group data by client/supplier
|
||||
const groupedData = computed(() => {
|
||||
if (selectedType.value === "treasury") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups = {};
|
||||
const nameField = selectedType.value === "clients" ? "client" : "furnizor";
|
||||
|
||||
filteredData.value.forEach((row) => {
|
||||
const clientName = row[nameField];
|
||||
if (!clientName) return;
|
||||
|
||||
if (!groups[clientName]) {
|
||||
groups[clientName] = {
|
||||
name: clientName,
|
||||
facturi: [],
|
||||
totalSold: 0,
|
||||
hasRestant: false,
|
||||
};
|
||||
}
|
||||
|
||||
groups[clientName].facturi.push(row);
|
||||
groups[clientName].totalSold += row.sold || 0;
|
||||
if (row.status === "Restant") {
|
||||
groups[clientName].hasRestant = true;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(groups);
|
||||
});
|
||||
|
||||
// Paginated groups
|
||||
const paginatedGroups = computed(() => {
|
||||
if (selectedType.value === "treasury") {
|
||||
return [];
|
||||
}
|
||||
const start = firstRow.value;
|
||||
const end = start + rowsPerPage.value;
|
||||
return groupedData.value.slice(start, end);
|
||||
});
|
||||
|
||||
// Paginated data (for treasury)
|
||||
const paginatedData = computed(() => {
|
||||
if (selectedType.value !== "treasury") {
|
||||
return [];
|
||||
}
|
||||
const end = firstRow.value + rowsPerPage.value;
|
||||
return filteredData.value.slice(firstRow.value, end);
|
||||
});
|
||||
|
||||
// Total records for paginator
|
||||
const totalRecords = computed(() => {
|
||||
if (selectedType.value === "treasury") {
|
||||
return filteredData.value.length;
|
||||
}
|
||||
return groupedData.value.length;
|
||||
});
|
||||
|
||||
// Expand/collapse functions
|
||||
const toggleClient = (clientName) => {
|
||||
if (expandedClients.value.has(clientName)) {
|
||||
expandedClients.value.delete(clientName);
|
||||
} else {
|
||||
expandedClients.value.add(clientName);
|
||||
}
|
||||
};
|
||||
|
||||
const isExpanded = (clientName) => {
|
||||
return expandedClients.value.has(clientName);
|
||||
};
|
||||
|
||||
const getRowClass = (row) => {
|
||||
if (row.status === "Restant") return "row-restant";
|
||||
return "row-in-termen";
|
||||
};
|
||||
|
||||
// Methods
|
||||
const loadData = async () => {
|
||||
try {
|
||||
if (!companyStore.selectedCompany) {
|
||||
toast.add({
|
||||
severity: "warn",
|
||||
summary: "Atenție",
|
||||
detail: "Vă rugăm să selectați o companie",
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await dashboardStore.loadDetailedData(
|
||||
selectedType.value,
|
||||
companyStore.selectedCompany.id_firma,
|
||||
);
|
||||
data.value = response.data;
|
||||
// Reset expanded state when loading new data
|
||||
expandedClients.value.clear();
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare",
|
||||
detail: "Nu s-au putut încărca datele detaliate",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (value, type) => {
|
||||
switch (type) {
|
||||
case "currency":
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
}).format(value || 0);
|
||||
case "date":
|
||||
if (!value) return "-";
|
||||
// Handle Oracle date format (YYYY-MM-DD or Date object)
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return value; // Return original if invalid
|
||||
return date.toLocaleDateString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
case "badge":
|
||||
return value;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotal = (field) => {
|
||||
return filteredData.value.reduce((sum, row) => sum + (row[field] || 0), 0);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
firstRow.value = 0; // Reset pagination on search
|
||||
expandedClients.value.clear(); // Reset expanded state on search
|
||||
};
|
||||
|
||||
const exportExcel = () => {
|
||||
const ws = XLSX.utils.json_to_sheet(filteredData.value);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, selectedType.value);
|
||||
XLSX.writeFile(wb, `${selectedType.value}_${new Date().toISOString()}.xlsx`);
|
||||
};
|
||||
|
||||
const exportPDF = () => {
|
||||
const doc = new jsPDF();
|
||||
const tableColumns = columns.value.map((c) => c.header);
|
||||
const tableRows = filteredData.value.map((row) =>
|
||||
columns.value.map((c) => formatValue(row[c.field], c.type)),
|
||||
);
|
||||
|
||||
doc.autoTable({
|
||||
head: [tableColumns],
|
||||
body: tableRows,
|
||||
theme: "grid",
|
||||
});
|
||||
|
||||
doc.save(`${selectedType.value}_${new Date().toISOString()}.pdf`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
watch(selectedType, () => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// Watch for company changes to reload data
|
||||
watch(
|
||||
() => companyStore.selectedCompany,
|
||||
(newCompany) => {
|
||||
if (newCompany) {
|
||||
loadData();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detailed-data-section {
|
||||
margin-top: 2rem;
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-type-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.5rem 2.5rem 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.search-wrapper i {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--color-bg-muted);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.detailed-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detailed-table th,
|
||||
.detailed-table td {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.detailed-table th {
|
||||
background: #ffffff;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.025em;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Group row styling */
|
||||
.group-row {
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
border-top: 1px solid var(--color-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.group-row:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.group-row.has-restant:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Single invoice row styling */
|
||||
.single-invoice-row {
|
||||
background: #ffffff;
|
||||
font-weight: 400;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.single-invoice-row:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.single-invoice-row.row-restant:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.single-invoice-row td:first-child {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.facturi-count {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.subtotal-cell {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Detail row styling */
|
||||
.detail-row {
|
||||
font-size: 11px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
padding-left: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.detail-row.row-restant:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.detail-row.row-in-termen:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Sold restant - only color the amount text */
|
||||
.sold-restant {
|
||||
color: rgb(239, 68, 68);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detailed-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.totals-row {
|
||||
background: #f8f9fa !important;
|
||||
border-top: 2px solid var(--color-border) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.section-controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin: 1rem -1rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.detailed-table th,
|
||||
.detailed-table td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.section-controls {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-controls > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
384
src/modules/reports/components/dashboard/PeriodSelectorMini.vue
Normal file
384
src/modules/reports/components/dashboard/PeriodSelectorMini.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="period-selector-mini" ref="dropdownContainer">
|
||||
<div class="period-dropdown" ref="dropdown">
|
||||
<button
|
||||
class="period-trigger"
|
||||
@click="toggleDropdown"
|
||||
:disabled="!companyStore.selectedCompany"
|
||||
:aria-expanded="dropdownOpen"
|
||||
aria-label="Select accounting period"
|
||||
>
|
||||
<div class="period-info">
|
||||
<span class="period-label">Perioada:</span>
|
||||
<span class="period-name">{{ selectedPeriodDisplay }}</span>
|
||||
</div>
|
||||
<i
|
||||
class="pi pi-chevron-down"
|
||||
:class="{ 'rotate-180': dropdownOpen }"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="dropdownOpen"
|
||||
class="period-dropdown-panel"
|
||||
:class="{ 'panel-open': dropdownOpen }"
|
||||
>
|
||||
<div class="period-list">
|
||||
<div
|
||||
v-for="(period, index) in periodStore.periods"
|
||||
:key="`${period.an}-${period.luna}`"
|
||||
class="period-item"
|
||||
:class="{
|
||||
active: isSelected(period),
|
||||
'keyboard-highlighted': isHighlighted(index),
|
||||
}"
|
||||
@click="selectPeriod(period)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
>
|
||||
<div class="period-details">
|
||||
{{ period.display_name }}
|
||||
</div>
|
||||
<i v-if="isSelected(period)" class="pi pi-check period-selected-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="periodStore.periods.length === 0" class="no-results">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>Nu sunt perioade disponibile</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { useAccountingPeriodStore } from "../../stores/accountingPeriod";
|
||||
import { useCompanyStore } from "../../stores/companies";
|
||||
|
||||
export default {
|
||||
name: "PeriodSelectorMini",
|
||||
emits: ["period-changed"],
|
||||
setup(props, { emit }) {
|
||||
const periodStore = useAccountingPeriodStore();
|
||||
const companyStore = useCompanyStore();
|
||||
const dropdown = ref(null);
|
||||
const dropdownContainer = ref(null);
|
||||
const dropdownOpen = ref(false);
|
||||
const highlightedIndex = ref(-1);
|
||||
|
||||
const selectedPeriodDisplay = computed(() => {
|
||||
return periodStore.selectedPeriod?.display_name || "Selectare perioada";
|
||||
});
|
||||
|
||||
const isSelected = (period) => {
|
||||
if (!periodStore.selectedPeriod) return false;
|
||||
return (
|
||||
period.an === periodStore.selectedPeriod.an &&
|
||||
period.luna === periodStore.selectedPeriod.luna
|
||||
);
|
||||
};
|
||||
|
||||
const isHighlighted = (index) => {
|
||||
return index === highlightedIndex.value;
|
||||
};
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (!companyStore.selectedCompany) return;
|
||||
dropdownOpen.value = !dropdownOpen.value;
|
||||
if (dropdownOpen.value) {
|
||||
highlightedIndex.value = -1;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownOpen.value = false;
|
||||
};
|
||||
|
||||
const selectPeriod = (period) => {
|
||||
periodStore.setSelectedPeriod(period);
|
||||
emit("period-changed", period);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const scrollToHighlighted = () => {
|
||||
nextTick(() => {
|
||||
const highlightedElement = document.querySelector(
|
||||
".period-item.keyboard-highlighted"
|
||||
);
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (!dropdownOpen.value || periodStore.periods.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + 1) % periodStore.periods.length;
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (highlightedIndex.value <= 0) {
|
||||
highlightedIndex.value = periodStore.periods.length - 1;
|
||||
} else {
|
||||
highlightedIndex.value--;
|
||||
}
|
||||
scrollToHighlighted();
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (
|
||||
highlightedIndex.value >= 0 &&
|
||||
highlightedIndex.value < periodStore.periods.length
|
||||
) {
|
||||
selectPeriod(periodStore.periods[highlightedIndex.value]);
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
closeDropdown();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for company changes - load periods and reset to latest
|
||||
watch(
|
||||
() => companyStore.selectedCompany,
|
||||
async (newCompany) => {
|
||||
if (newCompany) {
|
||||
await periodStore.loadPeriods(newCompany.id_firma);
|
||||
} else {
|
||||
periodStore.reset();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
return {
|
||||
periodStore,
|
||||
companyStore,
|
||||
dropdown,
|
||||
dropdownContainer,
|
||||
dropdownOpen,
|
||||
highlightedIndex,
|
||||
selectedPeriodDisplay,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
toggleDropdown,
|
||||
closeDropdown,
|
||||
selectPeriod,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.period-selector-mini {
|
||||
position: relative;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.period-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.period-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.period-trigger:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.period-trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.period-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.period-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.period-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pi-chevron-down {
|
||||
transition: transform var(--transition-fast);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.period-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.panel-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.period-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.period-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.period-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.period-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.period-item.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.period-item.keyboard-highlighted {
|
||||
background: var(--color-bg-secondary);
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.period-item.active.keyboard-highlighted {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.period-details {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.period-selected-icon {
|
||||
color: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.period-selector-mini {
|
||||
max-width: 140px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.period-trigger {
|
||||
min-width: auto;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.period-info {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.period-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.period-name {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.period-dropdown-panel {
|
||||
position: fixed;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: 60px;
|
||||
width: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
322
src/modules/reports/components/dashboard/TrendChart.vue
Normal file
322
src/modules/reports/components/dashboard/TrendChart.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<canvas
|
||||
ref="chartCanvas"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="chart-canvas"
|
||||
></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
||||
import {
|
||||
Chart,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
BarElement,
|
||||
LineController,
|
||||
BarController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
BarElement,
|
||||
LineController,
|
||||
BarController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
);
|
||||
|
||||
// Props definition
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
labels: [],
|
||||
datasets: [],
|
||||
}),
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: "line",
|
||||
validator: (value) => ["line", "bar", "area"].includes(value),
|
||||
},
|
||||
compare: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 400,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null);
|
||||
const chartInstance = ref(null);
|
||||
|
||||
// Romanian currency formatter
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
// Chart configuration
|
||||
const getChartConfig = () => {
|
||||
const chartType = props.type === "area" ? "line" : props.type;
|
||||
|
||||
const config = {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: props.data.labels || [],
|
||||
datasets: (props.data.datasets || []).map((dataset, index) => {
|
||||
const baseConfig = {
|
||||
...dataset,
|
||||
borderWidth: props.type === "line" || props.type === "area" ? 2 : 0,
|
||||
pointBackgroundColor: dataset.borderColor || dataset.backgroundColor,
|
||||
pointBorderColor: dataset.borderColor || dataset.backgroundColor,
|
||||
pointRadius: props.type === "line" || props.type === "area" ? 4 : 0,
|
||||
pointHoverRadius:
|
||||
props.type === "line" || props.type === "area" ? 6 : 0,
|
||||
};
|
||||
|
||||
// Area chart specific configuration
|
||||
if (props.type === "area") {
|
||||
baseConfig.fill = true;
|
||||
baseConfig.backgroundColor =
|
||||
dataset.backgroundColor ||
|
||||
(dataset.borderColor
|
||||
? dataset.borderColor
|
||||
.replace("rgb", "rgba")
|
||||
.replace(")", ", 0.1)")
|
||||
: "rgba(54, 162, 235, 0.1)");
|
||||
}
|
||||
|
||||
// Bar chart specific configuration
|
||||
if (props.type === "bar") {
|
||||
baseConfig.borderRadius = 4;
|
||||
baseConfig.borderSkipped = false;
|
||||
}
|
||||
|
||||
return baseConfig;
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: props.compare,
|
||||
position: "top",
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || "";
|
||||
if (label) {
|
||||
label += ": ";
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += formatCurrency(context.parsed.y);
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
titleColor: "#fff",
|
||||
bodyColor: "#fff",
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
color: "#6b7280",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.05)",
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
color: "#6b7280",
|
||||
callback: function (value) {
|
||||
return formatCurrency(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
hover: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
// Merge with custom options
|
||||
...props.options,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
// Create chart instance
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value) return;
|
||||
|
||||
const config = getChartConfig();
|
||||
|
||||
// Deep clone the entire config to break Vue reactivity circular references
|
||||
const clonedConfig = JSON.parse(JSON.stringify(config));
|
||||
|
||||
chartInstance.value = new Chart(chartCanvas.value, clonedConfig);
|
||||
};
|
||||
|
||||
// Destroy chart instance
|
||||
const destroyChart = () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.destroy();
|
||||
chartInstance.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Update chart data
|
||||
const updateChart = () => {
|
||||
if (!chartInstance.value) return;
|
||||
|
||||
const config = getChartConfig();
|
||||
|
||||
// Deep clone the data to break Vue reactivity circular references
|
||||
const clonedData = JSON.parse(JSON.stringify(config.data));
|
||||
|
||||
// Update data
|
||||
chartInstance.value.data = clonedData;
|
||||
|
||||
// Update options (clone options too to be safe)
|
||||
chartInstance.value.options = JSON.parse(JSON.stringify(config.options));
|
||||
|
||||
// Re-render
|
||||
chartInstance.value.update("none");
|
||||
};
|
||||
|
||||
// Recreate chart completely
|
||||
const recreateChart = async () => {
|
||||
destroyChart();
|
||||
await nextTick();
|
||||
createChart();
|
||||
};
|
||||
|
||||
// Watch for prop changes
|
||||
watch(
|
||||
() => [props.data, props.type, props.compare, props.options],
|
||||
async (newValues, oldValues) => {
|
||||
// Skip if chart is not initialized
|
||||
if (!chartInstance.value) return;
|
||||
|
||||
// If chart type changed, recreate completely
|
||||
if (newValues[1] !== oldValues[1]) {
|
||||
await recreateChart();
|
||||
} else {
|
||||
// Otherwise just update
|
||||
updateChart();
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
createChart();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyChart();
|
||||
});
|
||||
|
||||
// Expose methods for parent components
|
||||
defineExpose({
|
||||
updateChart,
|
||||
recreateChart,
|
||||
chartInstance: () => chartInstance.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.trend-chart {
|
||||
min-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.trend-chart {
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
769
src/modules/reports/components/dashboard/cards/CashFlowCard.vue
Normal file
769
src/modules/reports/components/dashboard/cards/CashFlowCard.vue
Normal file
@@ -0,0 +1,769 @@
|
||||
<template>
|
||||
<div class="cashflow-card">
|
||||
<!-- Card Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<h3 class="card-title">📅 Cash Flow Previzionat</h3>
|
||||
<div class="period-selector">
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
@change="handlePeriodChange"
|
||||
class="period-select"
|
||||
>
|
||||
<option value="7d">Următoarele 7 zile</option>
|
||||
<option value="1m">Următoarea lună</option>
|
||||
<option value="3m">Următoarele 3 luni</option>
|
||||
<option value="6m">Următoarele 6 luni</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Se încarcă previziunea cash flow...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<p>{{ error }}</p>
|
||||
<button @click="loadCashFlowData" class="retry-btn">
|
||||
Încearcă din nou
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Content -->
|
||||
<div v-else class="cashflow-content">
|
||||
<!-- Chart Container -->
|
||||
<div
|
||||
class="cashflow-bars"
|
||||
v-if="chartData && chartData.periods.length > 0"
|
||||
>
|
||||
<div class="chart-header">
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color inflow"></span>
|
||||
<span class="legend-label">Încasări</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color outflow"></span>
|
||||
<span class="legend-label">Plăți</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js Canvas -->
|
||||
<div class="chart-canvas-container">
|
||||
<canvas
|
||||
ref="cashflowChart"
|
||||
v-if="chartData?.periods?.length"
|
||||
width="400"
|
||||
height="200"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon">📊</div>
|
||||
<p>Nu există date de cash flow pentru perioada selectată</p>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Summary -->
|
||||
<div v-if="chartData" class="cashflow-summary">
|
||||
<div class="summary-row">
|
||||
<div
|
||||
class="summary-item net-flow"
|
||||
:class="getNetFlowClass(chartData.netTotal)"
|
||||
>
|
||||
<span class="summary-label">Net Total:</span>
|
||||
<span class="summary-value">{{
|
||||
formatCurrency(chartData.netTotal)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical Days Warnings -->
|
||||
<div
|
||||
v-if="chartData.criticalDays && chartData.criticalDays.length > 0"
|
||||
class="warnings"
|
||||
>
|
||||
<div class="warning-header">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
<span class="warning-title">Zile Critice</span>
|
||||
</div>
|
||||
<div class="critical-days">
|
||||
<span
|
||||
v-for="day in chartData.criticalDays"
|
||||
:key="day"
|
||||
class="critical-day"
|
||||
>
|
||||
{{ day }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import { useDashboardStore } from "@reports/stores/dashboard";
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables);
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["periodChanged"]);
|
||||
|
||||
// Store
|
||||
const dashboardStore = useDashboardStore();
|
||||
|
||||
// State
|
||||
const selectedPeriod = ref("7d");
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const chartData = ref(null);
|
||||
const cashflowChart = ref(null);
|
||||
const chartInstance = ref(null);
|
||||
|
||||
// Computed
|
||||
const maxValue = computed(() => {
|
||||
if (!chartData.value) return 1;
|
||||
|
||||
const allValues = [
|
||||
...chartData.value.inflows,
|
||||
...chartData.value.outflows.map(Math.abs),
|
||||
].filter((v) => v > 0);
|
||||
|
||||
return Math.max(...allValues, 1);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return "0,00 RON";
|
||||
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||
if (isNaN(numAmount)) return "0,00 RON";
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(numAmount);
|
||||
} catch (error) {
|
||||
return `${numAmount.toLocaleString("ro-RO", { minimumFractionDigits: 0, maximumFractionDigits: 0 })} RON`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrencyShort = (amount) => {
|
||||
if (!amount && amount !== 0) return "0";
|
||||
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||
if (isNaN(numAmount)) return "0";
|
||||
|
||||
const absAmount = Math.abs(numAmount);
|
||||
|
||||
if (absAmount >= 1000000) {
|
||||
return `${(numAmount / 1000000).toFixed(1)}M`;
|
||||
} else if (absAmount >= 1000) {
|
||||
return `${(numAmount / 1000).toFixed(0)}k`;
|
||||
}
|
||||
|
||||
return numAmount.toLocaleString("ro-RO", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const initializeChart = async () => {
|
||||
if (!cashflowChart.value || !chartData.value) return;
|
||||
|
||||
// Destroy existing chart instance
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.destroy();
|
||||
chartInstance.value = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const ctx = cashflowChart.value.getContext("2d");
|
||||
|
||||
chartInstance.value = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: chartData.value.periods,
|
||||
datasets: [
|
||||
{
|
||||
label: "Încasări",
|
||||
data: chartData.value.inflows,
|
||||
borderColor: "rgb(34, 197, 94)",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: "rgb(34, 197, 94)",
|
||||
pointBorderColor: "#ffffff",
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
},
|
||||
{
|
||||
label: "Plăți",
|
||||
data: chartData.value.outflows.map(Math.abs),
|
||||
borderColor: "rgb(239, 68, 68)",
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: "rgb(239, 68, 68)",
|
||||
pointBorderColor: "#ffffff",
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
},
|
||||
{
|
||||
label: "Net Flow",
|
||||
data: chartData.value.netFlow,
|
||||
borderColor: "rgb(99, 102, 241)",
|
||||
backgroundColor: "rgba(99, 102, 241, 0.1)",
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: "rgb(99, 102, 241)",
|
||||
pointBorderColor: "#ffffff",
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index",
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "top",
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
weight: "500",
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
titleColor: "#ffffff",
|
||||
bodyColor: "#ffffff",
|
||||
borderColor: "rgba(255, 255, 255, 0.2)",
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const label = context.dataset.label;
|
||||
const value = context.parsed.y;
|
||||
return `${label}: ${formatCurrency(value)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
color: "rgba(107, 114, 128, 0.8)",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: "rgba(107, 114, 128, 0.1)",
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
color: "rgba(107, 114, 128, 0.8)",
|
||||
callback: function (value) {
|
||||
return formatCurrencyShort(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderJoinStyle: "round",
|
||||
},
|
||||
point: {
|
||||
hoverBorderWidth: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getNetFlowClass = (amount) => {
|
||||
if (!amount && amount !== 0) return "neutral";
|
||||
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||
return numAmount > 0 ? "positive" : numAmount < 0 ? "negative" : "neutral";
|
||||
};
|
||||
|
||||
const handlePeriodChange = () => {
|
||||
emit("periodChanged", selectedPeriod.value);
|
||||
loadCashFlowData();
|
||||
};
|
||||
|
||||
const loadCashFlowData = async () => {
|
||||
if (!props.companyId) return;
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const result = await dashboardStore.loadCashFlowData(
|
||||
props.companyId,
|
||||
selectedPeriod.value,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
chartData.value = result.data;
|
||||
await nextTick();
|
||||
initializeChart();
|
||||
} else {
|
||||
error.value = result.error || "Nu s-au putut încărca datele";
|
||||
// Fallback to mock data for development
|
||||
chartData.value = generateMockData();
|
||||
await nextTick();
|
||||
initializeChart();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading cash flow data:", err);
|
||||
error.value = "Eroare la încărcarea datelor";
|
||||
// Fallback to mock data for development
|
||||
chartData.value = generateMockData();
|
||||
await nextTick();
|
||||
initializeChart();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockData = () => {
|
||||
const periods = {
|
||||
"7d": ["Luni", "Marți", "Miercuri", "Joi", "Vineri", "Sâmbătă", "Duminică"],
|
||||
"1m": ["S1", "S2", "S3", "S4"],
|
||||
"3m": ["Luna 1", "Luna 2", "Luna 3"],
|
||||
"6m": ["Trim 1", "Trim 2"],
|
||||
};
|
||||
|
||||
const periodLabels = periods[selectedPeriod.value] || periods["7d"];
|
||||
const inflows = periodLabels.map(() => Math.random() * 500000 + 100000);
|
||||
const outflows = periodLabels.map(() => -(Math.random() * 400000 + 50000));
|
||||
const netFlow = inflows.map((inflow, i) => inflow + outflows[i]);
|
||||
const cumulative = netFlow.reduce((acc, val, i) => {
|
||||
acc.push((acc[i - 1] || 0) + val);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const criticalDays = netFlow
|
||||
.map((net, i) => (net < -50000 ? periodLabels[i] : null))
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
periods: periodLabels,
|
||||
inflows,
|
||||
outflows,
|
||||
netFlow,
|
||||
cumulative,
|
||||
criticalDays,
|
||||
netTotal: netFlow.reduce((sum, val) => sum + val, 0),
|
||||
};
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.companyId,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
loadCashFlowData();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
chartData,
|
||||
(newData) => {
|
||||
if (newData) {
|
||||
nextTick(() => {
|
||||
initializeChart();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (props.companyId) {
|
||||
loadCashFlowData();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.destroy();
|
||||
chartInstance.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cashflow-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cashflow-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.card-header {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.period-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.period-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.error-icon,
|
||||
.empty-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Cash Flow Content */
|
||||
.cashflow-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Chart */
|
||||
.cashflow-bars {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.legend-color.inflow {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.legend-color.outflow {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Chart.js Container */
|
||||
.chart-canvas-container {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.chart-canvas-container canvas {
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Cash Flow Summary */
|
||||
.cashflow-summary {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.summary-item.positive {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.summary-item.negative {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.summary-item.neutral {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Warnings */
|
||||
.warnings {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.warning-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.critical-days {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.critical-day {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cashflow-card {
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.cashflow-bars,
|
||||
.cashflow-summary {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chart-legend {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.critical-days {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,659 @@
|
||||
<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();
|
||||
|
||||
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();
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,466 @@
|
||||
<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) }}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown section -->
|
||||
<div class="breakdown-section" v-if="breakdown">
|
||||
<!-- În termen -->
|
||||
<div class="breakdown-item">
|
||||
<span class="breakdown-label">În termen</span>
|
||||
<span class="breakdown-value">{{
|
||||
formatCurrency(breakdown.in_termen?.total || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Restant cu sub-perioade -->
|
||||
<div class="breakdown-group">
|
||||
<div class="breakdown-header" @click="toggleRestantExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<i
|
||||
class="pi pi-chevron-right breakdown-toggle"
|
||||
:class="{ expanded: isRestantExpanded }"
|
||||
></i>
|
||||
<span class="breakdown-label">Restant</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{
|
||||
formatCurrency(breakdown.restant?.total || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Perioade restante -->
|
||||
<div v-show="isRestantExpanded" class="breakdown-subitems slide-down">
|
||||
<div
|
||||
class="breakdown-subitem"
|
||||
v-for="(value, key) in breakdown.restant?.perioade"
|
||||
:key="key"
|
||||
>
|
||||
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
</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({
|
||||
total: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
sparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
previousSparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
previousSparklineLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
breakdown: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null);
|
||||
let chartInstance = null;
|
||||
const isRestantExpanded = ref(false);
|
||||
|
||||
// Toggle functions
|
||||
const toggleRestantExpanded = () => {
|
||||
isRestantExpanded.value = !isRestantExpanded.value;
|
||||
};
|
||||
|
||||
// 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));
|
||||
};
|
||||
|
||||
// Format period label
|
||||
const formatPeriodLabel = (key) => {
|
||||
const labelMap = {
|
||||
"7_zile": "7 zile",
|
||||
"14_zile": "14 zile",
|
||||
"30_zile": "30 zile",
|
||||
"60_zile": "60 zile",
|
||||
"90_zile": "90 zile",
|
||||
peste_90_zile: "Peste 90 zile",
|
||||
};
|
||||
return labelMap[key] || key;
|
||||
};
|
||||
|
||||
// Balance class
|
||||
const getBalanceClass = (amount) => {
|
||||
if (!amount && amount !== 0) return "neutral";
|
||||
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||
return numAmount > 0 ? "positive" : numAmount < 0 ? "negative" : "neutral";
|
||||
};
|
||||
|
||||
// Trend class
|
||||
const getTrendClass = (trend) => {
|
||||
if (!trend) return "";
|
||||
return {
|
||||
"trend-up": trend.direction === "up",
|
||||
"trend-down": trend.direction === "down",
|
||||
"trend-neutral": trend.direction === "neutral",
|
||||
};
|
||||
};
|
||||
|
||||
// Trend icon
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return "";
|
||||
switch (trend.direction) {
|
||||
case "up":
|
||||
return "▲";
|
||||
case "down":
|
||||
return "▼";
|
||||
case "neutral":
|
||||
return "▶";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.sparklineData && props.sparklineData.length > 0;
|
||||
});
|
||||
|
||||
// Initialize chart
|
||||
const initializeChart = async () => {
|
||||
if (!chartCanvas.value || !hasSparklineData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const ctx = chartCanvas.value.getContext("2d");
|
||||
|
||||
// Generate labels
|
||||
const labels =
|
||||
props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.sparklineData.map((_, i) => `L${i + 1}`);
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.sparklineData];
|
||||
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
allDataPoints.push(...props.previousSparklineData);
|
||||
}
|
||||
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;
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [
|
||||
{
|
||||
label: "Clienți (curent)",
|
||||
data: props.sparklineData,
|
||||
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.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
datasets.push({
|
||||
label: "Clienți (anul precedent)",
|
||||
data: props.previousSparklineData,
|
||||
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.6)",
|
||||
pointHoverBorderColor: "#ffffff",
|
||||
pointHoverBorderWidth: 2,
|
||||
});
|
||||
}
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
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.8)",
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for data changes
|
||||
watch(
|
||||
() => [
|
||||
props.sparklineData,
|
||||
props.previousSparklineData,
|
||||
props.sparklineLabels,
|
||||
props.previousSparklineLabels,
|
||||
],
|
||||
async () => {
|
||||
await initializeChart();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await initializeChart();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific: ClientiBalanceCard layout and breakdown */
|
||||
|
||||
/* Override min-height for balance card */
|
||||
.clienti-balance-card {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* Value section: horizontal layout */
|
||||
.value-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Color classes for positive/negative/neutral (component-specific logic) */
|
||||
.positive {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sparkline chart dimensions */
|
||||
.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 */
|
||||
@media (max-width: 768px) {
|
||||
.clienti-balance-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sparkline-chart {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
<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) }}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown section -->
|
||||
<div class="breakdown-section" v-if="breakdown">
|
||||
<!-- În termen -->
|
||||
<div class="breakdown-item">
|
||||
<span class="breakdown-label">În termen</span>
|
||||
<span class="breakdown-value">{{
|
||||
formatCurrency(breakdown.in_termen?.total || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Restant cu sub-perioade -->
|
||||
<div class="breakdown-group">
|
||||
<div class="breakdown-header" @click="toggleRestantExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<i
|
||||
class="pi pi-chevron-right breakdown-toggle"
|
||||
:class="{ expanded: isRestantExpanded }"
|
||||
></i>
|
||||
<span class="breakdown-label">Restant</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{
|
||||
formatCurrency(breakdown.restant?.total || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Perioade restante -->
|
||||
<div v-show="isRestantExpanded" class="breakdown-subitems slide-down">
|
||||
<div
|
||||
class="breakdown-subitem"
|
||||
v-for="(value, key) in breakdown.restant?.perioade"
|
||||
:key="key"
|
||||
>
|
||||
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
</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({
|
||||
total: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
sparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
previousSparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
previousSparklineLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
breakdown: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null);
|
||||
let chartInstance = null;
|
||||
const isRestantExpanded = ref(false);
|
||||
|
||||
// Toggle functions
|
||||
const toggleRestantExpanded = () => {
|
||||
isRestantExpanded.value = !isRestantExpanded.value;
|
||||
};
|
||||
|
||||
// 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));
|
||||
};
|
||||
|
||||
// Format period label
|
||||
const formatPeriodLabel = (key) => {
|
||||
const labelMap = {
|
||||
"7_zile": "7 zile",
|
||||
"14_zile": "14 zile",
|
||||
"30_zile": "30 zile",
|
||||
"60_zile": "60 zile",
|
||||
"90_zile": "90 zile",
|
||||
peste_90_zile: "Peste 90 zile",
|
||||
};
|
||||
return labelMap[key] || key;
|
||||
};
|
||||
|
||||
// Balance class
|
||||
const getBalanceClass = (amount) => {
|
||||
if (!amount && amount !== 0) return "neutral";
|
||||
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||
return numAmount > 0 ? "positive" : numAmount < 0 ? "negative" : "neutral";
|
||||
};
|
||||
|
||||
// Trend class
|
||||
const getTrendClass = (trend) => {
|
||||
if (!trend) return "";
|
||||
return {
|
||||
"trend-up": trend.direction === "up",
|
||||
"trend-down": trend.direction === "down",
|
||||
"trend-neutral": trend.direction === "neutral",
|
||||
};
|
||||
};
|
||||
|
||||
// Trend icon
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return "";
|
||||
switch (trend.direction) {
|
||||
case "up":
|
||||
return "▲";
|
||||
case "down":
|
||||
return "▼";
|
||||
case "neutral":
|
||||
return "▶";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.sparklineData && props.sparklineData.length > 0;
|
||||
});
|
||||
|
||||
// Initialize chart
|
||||
const initializeChart = async () => {
|
||||
if (!chartCanvas.value || !hasSparklineData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const ctx = chartCanvas.value.getContext("2d");
|
||||
|
||||
// Generate labels
|
||||
const labels =
|
||||
props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.sparklineData.map((_, i) => `L${i + 1}`);
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.sparklineData];
|
||||
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
allDataPoints.push(...props.previousSparklineData);
|
||||
}
|
||||
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;
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [
|
||||
{
|
||||
label: "Furnizori (curent)",
|
||||
data: props.sparklineData,
|
||||
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.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
datasets.push({
|
||||
label: "Furnizori (anul precedent)",
|
||||
data: props.previousSparklineData,
|
||||
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.6)",
|
||||
pointHoverBorderColor: "#ffffff",
|
||||
pointHoverBorderWidth: 2,
|
||||
});
|
||||
}
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
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.8)",
|
||||
usePointStyle: true,
|
||||
},
|
||||
},
|
||||
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.sparklineData,
|
||||
props.previousSparklineData,
|
||||
props.sparklineLabels,
|
||||
props.previousSparklineLabels,
|
||||
],
|
||||
async () => {
|
||||
await initializeChart();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await initializeChart();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific: FurnizoriBalanceCard layout and breakdown */
|
||||
|
||||
/* Override min-height for balance card */
|
||||
.furnizori-balance-card {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* Value section: horizontal layout */
|
||||
.value-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Color classes for positive/negative/neutral (component-specific logic) */
|
||||
.positive {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Sparkline chart dimensions */
|
||||
.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 */
|
||||
@media (max-width: 768px) {
|
||||
.furnizori-balance-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sparkline-chart {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,813 @@
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
517
src/modules/reports/components/dashboard/cards/MetricCard.vue
Normal file
517
src/modules/reports/components/dashboard/cards/MetricCard.vue
Normal file
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<div class="metric-card">
|
||||
<!-- Header with icon and title -->
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon bg-primary-light text-primary">{{ icon }}</span>
|
||||
<span class="metric-label">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main value display -->
|
||||
<div class="metric-value" :class="valueClass">
|
||||
{{ formatCurrency(value) }}
|
||||
</div>
|
||||
|
||||
<!-- Trend indicator -->
|
||||
<div class="trend-indicator" :class="trendClass" v-if="trend">
|
||||
<span class="trend-icon">{{ trendIcon }}</span>
|
||||
<span class="trend-value"
|
||||
>{{ Math.round(Math.abs(trend.value), 2) }}%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline mini-chart - STACKED BELOW (Best Practice) -->
|
||||
<div
|
||||
class="sparkline-container"
|
||||
v-if="sparklineData && sparklineData.length > 0"
|
||||
>
|
||||
<canvas ref="sparklineCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown display section - Suport ierarhic -->
|
||||
<div class="metric-breakdown" v-if="breakdown">
|
||||
<div
|
||||
v-for="(value, key) in breakdown"
|
||||
:key="key"
|
||||
class="breakdown-section"
|
||||
>
|
||||
<!-- Valoare simplă (backward compatible) -->
|
||||
<div v-if="!isHierarchical(value)" class="breakdown-item">
|
||||
<span class="breakdown-label">{{ formatBreakdownLabel(key) }}:</span>
|
||||
<span class="breakdown-value">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Valoare ierarhică (cu sub-items) -->
|
||||
<div v-else class="breakdown-group">
|
||||
<div class="breakdown-header" @click="() => toggleExpanded(key)">
|
||||
<div class="breakdown-header-left">
|
||||
<i
|
||||
class="pi pi-chevron-right breakdown-toggle"
|
||||
:class="{ expanded: isItemExpanded(key) }"
|
||||
></i>
|
||||
<span class="breakdown-label"
|
||||
>{{ formatBreakdownLabel(key) }}:</span
|
||||
>
|
||||
</div>
|
||||
<span class="breakdown-value">{{
|
||||
formatCurrency(value.total)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Sub-items (collapsible) -->
|
||||
<div
|
||||
v-if="value.items && value.items.length > 0"
|
||||
v-show="isItemExpanded(key)"
|
||||
class="breakdown-subitems slide-down"
|
||||
>
|
||||
<div
|
||||
v-for="(item, idx) in value.items"
|
||||
:key="idx"
|
||||
class="breakdown-subitem"
|
||||
>
|
||||
<span class="breakdown-sublabel">
|
||||
{{ item.nume }}
|
||||
<span v-if="item.cont" class="breakdown-cont"
|
||||
>({{ item.cont }})</span
|
||||
>
|
||||
</span>
|
||||
<span class="breakdown-subvalue">{{
|
||||
formatCurrency(item.sold)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
watch,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables);
|
||||
|
||||
// Props definition with validation
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => value.length > 0,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => value.length > 0,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null,
|
||||
validator: (value) => {
|
||||
if (value === null) return true;
|
||||
return (
|
||||
typeof value.value === "number" &&
|
||||
["up", "down", "neutral"].includes(value.direction)
|
||||
);
|
||||
},
|
||||
},
|
||||
sparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: (value) => {
|
||||
return value.every((item) => typeof item === "number");
|
||||
},
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
breakdown: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Refs
|
||||
const sparklineCanvas = ref(null);
|
||||
let chartInstance = null;
|
||||
const expandedStates = ref({});
|
||||
|
||||
// Toggle breakdown expansion for a specific key
|
||||
const toggleExpanded = (key) => {
|
||||
expandedStates.value[key] = !expandedStates.value[key];
|
||||
};
|
||||
|
||||
// Check if a specific breakdown item is expanded
|
||||
const isItemExpanded = (key) => {
|
||||
return !!expandedStates.value[key];
|
||||
};
|
||||
|
||||
// Format currency value
|
||||
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))
|
||||
.replace("RON", "RON");
|
||||
};
|
||||
|
||||
// Format breakdown label
|
||||
const formatBreakdownLabel = (key) => {
|
||||
const labelMap = {
|
||||
casa: "Casă",
|
||||
banca: "Bancă",
|
||||
clienti: "Clienți",
|
||||
furnizori: "Furnizori",
|
||||
clienti_in_termen: "Clienți în termen",
|
||||
clienti_restanti: "Clienți restanți",
|
||||
furnizori_termen: "Furnizori în termen",
|
||||
furnizori_scadent: "Furnizori scadenți",
|
||||
numerar: "Numerar",
|
||||
cont: "Cont",
|
||||
depozit: "Depozit",
|
||||
credit: "Credit",
|
||||
debit: "Debit",
|
||||
sold: "Sold",
|
||||
total: "Total",
|
||||
};
|
||||
|
||||
return (
|
||||
labelMap[key.toLowerCase()] || key.charAt(0).toUpperCase() + key.slice(1)
|
||||
);
|
||||
};
|
||||
|
||||
// Check if value is hierarchical (has total and items)
|
||||
const isHierarchical = (value) => {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"total" in value &&
|
||||
"items" in value
|
||||
);
|
||||
};
|
||||
|
||||
// Computed properties for styling
|
||||
const iconClass = computed(() => {
|
||||
return `icon-${props.title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
});
|
||||
|
||||
const valueClass = computed(() => {
|
||||
if (!props.value && props.value !== 0) return "";
|
||||
return props.value < 0 ? "negative" : "positive";
|
||||
});
|
||||
|
||||
const trendClass = computed(() => {
|
||||
if (!props.trend) return "";
|
||||
|
||||
return {
|
||||
"trend-up": props.trend.direction === "up",
|
||||
"trend-down": props.trend.direction === "down",
|
||||
"trend-neutral": props.trend.direction === "neutral",
|
||||
};
|
||||
});
|
||||
|
||||
const trendIcon = computed(() => {
|
||||
if (!props.trend) return "";
|
||||
|
||||
switch (props.trend.direction) {
|
||||
case "up":
|
||||
return "▲";
|
||||
case "down":
|
||||
return "▼";
|
||||
case "neutral":
|
||||
return "▶";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
// Sparkline color based on trend
|
||||
const sparklineColor = computed(() => {
|
||||
if (!props.trend) {
|
||||
return "#3b82f6"; // Primary blue
|
||||
}
|
||||
|
||||
switch (props.trend.direction) {
|
||||
case "up":
|
||||
return "#10b981"; // Success green
|
||||
case "down":
|
||||
return "#ef4444"; // Danger red
|
||||
default:
|
||||
return "#3b82f6"; // Primary blue
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Chart.js sparkline
|
||||
const initializeSparkline = async () => {
|
||||
if (
|
||||
!sparklineCanvas.value ||
|
||||
!props.sparklineData ||
|
||||
props.sparklineData.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart instance
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const ctx = sparklineCanvas.value.getContext("2d");
|
||||
const color = sparklineColor.value;
|
||||
|
||||
// Generate labels: use provided labels or generate generic ones
|
||||
const labels =
|
||||
props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.sparklineData.map((_, i) => `L${i + 1}`);
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.sparklineData,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}20`, // 20 = 12.5% opacity in hex
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0, // Hide points by default
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: color,
|
||||
pointHoverBorderColor: "#ffffff",
|
||||
pointHoverBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
titleColor: "#ffffff",
|
||||
bodyColor: "#ffffff",
|
||||
borderColor: "rgba(255, 255, 255, 0.2)",
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
// Show period label in tooltip
|
||||
return context[0].label || "";
|
||||
},
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "rgba(107, 114, 128, 0.7)",
|
||||
font: {
|
||||
size: 9,
|
||||
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: "rgba(107, 114, 128, 0.1)",
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "rgba(107, 114, 128, 0.7)",
|
||||
font: {
|
||||
size: 9,
|
||||
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
},
|
||||
maxTicksLimit: 4,
|
||||
callback: function (value) {
|
||||
// Format as compact currency
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderCapStyle: "round",
|
||||
borderJoinStyle: "round",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for data changes
|
||||
watch(
|
||||
() => props.sparklineData,
|
||||
async () => {
|
||||
await initializeSparkline();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.sparklineLabels,
|
||||
async () => {
|
||||
await initializeSparkline();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.trend,
|
||||
async () => {
|
||||
await initializeSparkline();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await initializeSparkline();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles only - Base patterns now in global CSS */
|
||||
|
||||
/* Trend indicator styles - Component specific */
|
||||
.trend-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.trend-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
/* Value styling - Component specific colors */
|
||||
.metric-value.positive {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.metric-value.negative {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Breakdown section - Component specific layout */
|
||||
.metric-breakdown {
|
||||
margin-top: var(--space-lg);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.breakdown-section {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.breakdown-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.breakdown-group {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Breakdown item - Component specific styles */
|
||||
.breakdown-item {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.breakdown-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Breakdown cont label - Component specific */
|
||||
.breakdown-cont {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.7;
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,980 @@
|
||||
<template>
|
||||
<div class="performance-card card">
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">
|
||||
<span class="card-icon">📊</span>
|
||||
<h3 class="card-title">Performanță & Cash Flow</h3>
|
||||
</div>
|
||||
<div class="period-selector">
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
@change="handlePeriodChange"
|
||||
class="period-select"
|
||||
: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="ytd">YTD</option>
|
||||
<option value="12m">12 luni</option>
|
||||
</select>
|
||||
<i class="pi pi-chevron-down select-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="loading-text">Se încarcă datele...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<i class="pi pi-exclamation-triangle error-icon"></i>
|
||||
<span class="error-text">{{ error }}</span>
|
||||
<button @click="retryLoad" class="retry-button">
|
||||
<i class="pi pi-refresh"></i>
|
||||
Reîncarcă
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<!-- Chart Container -->
|
||||
<div class="chart-container">
|
||||
<div class="chart-placeholder" v-if="!chartData?.labels?.length">
|
||||
<div class="placeholder-content">
|
||||
<i class="pi pi-chart-line placeholder-icon"></i>
|
||||
<span class="placeholder-text">Grafic încasări vs plăți</span>
|
||||
<small class="placeholder-subtitle"
|
||||
>Datele vor fi afișate aici</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chart-content">
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color income"></span>
|
||||
<span class="legend-label">Încasări</span>
|
||||
<span class="legend-value">{{
|
||||
formatCurrency(totalIncome)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color expenses"></span>
|
||||
<span class="legend-label">Plăți</span>
|
||||
<span class="legend-value">{{
|
||||
formatCurrency(totalExpenses)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-canvas-container">
|
||||
<canvas
|
||||
ref="performanceChart"
|
||||
v-if="chartData?.labels?.length"
|
||||
width="400"
|
||||
height="200"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Indicators -->
|
||||
<div class="indicators-section">
|
||||
<div class="indicators-grid">
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">💰</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Rata încasare</div>
|
||||
<div
|
||||
class="indicator-value"
|
||||
:class="getRateClass(performanceData.rataIncasare)"
|
||||
>
|
||||
{{ performanceData.rataIncasare || 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">⏱️</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Cash conversion</div>
|
||||
<div class="indicator-value">
|
||||
{{ performanceData.cashConversion || 0 }} zile
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">📈</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Trend</div>
|
||||
<div
|
||||
class="indicator-value"
|
||||
:class="getTrendClass(performanceData.trend)"
|
||||
>
|
||||
<i :class="getTrendIcon(performanceData.trend)"></i>
|
||||
{{ getTrendText(performanceData.trend) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">💼</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Capital lucru</div>
|
||||
<div
|
||||
class="indicator-value"
|
||||
:class="
|
||||
getWorkingCapitalClass(performanceData.workingCapital)
|
||||
"
|
||||
>
|
||||
{{ formatCurrency(performanceData.workingCapital || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
watch,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import { useDashboardStore } from "@reports/stores/dashboard";
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables);
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["periodChanged"]);
|
||||
|
||||
// State
|
||||
const selectedPeriod = ref("7d");
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const performanceChart = ref(null);
|
||||
let chartInstance = null;
|
||||
|
||||
// Store
|
||||
const dashboardStore = useDashboardStore();
|
||||
|
||||
// Sample data (will be replaced with actual API data)
|
||||
const performanceData = ref({
|
||||
rataIncasare: 85.2,
|
||||
cashConversion: 45,
|
||||
trend: "up",
|
||||
workingCapital: 125000,
|
||||
});
|
||||
|
||||
const chartData = ref({
|
||||
labels: ["Lun", "Mar", "Mie", "Joi", "Vin", "Sâm", "Dum"],
|
||||
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
|
||||
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000],
|
||||
});
|
||||
|
||||
// Computed
|
||||
const totalIncome = computed(() => {
|
||||
return chartData.value.income?.reduce((sum, val) => sum + val, 0) || 0;
|
||||
});
|
||||
|
||||
const totalExpenses = computed(() => {
|
||||
return chartData.value.expenses?.reduce((sum, val) => sum + val, 0) || 0;
|
||||
});
|
||||
|
||||
const maxValue = computed(() => {
|
||||
const allValues = [
|
||||
...(chartData.value.income || []),
|
||||
...(chartData.value.expenses || []),
|
||||
];
|
||||
return Math.max(...allValues, 0);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handlePeriodChange = () => {
|
||||
emit("periodChanged", selectedPeriod.value);
|
||||
loadPerformanceData();
|
||||
};
|
||||
|
||||
const loadPerformanceData = async () => {
|
||||
if (!props.companyId) return;
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// This will be replaced with actual API call
|
||||
// const result = await dashboardStore.loadPerformanceData(props.companyId, selectedPeriod.value)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Mock data based on period
|
||||
const mockData = {
|
||||
"7d": {
|
||||
rataIncasare: 85.2,
|
||||
cashConversion: 45,
|
||||
trend: "up",
|
||||
workingCapital: 125000,
|
||||
chartData: {
|
||||
labels: ["Lun", "Mar", "Mie", "Joi", "Vin", "Sâm", "Dum"],
|
||||
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
|
||||
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000],
|
||||
},
|
||||
},
|
||||
"1m": {
|
||||
rataIncasare: 78.5,
|
||||
cashConversion: 52,
|
||||
trend: "stable",
|
||||
workingCapital: 89000,
|
||||
chartData: {
|
||||
labels: ["S1", "S2", "S3", "S4"],
|
||||
income: [45000, 52000, 38000, 48000],
|
||||
expenses: [42000, 47000, 51000, 45000],
|
||||
},
|
||||
},
|
||||
"3m": {
|
||||
rataIncasare: 82.1,
|
||||
cashConversion: 38,
|
||||
trend: "up",
|
||||
workingCapital: 156000,
|
||||
chartData: {
|
||||
labels: ["Ian", "Feb", "Mar"],
|
||||
income: [165000, 182000, 155000],
|
||||
expenses: [158000, 162000, 168000],
|
||||
},
|
||||
},
|
||||
"6m": {
|
||||
rataIncasare: 79.8,
|
||||
cashConversion: 41,
|
||||
trend: "down",
|
||||
workingCapital: 98000,
|
||||
chartData: {
|
||||
labels: ["Oct", "Noi", "Dec", "Ian", "Feb", "Mar"],
|
||||
income: [145000, 162000, 185000, 165000, 182000, 155000],
|
||||
expenses: [152000, 158000, 172000, 158000, 162000, 168000],
|
||||
},
|
||||
},
|
||||
ytd: {
|
||||
rataIncasare: 81.3,
|
||||
cashConversion: 43,
|
||||
trend: "stable",
|
||||
workingCapital: 142000,
|
||||
chartData: {
|
||||
labels: ["Q1", "Q2", "Q3"],
|
||||
income: [502000, 485000, 456000],
|
||||
expenses: [488000, 512000, 478000],
|
||||
},
|
||||
},
|
||||
"12m": {
|
||||
rataIncasare: 83.7,
|
||||
cashConversion: 39,
|
||||
trend: "up",
|
||||
workingCapital: 178000,
|
||||
chartData: {
|
||||
labels: ["T1", "T2", "T3", "T4"],
|
||||
income: [1456000, 1523000, 1387000, 1612000],
|
||||
expenses: [1423000, 1498000, 1456000, 1534000],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = mockData[selectedPeriod.value] || mockData["7d"];
|
||||
performanceData.value = data;
|
||||
chartData.value = data.chartData;
|
||||
|
||||
// Initialize or update chart after data is loaded
|
||||
await nextTick();
|
||||
await updateChart();
|
||||
} catch (err) {
|
||||
console.error("Failed to load performance data:", err);
|
||||
error.value = "Nu s-au putut încărca datele de performanță";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const retryLoad = () => {
|
||||
loadPerformanceData();
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
if (value === null || value === undefined) return "0 RON";
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const initializeChart = async () => {
|
||||
if (!performanceChart.value || !chartData.value?.labels?.length) return;
|
||||
|
||||
// Destroy existing chart instance
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const ctx = performanceChart.value.getContext("2d");
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: chartData.value.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Încasări",
|
||||
data: chartData.value.income,
|
||||
borderColor: "rgba(16, 185, 129, 1)", // var(--color-success)
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: "rgba(16, 185, 129, 1)",
|
||||
pointBorderColor: "#ffffff",
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
{
|
||||
label: "Plăți",
|
||||
data: chartData.value.expenses,
|
||||
borderColor: "rgba(239, 68, 68, 1)", // var(--color-error)
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: "rgba(239, 68, 68, 1)",
|
||||
pointBorderColor: "#ffffff",
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false, // We have our own custom legend
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
titleColor: "#ffffff",
|
||||
bodyColor: "#ffffff",
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const value = context.parsed.y;
|
||||
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
return `${context.dataset.label}: ${formattedValue}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "rgba(107, 114, 128, 0.8)",
|
||||
font: {
|
||||
size: 12,
|
||||
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(107, 114, 128, 0.1)",
|
||||
drawBorder: false,
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "rgba(107, 114, 128, 0.8)",
|
||||
font: {
|
||||
size: 12,
|
||||
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
},
|
||||
callback: function (value) {
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
}).format(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderCapStyle: "round",
|
||||
borderJoinStyle: "round",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateChart = async () => {
|
||||
if (chartInstance && chartData.value?.labels?.length) {
|
||||
chartInstance.data.labels = chartData.value.labels;
|
||||
chartInstance.data.datasets[0].data = chartData.value.income;
|
||||
chartInstance.data.datasets[1].data = chartData.value.expenses;
|
||||
chartInstance.update("active");
|
||||
} else {
|
||||
await initializeChart();
|
||||
}
|
||||
};
|
||||
|
||||
const getRateClass = (rate) => {
|
||||
if (rate >= 85) return "rate-excellent";
|
||||
if (rate >= 75) return "rate-good";
|
||||
if (rate >= 60) return "rate-average";
|
||||
return "rate-poor";
|
||||
};
|
||||
|
||||
const getTrendClass = (trend) => {
|
||||
switch (trend) {
|
||||
case "up":
|
||||
return "trend-up";
|
||||
case "down":
|
||||
return "trend-down";
|
||||
default:
|
||||
return "trend-stable";
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend) => {
|
||||
switch (trend) {
|
||||
case "up":
|
||||
return "pi pi-arrow-up";
|
||||
case "down":
|
||||
return "pi pi-arrow-down";
|
||||
default:
|
||||
return "pi pi-minus";
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendText = (trend) => {
|
||||
switch (trend) {
|
||||
case "up":
|
||||
return "Crescător";
|
||||
case "down":
|
||||
return "Descrescător";
|
||||
default:
|
||||
return "Stabil";
|
||||
}
|
||||
};
|
||||
|
||||
const getWorkingCapitalClass = (value) => {
|
||||
if (value > 100000) return "capital-positive";
|
||||
if (value > 0) return "capital-neutral";
|
||||
return "capital-negative";
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.companyId,
|
||||
(newId) => {
|
||||
if (newId) {
|
||||
loadPerformanceData();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
chartData,
|
||||
async () => {
|
||||
if (chartData.value?.labels?.length) {
|
||||
await nextTick();
|
||||
await updateChart();
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (props.companyId) {
|
||||
await loadPerformanceData();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Performance Card Styles */
|
||||
.performance-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.performance-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
padding: var(--space-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Period Selector */
|
||||
.period-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
appearance: none;
|
||||
padding: var(--space-sm) var(--space-xl) var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.period-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.period-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.period-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
position: absolute;
|
||||
right: var(--space-sm);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.card-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
gap: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Chart Container */
|
||||
.chart-container {
|
||||
margin-bottom: var(--space-xl);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.placeholder-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Chart Content */
|
||||
.chart-content {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.legend-color.income {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.legend-color.expenses {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
padding: var(--space-lg);
|
||||
height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-canvas-container canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Indicators */
|
||||
.indicators-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.indicators-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.indicator-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.indicator-card:hover {
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.indicator-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.indicator-value {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Indicator Value Colors */
|
||||
.rate-excellent {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.rate-good {
|
||||
color: #10b981;
|
||||
}
|
||||
.rate-average {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.rate-poor {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.trend-down {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.trend-stable {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.capital-positive {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.capital-neutral {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.capital-negative {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.indicators-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.indicator-card {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 160px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-header,
|
||||
.card-body {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.indicator-value {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,722 @@
|
||||
<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) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Bancă Section -->
|
||||
<div class="value-block banca">
|
||||
<div class="metric-label">Bancă</div>
|
||||
<div class="metric-value text-primary">
|
||||
{{ formatCurrency(bancaTotal) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical -->
|
||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||
<!-- Grafic Casa -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-title text-success">Casa</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="casaCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grafic Bancă -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-title text-primary">Bancă</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="bancaCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</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>
|
||||
</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({
|
||||
casaTotal: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
bancaTotal: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
casaItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
bancaItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
casaSparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
bancaSparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
casaPreviousSparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
bancaPreviousSparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
previousSparklineLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Refs pentru 2 canvas-uri separate
|
||||
const casaCanvas = ref(null);
|
||||
const bancaCanvas = ref(null);
|
||||
let casaChartInstance = null;
|
||||
let bancaChartInstance = null;
|
||||
const isCasaExpanded = ref(false);
|
||||
const isBancaExpanded = ref(false);
|
||||
|
||||
// Toggle functions
|
||||
const toggleCasaExpanded = () => {
|
||||
isCasaExpanded.value = !isCasaExpanded.value;
|
||||
};
|
||||
|
||||
const toggleBancaExpanded = () => {
|
||||
isBancaExpanded.value = !isBancaExpanded.value;
|
||||
};
|
||||
|
||||
// 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.casaSparklineData.length > 0 && props.bancaSparklineData.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
// Initialize Casa chart
|
||||
const initializeCasaChart = async () => {
|
||||
if (!casaCanvas.value || props.casaSparklineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (casaChartInstance) {
|
||||
casaChartInstance.destroy();
|
||||
casaChartInstance = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const ctx = casaCanvas.value.getContext("2d");
|
||||
|
||||
// Generate labels
|
||||
const labels =
|
||||
props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.casaSparklineData.map((_, i) => `L${i + 1}`);
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [
|
||||
{
|
||||
label: "Casa (curent)",
|
||||
data: props.casaSparklineData,
|
||||
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.casaPreviousSparklineData &&
|
||||
props.casaPreviousSparklineData.length > 0
|
||||
) {
|
||||
datasets.push({
|
||||
label: "Casa (anul precedent)",
|
||||
data: props.casaPreviousSparklineData,
|
||||
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.casaSparklineData];
|
||||
if (
|
||||
props.casaPreviousSparklineData &&
|
||||
props.casaPreviousSparklineData.length > 0
|
||||
) {
|
||||
allDataPoints.push(...props.casaPreviousSparklineData);
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints);
|
||||
const dataMax = Math.max(...allDataPoints);
|
||||
const dataRange = dataMax - dataMin;
|
||||
const dataPadding = dataRange * 0.05;
|
||||
|
||||
casaChartInstance = 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: dataMin - dataPadding,
|
||||
max: dataMax + dataPadding,
|
||||
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 Bancă chart
|
||||
const initializeBancaChart = async () => {
|
||||
if (!bancaCanvas.value || props.bancaSparklineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (bancaChartInstance) {
|
||||
bancaChartInstance.destroy();
|
||||
bancaChartInstance = null;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const ctx = bancaCanvas.value.getContext("2d");
|
||||
|
||||
// Generate labels
|
||||
const labels =
|
||||
props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.bancaSparklineData.map((_, i) => `L${i + 1}`);
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [
|
||||
{
|
||||
label: "Bancă (curent)",
|
||||
data: props.bancaSparklineData,
|
||||
borderColor: "#3b82f6",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: "#3b82f6",
|
||||
pointHoverBorderColor: "#ffffff",
|
||||
pointHoverBorderWidth: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// Add previous year dataset if available
|
||||
if (
|
||||
props.bancaPreviousSparklineData &&
|
||||
props.bancaPreviousSparklineData.length > 0
|
||||
) {
|
||||
datasets.push({
|
||||
label: "Bancă (anul precedent)",
|
||||
data: props.bancaPreviousSparklineData,
|
||||
borderColor: "rgba(59, 130, 246, 0.4)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.05)",
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: "rgba(59, 130, 246, 0.4)",
|
||||
pointHoverBorderColor: "#ffffff",
|
||||
pointHoverBorderWidth: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.bancaSparklineData];
|
||||
if (
|
||||
props.bancaPreviousSparklineData &&
|
||||
props.bancaPreviousSparklineData.length > 0
|
||||
) {
|
||||
allDataPoints.push(...props.bancaPreviousSparklineData);
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints);
|
||||
const dataMax = Math.max(...allDataPoints);
|
||||
const dataRange = dataMax - dataMin;
|
||||
const dataPadding = dataRange * 0.05;
|
||||
|
||||
bancaChartInstance = 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: dataMin - dataPadding,
|
||||
max: dataMax + dataPadding,
|
||||
grid: {
|
||||
color: "rgba(107, 114, 128, 0.1)",
|
||||
drawBorder: false,
|
||||
},
|
||||
ticks: {
|
||||
color: "#3b82f6",
|
||||
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.casaSparklineData,
|
||||
props.bancaSparklineData,
|
||||
props.sparklineLabels,
|
||||
props.casaPreviousSparklineData,
|
||||
props.bancaPreviousSparklineData,
|
||||
props.previousSparklineLabels,
|
||||
],
|
||||
async () => {
|
||||
await Promise.all([initializeCasaChart(), initializeBancaChart()]);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await Promise.all([initializeCasaChart(), initializeBancaChart()]);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (casaChartInstance) {
|
||||
casaChartInstance.destroy();
|
||||
casaChartInstance = null;
|
||||
}
|
||||
if (bancaChartInstance) {
|
||||
bancaChartInstance.destroy();
|
||||
bancaChartInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific: Dual-layout for TreasuryDualCard (Casa | Bancă) */
|
||||
|
||||
/* Override min-height for dual chart layout */
|
||||
.treasury-dual-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
/* Split layout: Casa | Divider | Bancă */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Component-specific: Account number display in breakdown */
|
||||
.breakdown-cont {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Responsive: Stack vertically on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.treasury-dual-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) {
|
||||
.treasury-dual-card {
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.sparkline-wrapper {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.values-section {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user