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

Modern ERP Reports Application with microservices architecture

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

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

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

View File

@@ -0,0 +1,491 @@
<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: none;
width: 100%;
}
.company-trigger {
min-width: auto;
}
.company-dropdown-panel {
left: -16px;
right: -16px;
width: calc(100% + 32px);
}
}
</style>

View File

@@ -0,0 +1,658 @@
<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>

View File

@@ -0,0 +1,316 @@
<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>

View File

@@ -0,0 +1,742 @@
<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 '../../../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>

View File

@@ -0,0 +1,715 @@
<template>
<div class="cashflow-card">
<!-- Main values section - Split layout (Încasări | Plăți) -->
<div class="values-section">
<!-- Încasări Section -->
<div class="value-block inflows">
<div class="value-label">Încasări</div>
<div class="value-amount positive">
{{ formatCurrency(inflowsValue) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Plăți Section -->
<div class="value-block outflows">
<div class="value-label">Plăți</div>
<div class="value-amount negative">
{{ formatCurrency(outflowsValue) }}
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Încasări -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Încasări</div>
<div class="sparkline-chart">
<canvas ref="inflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Plăți -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Plăți</div>
<div class="sparkline-chart">
<canvas ref="outflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps({
inflowsValue: {
type: Number,
default: 0
},
outflowsValue: {
type: Number,
default: 0
},
inflowsTrend: {
type: Object,
default: null
},
outflowsTrend: {
type: Object,
default: null
},
inflowsSparkline: {
type: Array,
default: () => []
},
outflowsSparkline: {
type: Array,
default: () => []
},
inflowsPreviousSparkline: {
type: Array,
default: () => []
},
outflowsPreviousSparkline: {
type: Array,
default: () => []
},
sparklineLabels: {
type: Array,
default: () => []
},
previousSparklineLabels: {
type: Array,
default: () => []
}
})
// Refs pentru 2 canvas-uri separate
const inflowsCanvas = ref(null)
const outflowsCanvas = ref(null)
let inflowsChartInstance = null
let outflowsChartInstance = null
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Math.abs(amount))
}
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.inflowsSparkline.length > 0 && props.outflowsSparkline.length > 0
})
// Initialize Încasări chart
const initializeInflowsChart = async () => {
if (!inflowsCanvas.value || props.inflowsSparkline.length === 0) {
return
}
// Destroy existing chart
if (inflowsChartInstance) {
inflowsChartInstance.destroy()
inflowsChartInstance = null
}
await nextTick()
const ctx = inflowsCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.inflowsSparkline.map((_, i) => `L${i + 1}`)
// Prepare datasets
const datasets = [{
label: 'Încasări (curent)',
data: props.inflowsSparkline,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#10b981',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.inflowsPreviousSparkline && props.inflowsPreviousSparkline.length > 0) {
datasets.push({
label: 'Încasări (anul precedent)',
data: props.inflowsPreviousSparkline,
borderColor: 'rgba(16, 185, 129, 0.4)',
backgroundColor: 'rgba(16, 185, 129, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(16, 185, 129, 0.4)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
// Calculate limits including both datasets
const allDataPoints = [...props.inflowsSparkline]
if (props.inflowsPreviousSparkline && props.inflowsPreviousSparkline.length > 0) {
allDataPoints.push(...props.inflowsPreviousSparkline)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2
const targetRange = Math.max(dataRange, minVisibleRange)
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2
let calculatedMax = center + targetRange / 2
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
inflowsChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.9)',
usePointStyle: true,
pointStyle: 'line'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#10b981',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Initialize Plăți chart
const initializeOutflowsChart = async () => {
if (!outflowsCanvas.value || props.outflowsSparkline.length === 0) {
return
}
// Destroy existing chart
if (outflowsChartInstance) {
outflowsChartInstance.destroy()
outflowsChartInstance = null
}
await nextTick()
const ctx = outflowsCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.outflowsSparkline.map((_, i) => `L${i + 1}`)
// Prepare datasets
const datasets = [{
label: 'Plăți (curent)',
data: props.outflowsSparkline,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#ef4444',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.outflowsPreviousSparkline && props.outflowsPreviousSparkline.length > 0) {
datasets.push({
label: 'Plăți (anul precedent)',
data: props.outflowsPreviousSparkline,
borderColor: 'rgba(239, 68, 68, 0.4)',
backgroundColor: 'rgba(239, 68, 68, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(239, 68, 68, 0.4)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
// Calculate limits including both datasets
const allDataPoints = [...props.outflowsSparkline]
if (props.outflowsPreviousSparkline && props.outflowsPreviousSparkline.length > 0) {
allDataPoints.push(...props.outflowsPreviousSparkline)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2
const targetRange = Math.max(dataRange, minVisibleRange)
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2
let calculatedMax = center + targetRange / 2
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
outflowsChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.9)',
usePointStyle: true,
pointStyle: 'line'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#ef4444',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Watch for data changes
watch(() => [
props.inflowsSparkline,
props.outflowsSparkline,
props.sparklineLabels,
props.inflowsPreviousSparkline,
props.outflowsPreviousSparkline,
props.previousSparklineLabels
], async () => {
await Promise.all([
initializeInflowsChart(),
initializeOutflowsChart()
])
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await Promise.all([
initializeInflowsChart(),
initializeOutflowsChart()
])
})
onBeforeUnmount(() => {
if (inflowsChartInstance) {
inflowsChartInstance.destroy()
inflowsChartInstance = null
}
if (outflowsChartInstance) {
outflowsChartInstance.destroy()
outflowsChartInstance = null
}
})
</script>
<style scoped>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.cashflow-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.cashflow-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Values section - Split layout */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
}
/* Dual sparkline container - stack vertical */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-label {
font-size: var(--card-label-size);
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
/* Culori distinctive pentru label-uri */
.sparkline-wrapper:first-child .sparkline-label {
color: #10b981; /* Verde pentru Încasări */
}
.sparkline-wrapper:last-child .sparkline-label {
color: #ef4444; /* Roșu pentru Plăți */
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive */
@media (max-width: 768px) {
.cashflow-card {
min-height: 380px;
padding: var(--space-md, 1rem);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.cashflow-card {
min-height: 380px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.sparkline-chart {
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
}
.sparkline-wrapper {
padding: 0.25rem;
border: 1px solid var(--color-border, #e5e7eb);
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.cashflow-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,625 @@
<template>
<div class="clienti-balance-card">
<!-- Main value section - NO HEADER -->
<div class="value-section">
<div class="value-label">Clienți</div>
<div class="value-amount" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</div>
</div>
<div class="value-trend" :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="sparkline-container" 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">
<span class="collapse-icon">{{ isRestantExpanded ? '▼' : '▶' }}</span>
<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">
<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.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
// 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>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--breakdown-label-size: 0.875rem;
--breakdown-value-size: 0.9375rem;
--breakdown-sub-label-size: 0.8125rem;
--breakdown-sub-value-size: 0.8125rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.clienti-balance-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 320px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.clienti-balance-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Value section */
.value-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.value-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--card-trend-size);
font-weight: 500;
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.625rem;
}
/* Sparkline container */
.sparkline-container {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.625rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.875rem;
}
.breakdown-label {
font-size: var(--breakdown-label-size);
color: var(--color-text, #111827);
font-weight: 500;
font-family: var(--font-sans);
}
.breakdown-value {
font-size: var(--breakdown-value-size);
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: var(--breakdown-sub-label-size);
font-weight: 400;
font-family: var(--font-sans);
}
.breakdown-subvalue {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--breakdown-sub-value-size);
color: var(--color-text, #111827);
}
/* Responsive */
@media (max-width: 768px) {
.clienti-balance-card {
min-height: 280px;
padding: var(--space-md, 1rem);
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.clienti-balance-card {
min-height: 280px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.sparkline-chart {
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
}
.sparkline-container {
padding: 0.25rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.clienti-balance-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,969 @@
<template>
<div class="balance-dual-card">
<!-- Header -->
<div class="card-header">
<span class="card-icon">💰</span>
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
</div>
<!-- Main values section - Split layout -->
<div class="values-section">
<!-- Clienți Section -->
<div class="value-block clienti">
<div class="value-label">Clienți</div>
<div class="value-amount" :class="getBalanceClass(clientiTotal)">
{{ formatCurrency(clientiTotal) }}
</div>
<div class="value-trend" :class="getTrendClass(clientiTrend)" v-if="clientiTrend">
<span class="trend-icon">{{ getTrendIcon(clientiTrend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(clientiTrend.value)) }}%</span>
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Furnizori Section -->
<div class="value-block furnizori">
<div class="value-label">Furnizori</div>
<div class="value-amount" :class="getBalanceClass(furnizoriTotal)">
{{ formatCurrency(furnizoriTotal) }}
</div>
<div class="value-trend" :class="getTrendClass(furnizoriTrend)" v-if="furnizoriTrend">
<span class="trend-icon">{{ getTrendIcon(furnizoriTrend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(furnizoriTrend.value)) }}%</span>
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Clienți -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Clienți</div>
<div class="sparkline-chart">
<canvas ref="clientiCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Furnizori -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Furnizori</div>
<div class="sparkline-chart">
<canvas ref="furnizoriCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Breakdown section -->
<div class="breakdown-section" v-if="breakdown">
<!-- Clienți Breakdown -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleClientiExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isClientiExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Clienți - Detaliere</span>
</div>
<span class="breakdown-value">{{ formatCurrency(breakdown.clienti.total) }}</span>
</div>
<!-- Clienți Sub-items -->
<div v-show="isClientiExpanded" class="breakdown-subitems">
<!-- În termen -->
<div class="breakdown-subitem">
<span class="breakdown-sublabel">În termen</span>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.clienti.in_termen.total) }}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-subitem-group">
<div class="breakdown-subitem-header" @click="toggleClientiRestantExpanded">
<div class="subitem-header-left">
<span class="collapse-icon-small">{{ isClientiRestantExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-sublabel">Restant</span>
</div>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.clienti.restant.total) }}</span>
</div>
<!-- Perioade restante -->
<div v-show="isClientiRestantExpanded" class="breakdown-perioade">
<div class="perioada-item" v-for="(value, key) in breakdown.clienti.restant.perioade" :key="key">
<span class="perioada-label">{{ formatPeriodLabel(key) }}</span>
<span class="perioada-value">{{ formatCurrency(value) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Furnizori Breakdown -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleFurnizoriExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isFurnizoriExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Furnizori - Detaliere</span>
</div>
<span class="breakdown-value">{{ formatCurrency(breakdown.furnizori.total) }}</span>
</div>
<!-- Furnizori Sub-items -->
<div v-show="isFurnizoriExpanded" class="breakdown-subitems">
<!-- În termen -->
<div class="breakdown-subitem">
<span class="breakdown-sublabel">În termen</span>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.furnizori.in_termen.total) }}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-subitem-group">
<div class="breakdown-subitem-header" @click="toggleFurnizoriRestantExpanded">
<div class="subitem-header-left">
<span class="collapse-icon-small">{{ isFurnizoriRestantExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-sublabel">Restant</span>
</div>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.furnizori.restant.total) }}</span>
</div>
<!-- Perioade restante -->
<div v-show="isFurnizoriRestantExpanded" class="breakdown-perioade">
<div class="perioada-item" v-for="(value, key) in breakdown.furnizori.restant.perioade" :key="key">
<span class="perioada-label">{{ formatPeriodLabel(key) }}</span>
<span class="perioada-value">{{ formatCurrency(value) }}</span>
</div>
</div>
</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({
clientiTotal: {
type: Number,
required: true
},
furnizoriTotal: {
type: Number,
required: true
},
clientiTrend: {
type: Object,
default: null
},
furnizoriTrend: {
type: Object,
default: null
},
clientiSparklineData: {
type: Array,
default: () => []
},
furnizoriSparklineData: {
type: Array,
default: () => []
},
sparklineLabels: {
type: Array,
default: () => []
},
breakdown: {
type: Object,
default: null
}
})
// Refs pentru 2 canvas-uri separate
const clientiCanvas = ref(null)
const furnizoriCanvas = ref(null)
let clientiChartInstance = null
let furnizoriChartInstance = null
const isClientiExpanded = ref(false)
const isFurnizoriExpanded = ref(false)
const isClientiRestantExpanded = ref(false)
const isFurnizoriRestantExpanded = ref(false)
// Toggle functions
const toggleClientiExpanded = () => {
isClientiExpanded.value = !isClientiExpanded.value
}
const toggleFurnizoriExpanded = () => {
isFurnizoriExpanded.value = !isFurnizoriExpanded.value
}
const toggleClientiRestantExpanded = () => {
isClientiRestantExpanded.value = !isClientiRestantExpanded.value
}
const toggleFurnizoriRestantExpanded = () => {
isFurnizoriRestantExpanded.value = !isFurnizoriRestantExpanded.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.clientiSparklineData.length > 0 && props.furnizoriSparklineData.length > 0
})
// Initialize Clienți chart
const initializeClientiChart = async () => {
if (!clientiCanvas.value || props.clientiSparklineData.length === 0) {
return
}
// Destroy existing chart
if (clientiChartInstance) {
clientiChartInstance.destroy()
clientiChartInstance = null
}
await nextTick()
const ctx = clientiCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.clientiSparklineData.map((_, i) => `L${i + 1}`)
// Calculează limite pentru clienți
const clientiMin = Math.min(...props.clientiSparklineData)
const clientiMax = Math.max(...props.clientiSparklineData)
const clientiRange = clientiMax - clientiMin
const clientiPadding = clientiRange * 0.05
clientiChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Clienți',
data: props.clientiSparklineData,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0, // Ascunde punctele pentru a economisi spațiu
pointHoverRadius: 4,
pointHoverBackgroundColor: '#10b981',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false // Ascunde legenda - e clar din label că e "Clienți"
},
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) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const formatted = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return formatted
}
}
}
},
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: clientiMin - clientiPadding,
max: clientiMax + clientiPadding,
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 Furnizori chart
const initializeFurnizoriChart = async () => {
if (!furnizoriCanvas.value || props.furnizoriSparklineData.length === 0) {
return
}
// Destroy existing chart
if (furnizoriChartInstance) {
furnizoriChartInstance.destroy()
furnizoriChartInstance = null
}
await nextTick()
const ctx = furnizoriCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.furnizoriSparklineData.map((_, i) => `L${i + 1}`)
// Calculează limite pentru furnizori
const furnizoriMin = Math.min(...props.furnizoriSparklineData)
const furnizoriMax = Math.max(...props.furnizoriSparklineData)
const furnizoriRange = furnizoriMax - furnizoriMin
const furnizoriPadding = furnizoriRange * 0.05
furnizoriChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Furnizori',
data: props.furnizoriSparklineData,
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
}
]
},
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) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const formatted = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return formatted
}
}
}
},
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: furnizoriMin - furnizoriPadding,
max: furnizoriMax + furnizoriPadding,
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.clientiSparklineData, props.furnizoriSparklineData, props.sparklineLabels], async () => {
await Promise.all([
initializeClientiChart(),
initializeFurnizoriChart()
])
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await Promise.all([
initializeClientiChart(),
initializeFurnizoriChart()
])
})
onBeforeUnmount(() => {
if (clientiChartInstance) {
clientiChartInstance.destroy()
clientiChartInstance = null
}
if (furnizoriChartInstance) {
furnizoriChartInstance.destroy()
furnizoriChartInstance = null
}
})
</script>
<style scoped>
.balance-dual-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.balance-dual-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Header */
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.card-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-sm, 4px);
}
.card-title {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Values section - Split layout */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.value-label {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--font-medium, 500);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.value-amount {
font-size: var(--text-xl, 1.5rem);
font-weight: var(--font-bold, 700);
line-height: 1.2;
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.value-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.75rem;
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
}
/* Dual sparkline container - stack vertical */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-label {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Culori distinctive pentru label-uri */
.sparkline-wrapper:first-child .sparkline-label {
color: #10b981; /* Verde pentru Clienți */
}
.sparkline-wrapper:last-child .sparkline-label {
color: #ef4444; /* Roșu pentru Furnizori */
}
.sparkline-chart {
width: 100%;
height: 120px; /* Înălțime mărită pentru fiecare grafic individual */
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
font-weight: var(--font-semibold, 600);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 1rem;
}
.breakdown-label {
font-size: 0.9rem;
color: var(--color-text, #111827);
}
.breakdown-value {
font-size: 1rem;
font-weight: var(--font-semibold, 600);
font-family: monospace;
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
}
.breakdown-subitem-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-subitem-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-subitem-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.subitem-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon-small {
font-size: 0.65rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.75rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: 0.9rem;
}
.breakdown-subvalue {
font-weight: var(--font-medium, 500);
font-family: monospace;
font-size: 1rem;
color: var(--color-text, #111827);
}
.breakdown-perioade {
padding-left: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
animation: slideDown 0.2s ease-out;
}
.perioada-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
}
.perioada-label {
color: var(--color-text-secondary, #6b7280);
font-size: 0.9rem;
}
.perioada-value {
font-family: monospace;
font-weight: var(--font-medium, 500);
color: var(--color-text, #111827);
font-size: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.balance-dual-card {
min-height: 380px;
padding: var(--space-md, 1rem);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.value-amount {
font-size: var(--text-lg, 1.125rem);
}
.sparkline-chart {
height: 100px;
}
}
@media (max-width: 480px) {
.balance-dual-card {
min-height: 340px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
border-radius: 0;
}
.card-header {
padding: 0.25rem;
margin-bottom: 0.25rem;
}
.card-icon {
width: 1.25rem;
height: 1.25rem;
font-size: 0.875rem;
}
.card-title {
font-size: 0.625rem;
}
.value-amount {
font-size: var(--text-base, 1rem);
}
.sparkline-chart {
height: 120px;
}
.sparkline-wrapper {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.balance-dual-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,625 @@
<template>
<div class="furnizori-balance-card">
<!-- Main value section - NO HEADER -->
<div class="value-section">
<div class="value-label">Furnizori</div>
<div class="value-amount" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</div>
</div>
<div class="value-trend" :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="sparkline-container" 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">
<span class="collapse-icon">{{ isRestantExpanded ? '▼' : '▶' }}</span>
<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">
<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.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
// 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>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--breakdown-label-size: 0.875rem;
--breakdown-value-size: 0.9375rem;
--breakdown-sub-label-size: 0.8125rem;
--breakdown-sub-value-size: 0.8125rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.furnizori-balance-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 320px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.furnizori-balance-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Value section */
.value-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.value-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--card-trend-size);
font-weight: 500;
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.625rem;
}
/* Sparkline container */
.sparkline-container {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.625rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.875rem;
}
.breakdown-label {
font-size: var(--breakdown-label-size);
color: var(--color-text, #111827);
font-weight: 500;
font-family: var(--font-sans);
}
.breakdown-value {
font-size: var(--breakdown-value-size);
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: var(--breakdown-sub-label-size);
font-weight: 400;
font-family: var(--font-sans);
}
.breakdown-subvalue {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--breakdown-sub-value-size);
color: var(--color-text, #111827);
}
/* Responsive */
@media (max-width: 768px) {
.furnizori-balance-card {
min-height: 280px;
padding: var(--space-md, 1rem);
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.furnizori-balance-card {
min-height: 280px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.sparkline-chart {
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
}
.sparkline-container {
padding: 0.25rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.furnizori-balance-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,775 @@
<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 '../../../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

View File

@@ -0,0 +1,708 @@
<template>
<div class="metric-card">
<!-- Header with icon and title -->
<div class="metric-header">
<span class="metric-icon" :class="iconClass">{{ icon }}</span>
<span class="metric-title">{{ title }}</span>
</div>
<!-- Main value display -->
<div class="metric-value" :class="valueClass">
{{ formatCurrency(value) }}
</div>
<!-- Trend indicator -->
<div class="metric-trend" :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="metric-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-item breakdown-header" @click="() => toggleExpanded(key)">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isItemExpanded(key) ? '▼' : '▶' }}</span>
<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">
<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>
.metric-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 280px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.metric-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Header */
.metric-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.metric-icon {
font-size: 1.25rem;
display: inline-block;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-sm, 4px);
transition: all 0.3s ease;
}
.metric-card:hover .metric-icon {
background: var(--color-primary-light, #dbeafe);
}
.metric-title {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Value */
.metric-value {
font-size: var(--text-2xl, 1.875rem);
font-weight: var(--font-bold, 700);
color: var(--color-text-primary, #111827);
line-height: 1.2;
margin-bottom: 0.5rem;
}
.metric-value.positive {
color: var(--color-success, #10b981);
}
.metric-value.negative {
color: var(--color-danger, #ef4444);
}
/* Trend - Positioned below value */
.metric-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
margin-bottom: 0.75rem;
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.75rem;
}
.trend-value {
font-weight: var(--font-semibold, 600);
}
/* Sparkline Container - STACKED LAYOUT (Best Practice) */
.metric-sparkline-container {
width: 100%;
height: 80px;
margin-bottom: 0.75rem;
border-radius: 4px;
position: relative;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive design */
@media (max-width: 768px) {
.metric-card {
min-height: 240px;
padding: var(--space-md, 1rem);
}
.metric-value {
font-size: var(--text-xl, 1.5rem);
margin-bottom: 0.375rem;
}
.metric-trend {
margin-bottom: 0.5rem;
}
.metric-sparkline-container {
height: 70px;
margin-bottom: 0.5rem;
}
}
@media (max-width: 480px) {
.metric-card {
min-height: 200px;
padding: var(--space-sm, 0.75rem);
gap: 0.5rem;
}
.metric-icon {
width: 1.5rem;
height: 1.5rem;
font-size: 1rem;
}
.metric-title {
font-size: var(--text-xs, 0.75rem);
}
.metric-value {
font-size: var(--text-lg, 1.125rem);
margin-bottom: 0.25rem;
}
.metric-trend {
font-size: var(--text-xs, 0.75rem);
margin-bottom: 0.375rem;
}
.metric-sparkline-container {
height: 60px;
margin-bottom: 0.5rem;
}
}
/* CSS Custom Properties fallbacks */
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-border: #e5e7eb;
--color-primary: #3b82f6;
--color-primary-light: #dbeafe;
--color-success: #10b981;
--color-danger: #ef4444;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--card-radius: 8px;
--radius-sm: 4px;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-lg: 1.125rem;
--text-xl: 1.5rem;
--text-2xl: 1.875rem;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
}
/* Breakdown section */
.metric-breakdown {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.breakdown-section {
margin-bottom: 0.875rem;
}
.breakdown-section:last-child {
margin-bottom: 0;
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 1rem;
}
.breakdown-item:last-child {
margin-bottom: 0;
}
.breakdown-label {
font-size: 1rem;
color: var(--color-text-secondary, #6b7280);
font-weight: var(--font-medium, 500);
}
.breakdown-value {
font-size: 1.125rem;
color: var(--color-text-primary, #111827);
font-weight: var(--font-semibold, 600);
font-family: monospace;
}
/* Hierarchical breakdown styles */
.breakdown-group {
margin-bottom: 0.875rem;
}
.breakdown-header {
font-weight: var(--font-semibold, 600);
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 0.5rem;
margin-bottom: 0.625rem;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
padding: 0.25rem 0.5rem;
margin: -0.25rem -0.5rem 0.625rem -0.5rem;
border-radius: var(--radius-sm, 4px);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 1rem;
}
.breakdown-subitems {
padding-left: 0;
border-left: none;
margin-top: 0.625rem;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
gap: 0.75rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: 0.875rem;
}
.breakdown-cont {
font-size: 0.875rem;
opacity: 0.7;
margin-left: 0.25rem;
}
.breakdown-subvalue {
font-weight: var(--font-medium, 500);
font-family: monospace;
font-size: 0.875rem;
color: var(--color-text-primary, #111827);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.metric-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text-primary: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,915 @@
<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 '../../../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>

View File

@@ -0,0 +1,858 @@
<template>
<div class="treasury-dual-card">
<!-- Main values section - Split layout (Casa | Bancă) -->
<div class="values-section">
<!-- Casa Section -->
<div class="value-block casa">
<div class="value-label">Casa</div>
<div class="value-amount positive">
{{ formatCurrency(casaTotal) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Bancă Section -->
<div class="value-block banca">
<div class="value-label">Bancă</div>
<div class="value-amount positive">
{{ 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-label">Casa</div>
<div class="sparkline-chart">
<canvas ref="casaCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Bancă -->
<div class="sparkline-wrapper">
<div class="sparkline-label">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">
<span class="collapse-icon">{{ isCasaExpanded ? '▼' : '▶' }}</span>
<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">
<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">
<span class="collapse-icon">{{ isBancaExpanded ? '▼' : '▶' }}</span>
<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">
<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>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--breakdown-label-size: 0.875rem;
--breakdown-value-size: 0.9375rem;
--breakdown-sub-label-size: 0.8125rem;
--breakdown-sub-value-size: 0.8125rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.treasury-dual-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.treasury-dual-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Values section - Split layout */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
}
/* Dual sparkline container - stack vertical */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-label {
font-size: var(--card-label-size);
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
/* Culori distinctive pentru label-uri */
.sparkline-wrapper:first-child .sparkline-label {
color: #10b981; /* Verde pentru Casa */
}
.sparkline-wrapper:last-child .sparkline-label {
color: #3b82f6; /* Albastru pentru Bancă */
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.625rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.875rem;
}
.breakdown-label {
font-size: var(--breakdown-label-size);
color: var(--color-text, #111827);
font-weight: 500;
font-family: var(--font-sans);
}
.breakdown-value {
font-size: var(--breakdown-value-size);
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: var(--breakdown-sub-label-size);
font-weight: 400;
font-family: var(--font-sans);
}
.breakdown-cont {
font-size: var(--breakdown-sub-label-size);
opacity: 0.7;
margin-left: 0.25rem;
}
.breakdown-subvalue {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--breakdown-sub-value-size);
color: var(--color-text, #111827);
}
/* Responsive */
@media (max-width: 768px) {
.treasury-dual-card {
min-height: 380px;
padding: var(--space-md, 1rem);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.treasury-dual-card {
min-height: 340px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
border-radius: 0;
margin: 0;
}
.sparkline-chart {
height: 120px;
}
.sparkline-wrapper {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.treasury-dual-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,255 @@
<template>
<header class="header-container">
<nav class="header-nav">
<!-- Left side: Brand + Hamburger -->
<div class="flex items-center gap-4">
<button
class="hamburger-btn"
:class="{ active: menuOpen }"
@click="toggleMenu"
aria-label="Toggle navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<router-link to="/dashboard" class="header-brand">
<span>ROA2WEB</span>
</router-link>
</div>
<!-- Right side: Company + User -->
<div class="header-actions">
<CompanySelectorMini
v-model="selectedCompany"
@company-changed="onCompanyChanged"
/>
<div class="user-menu-container">
<div class="header-user" @click="toggleUserMenu">
<i class="pi pi-user"></i>
<span class="desktop-only">{{ currentUser?.username || 'User' }}</span>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': userMenuOpen }"></i>
</div>
<!-- User Dropdown Menu -->
<div v-if="userMenuOpen" class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-info">
<div class="user-name">{{ currentUser?.username || 'User' }}</div>
<div class="user-email">{{ currentUser?.email || '' }}</div>
</div>
</div>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="navigateToTelegram">
<i class="pi pi-telegram"></i>
<span>Telegram Bot</span>
</button>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="handleLogout">
<i class="pi pi-sign-out"></i>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<!-- Overlay for user menu -->
<div
v-if="userMenuOpen"
class="user-menu-overlay"
@click="closeUserMenu"
></div>
</header>
</template>
<script>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import CompanySelectorMini from '../dashboard/CompanySelectorMini.vue'
import { useCompanyStore } from '../../stores/companies'
import { useAuthStore } from '../../stores/auth'
export default {
name: 'DashboardHeader',
components: {
CompanySelectorMini
},
emits: ['menu-toggle', 'company-changed'],
setup(props, { emit }) {
const router = useRouter()
const companiesStore = useCompanyStore()
const authStore = useAuthStore()
const menuOpen = ref(false)
const userMenuOpen = ref(false)
const selectedCompany = computed({
get: () => companiesStore.selectedCompany,
set: (value) => companiesStore.setSelectedCompany(value)
})
const currentUser = computed(() => authStore.currentUser)
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
emit('menu-toggle', menuOpen.value)
}
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value
}
const closeUserMenu = () => {
userMenuOpen.value = false
}
const onCompanyChanged = (company) => {
emit('company-changed', company)
}
const navigateToTelegram = async () => {
try {
closeUserMenu()
await router.push('/telegram')
} catch (error) {
console.error('Navigation error:', error)
}
}
const handleLogout = async () => {
try {
authStore.logout()
closeUserMenu()
await router.push('/login')
} catch (error) {
console.error('Logout error:', error)
}
}
return {
menuOpen,
userMenuOpen,
selectedCompany,
currentUser,
toggleMenu,
toggleUserMenu,
closeUserMenu,
onCompanyChanged,
navigateToTelegram,
handleLogout
}
}
}
</script>
<style scoped>
/* User Menu Container */
.user-menu-container {
position: relative;
}
/* User Dropdown */
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
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);
overflow: hidden;
}
.user-dropdown-header {
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.user-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.user-name {
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
}
.user-email {
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.user-dropdown-divider {
height: 1px;
background: var(--color-border);
}
.user-dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
color: var(--color-text);
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.user-dropdown-item:hover {
background: var(--color-bg-secondary);
}
.user-dropdown-item:focus {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
background: var(--color-bg-secondary);
}
/* User Menu Overlay */
.user-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: transparent;
}
/* Chevron rotation animation */
.rotate-180 {
transform: rotate(180deg);
transition: transform var(--transition-fast);
}
.pi-chevron-down {
transition: transform var(--transition-fast);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.user-dropdown {
min-width: 200px;
}
.user-dropdown-header {
padding: var(--space-sm);
}
.user-dropdown-item {
padding: var(--space-sm);
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div>
<!-- Menu Overlay -->
<div
class="slide-menu-overlay"
:class="{ open: isOpen }"
@click="closeMenu"
></div>
<!-- Slide Menu -->
<nav class="slide-menu" :class="{ open: isOpen }">
<!-- Navigation Section -->
<div class="menu-section">
<h3 class="menu-title">Navigation</h3>
<ul class="menu-list">
<li class="menu-item">
<router-link
to="/dashboard"
class="menu-link"
:class="{ active: $route.name === 'Dashboard' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-home"></i>
<span>Dashboard</span>
</router-link>
</li>
<li class="menu-item">
<router-link
to="/invoices"
class="menu-link"
:class="{ active: $route.name === 'Invoices' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-file-text"></i>
<span>Invoices</span>
</router-link>
</li>
<li class="menu-item">
<router-link
to="/bank-cash-register"
class="menu-link"
:class="{ active: $route.name === 'BankCashRegister' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-money-bill"></i>
<span>Bank & Cash</span>
</router-link>
</li>
</ul>
</div>
</nav>
</div>
</template>
<script>
export default {
name: 'HamburgerMenu',
props: {
isOpen: {
type: Boolean,
default: false
}
},
emits: ['close'],
setup(props, { emit }) {
const closeMenu = () => {
emit('close')
}
return {
closeMenu
}
}
}
</script>